feat: add auto theme mode (follow system preference)

- Add 'auto' to Theme type
- Implement cycleTheme (light -> dark -> auto)
- Add autoTheme icon (sun with half-filled center)
- Listen to system theme changes in auto mode

Also includes some Prettier formatting fixes.
This commit is contained in:
XYenon
2025-12-24 00:02:59 +08:00
parent 561e06503c
commit 5f7df33469
3 changed files with 134 additions and 60 deletions

View File

@@ -1,4 +1,12 @@
import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import {
ReactNode,
SVGProps,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -14,10 +22,16 @@ import {
IconScrollText, IconScrollText,
IconSettings, IconSettings,
IconShield, IconShield,
IconSlidersHorizontal IconSlidersHorizontal,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores'; import {
useAuthStore,
useConfigStore,
useLanguageStore,
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api'; import { configApi, versionApi } from '@/services/api';
const sidebarIcons: Record<string, ReactNode> = { const sidebarIcons: Record<string, ReactNode> = {
@@ -30,7 +44,7 @@ const sidebarIcons: Record<string, ReactNode> = {
usage: <IconChartLine size={18} />, usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />, config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />, logs: <IconScrollText size={18} />,
system: <IconInfo size={18} /> system: <IconInfo size={18} />,
}; };
// Header action icons - smaller size for header buttons // Header action icons - smaller size for header buttons
@@ -44,7 +58,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
strokeLinecap: 'round', strokeLinecap: 'round',
strokeLinejoin: 'round', strokeLinejoin: 'round',
'aria-hidden': 'true', 'aria-hidden': 'true',
focusable: 'false' focusable: 'false',
}; };
const headerIcons = { const headerIcons = {
@@ -102,13 +116,32 @@ const headerIcons = {
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" /> <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg> </svg>
), ),
autoTheme: (
<svg {...headerIconProps}>
<defs>
<clipPath id="sunLeftHalf">
<rect x="0" y="0" width="12" height="24" />
</clipPath>
</defs>
<circle cx="12" cy="12" r="4" />
<circle cx="12" cy="12" r="4" clipPath="url(#sunLeftHalf)" fill="currentColor" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="M4.93 4.93l1.41 1.41" />
<path d="M17.66 17.66l1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="M6.34 17.66l-1.41 1.41" />
<path d="M19.07 4.93l-1.41 1.41" />
</svg>
),
logout: ( logout: (
<svg {...headerIconProps}> <svg {...headerIconProps}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" /> <path d="m16 17 5-5-5-5" />
<path d="M21 12H9" /> <path d="M21 12H9" />
</svg> </svg>
) ),
}; };
const parseVersionSegments = (version?: string | null) => { const parseVersionSegments = (version?: string | null) => {
@@ -153,7 +186,7 @@ export function MainLayout() {
const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const toggleTheme = useThemeStore((state) => state.toggleTheme); const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage); const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -187,7 +220,9 @@ export function MainLayout() {
updateHeaderHeight(); updateHeaderHeight();
const resizeObserver = const resizeObserver =
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null; typeof ResizeObserver !== 'undefined' && headerRef.current
? new ResizeObserver(updateHeaderHeight)
: null;
if (resizeObserver && headerRef.current) { if (resizeObserver && headerRef.current) {
resizeObserver.observe(headerRef.current); resizeObserver.observe(headerRef.current);
} }
@@ -320,8 +355,10 @@ export function MainLayout() {
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth }, { path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage }, { path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config }, { path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []), ...(config?.loggingToFile
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system } ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
]; ];
const handleRefreshAll = async () => { const handleRefreshAll = async () => {
@@ -370,7 +407,11 @@ export function MainLayout() {
<button <button
className="sidebar-toggle-header" className="sidebar-toggle-header"
onClick={() => setSidebarCollapsed((prev) => !prev)} onClick={() => setSidebarCollapsed((prev) => !prev)}
title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })} title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
> >
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft} {sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button> </button>
@@ -400,20 +441,40 @@ export function MainLayout() {
</div> </div>
<div className="header-actions"> <div className="header-actions">
<Button className="mobile-menu-btn" variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}> <Button
className="mobile-menu-btn"
variant="ghost"
size="sm"
onClick={() => setSidebarOpen((prev) => !prev)}
>
{headerIcons.menu} {headerIcons.menu}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={handleRefreshAll} title={t('header.refresh_all')}> <Button
variant="ghost"
size="sm"
onClick={handleRefreshAll}
title={t('header.refresh_all')}
>
{headerIcons.refresh} {headerIcons.refresh}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion} title={t('system_info.version_check_button')}> <Button
variant="ghost"
size="sm"
onClick={handleVersionCheck}
loading={checkingVersion}
title={t('system_info.version_check_button')}
>
{headerIcons.update} {headerIcons.update}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}> <Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language} {headerIcons.language}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}> <Button variant="ghost" size="sm" onClick={cycleTheme} title={t(`theme.${theme}`)}>
{theme === 'dark' ? headerIcons.sun : headerIcons.moon} {theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}> <Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout} {headerIcons.logout}
@@ -423,7 +484,9 @@ export function MainLayout() {
</header> </header>
<div className="main-body"> <div className="main-body">
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}> <aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="nav-section"> <div className="nav-section">
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
@@ -454,7 +517,9 @@ export function MainLayout() {
</span> </span>
<span> <span>
{t('footer.build_date')}:{' '} {t('footer.build_date')}:{' '}
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')} {serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span> </span>
</footer> </footer>
</div> </div>

View File

@@ -8,63 +8,72 @@ import { persist } from 'zustand/middleware';
import type { Theme } from '@/types'; import type { Theme } from '@/types';
import { STORAGE_KEY_THEME } from '@/utils/constants'; import { STORAGE_KEY_THEME } from '@/utils/constants';
type ResolvedTheme = 'light' | 'dark';
interface ThemeState { interface ThemeState {
theme: Theme; theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; cycleTheme: () => void;
initializeTheme: () => void; initializeTheme: () => void;
} }
export const useThemeStore = create<ThemeState>()( const getSystemTheme = (): ResolvedTheme => {
persist( if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
(set, get) => ({ return 'dark';
theme: 'light', }
return 'light';
};
setTheme: (theme) => { const applyTheme = (resolved: ResolvedTheme) => {
// 应用主题到 DOM if (resolved === 'dark') {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
} else { } else {
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
} }
};
set({ theme }); export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'auto',
resolvedTheme: 'light',
setTheme: (theme) => {
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
applyTheme(resolved);
set({ theme, resolvedTheme: resolved });
}, },
toggleTheme: () => { cycleTheme: () => {
const { theme, setTheme } = get(); const { theme, setTheme } = get();
const newTheme: Theme = theme === 'light' ? 'dark' : 'light'; const order: Theme[] = ['light', 'dark', 'auto'];
setTheme(newTheme); const currentIndex = order.indexOf(theme);
const nextTheme = order[(currentIndex + 1) % order.length];
setTheme(nextTheme);
}, },
initializeTheme: () => { initializeTheme: () => {
const { theme, setTheme } = get(); const { theme, setTheme } = get();
// 检查系统偏好
if (
!localStorage.getItem(STORAGE_KEY_THEME) &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setTheme('dark');
return;
}
// 应用已保存的主题 // 应用已保存的主题
setTheme(theme); setTheme(theme);
// 监听系统主题变化(仅在用户未手动设置时 // 监听系统主题变化(仅在 auto 模式下生效
if (window.matchMedia) { if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!localStorage.getItem(STORAGE_KEY_THEME)) { const { theme: currentTheme } = get();
setTheme(e.matches ? 'dark' : 'light'); if (currentTheme === 'auto') {
const resolved = getSystemTheme();
applyTheme(resolved);
set({ resolvedTheme: resolved });
} }
}); });
} }
} },
}), }),
{ {
name: STORAGE_KEY_THEME name: STORAGE_KEY_THEME,
} }
) )
); );

View File

@@ -2,7 +2,7 @@
* 通用类型定义 * 通用类型定义
*/ */
export type Theme = 'light' | 'dark'; export type Theme = 'light' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en'; export type Language = 'zh-CN' | 'en';