feat(panel): 深色模式(跟随系统/亮色/深色),主页顶栏图标切换

- 主页顶栏最右加主题图标按钮,循环切换 跟随系统 / 亮色 / 深色,存浏览器 localStorage。
- index.html 内联脚本首屏前置 data-theme,避免亮/暗闪烁。
- styles.css 加牛奶布艺·暗色变量(亮蓝黑 base、surface 更高一档、近纯黑阴影、极淡顶面高光),
  data-theme=dark 强制深色、=auto 跟随 prefers-color-scheme、=light 维持亮色。
  界面基于 CSS 变量,覆盖核心变量即整体适配。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-18 17:35:49 +08:00
parent bf99cc2116
commit 60dd6d018f
4 changed files with 130 additions and 0 deletions

View File

@@ -10,6 +10,15 @@
<meta name="apple-mobile-web-app-title" content="云微" />
<link rel="apple-touch-icon" href="/icon-180.png" />
<title>云微</title>
<script>
// 首屏前置好主题,避免亮/暗闪烁。auto/light/dark缺省 autoCSS 用 prefers-color-scheme 决定)。
try {
var t = localStorage.getItem('woc_theme');
document.documentElement.dataset.theme = t === 'light' || t === 'dark' ? t : 'auto';
} catch (e) {
document.documentElement.dataset.theme = 'auto';
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -4,6 +4,7 @@ import { useAuth } from './auth';
import { useUI, PasswordInput } from './ui';
import { api, appProfile, type InstanceWithStatus } from './api';
import { InstanceIcon } from './AppIcon';
import { getThemeMode, applyThemeMode, nextThemeMode, type ThemeMode } from './theme';
import InstanceView from './pages/Desktop';
import Admin from './pages/Admin';
@@ -240,6 +241,41 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
);
}
// 主题切换图标(跟随系统 / 亮色 / 深色 循环)。
const themeIcon: Record<ThemeMode, JSX.Element> = {
auto: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none" />
</svg>
),
light: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.4 1.4M17.6 17.6L19 19M19 5l-1.4 1.4M6.4 17.6L5 19" />
</svg>
),
dark: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 14.5A8 8 0 0 1 9.5 4a7 7 0 1 0 10.5 10.5z" />
</svg>
),
};
function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>(() => getThemeMode());
const cycle = () => {
const m = nextThemeMode(mode);
applyThemeMode(m);
setMode(m);
};
const label = mode === 'auto' ? '跟随系统' : mode === 'light' ? '亮色' : '深色';
return (
<button className="theme-toggle" onClick={cycle} title={`主题:${label}(点击切换:跟随系统 / 亮色 / 深色)`} aria-label={`主题:${label}`}>
{themeIcon[mode]}
</button>
);
}
function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
const { user } = useAuth();
const { instances, loaded } = useInstances();
@@ -253,6 +289,7 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
{Icon.menu}
</button>
<span className="ws-title"></span>
<ThemeToggle />
</header>
<div className="content">

View File

@@ -33,6 +33,36 @@
rgba(255, 255, 255, 0.16) 38%,
transparent 74%
);
color-scheme: light;
}
/* ---------- 深色模式牛奶布艺·暗base=亮蓝黑、surface 更高一档、阴影近纯黑、顶面高光极淡 ---------- */
/* data-theme=dark 强制深色data-theme=auto 跟随系统 prefers-color-schemedata-theme=light 维持亮色。 */
:root[data-theme='dark'] {
color-scheme: dark;
--base: #14161c;
--surface: #23262f;
--trough: #0e0f14;
--shadow: 0 0 0;
--text: #e7eaef;
--muted: #969ca6;
--crease: 0 1px 3px rgba(0 0 0 / 0.55), 0 2px 10px rgba(0 0 0 / 0.45);
--crease-press: 0 1px 2px rgba(0 0 0 / 0.4), 0 1px 3px rgba(0 0 0 / 0.25);
--sheen: radial-gradient(ellipse at 50% 28%, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 40%, transparent 75%);
}
@media (prefers-color-scheme: dark) {
:root[data-theme='auto'] {
color-scheme: dark;
--base: #14161c;
--surface: #23262f;
--trough: #0e0f14;
--shadow: 0 0 0;
--text: #e7eaef;
--muted: #969ca6;
--crease: 0 1px 3px rgba(0 0 0 / 0.55), 0 2px 10px rgba(0 0 0 / 0.45);
--crease-press: 0 1px 2px rgba(0 0 0 / 0.4), 0 1px 3px rgba(0 0 0 / 0.25);
--sheen: radial-gradient(ellipse at 50% 28%, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 40%, transparent 75%);
}
}
* {
@@ -1608,6 +1638,32 @@ button {
cursor: pointer;
box-shadow: var(--crease);
}
/* 主题切换:顶栏最右的图标按钮 */
.theme-toggle {
margin-left: auto;
flex: none;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
color: var(--muted);
border-radius: 10px;
transition:
background 0.15s,
color 0.15s,
transform 0.15s;
}
.theme-toggle:hover {
color: var(--text);
background: rgba(var(--shadow) / 0.06);
}
.theme-toggle:active {
transform: scale(0.94);
}
.ws-action:active {
transform: scale(0.96);
}

28
panel/web/src/theme.ts Normal file
View File

@@ -0,0 +1,28 @@
// 主题auto跟随系统/ light / dark。存浏览器 localStorage应用到 <html data-theme>。
// 首屏无闪烁由 index.html 里的内联脚本提前置好 data-theme这里负责读取与切换。
export type ThemeMode = 'auto' | 'light' | 'dark';
const KEY = 'woc_theme';
export function getThemeMode(): ThemeMode {
try {
const v = localStorage.getItem(KEY);
if (v === 'light' || v === 'dark' || v === 'auto') return v;
} catch {
/* 隐私模式禁用 localStorage */
}
return 'auto';
}
export function applyThemeMode(m: ThemeMode): void {
try {
localStorage.setItem(KEY, m);
} catch {
/* ignore */
}
document.documentElement.dataset.theme = m;
}
// 循环切换顺序:跟随系统 → 亮色 → 深色 → 跟随系统
export function nextThemeMode(m: ThemeMode): ThemeMode {
return m === 'auto' ? 'light' : m === 'light' ? 'dark' : 'auto';
}