Merge pull request #29 from XYenon/feat/auto-theme-mode

feat: add auto theme mode (follow system preference)
This commit is contained in:
Supra4E8C
2025-12-26 18:04:40 +08:00
committed by GitHub
7 changed files with 155 additions and 72 deletions

View File

@@ -31,10 +31,11 @@ function App() {
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
initializeTheme();
const cleanupTheme = initializeTheme();
void restoreSession().finally(() => {
setAuthReady(true);
});
return cleanupTheme;
}, [initializeTheme, restoreSession]);
useEffect(() => {

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 { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
@@ -14,10 +22,16 @@ import {
IconScrollText,
IconSettings,
IconShield,
IconSlidersHorizontal
IconSlidersHorizontal,
} from '@/components/ui/icons';
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';
const sidebarIcons: Record<string, ReactNode> = {
@@ -30,7 +44,7 @@ const sidebarIcons: Record<string, ReactNode> = {
usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />,
system: <IconInfo size={18} />
system: <IconInfo size={18} />,
};
// Header action icons - smaller size for header buttons
@@ -44,7 +58,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
focusable: 'false'
focusable: 'false',
};
const headerIcons = {
@@ -97,19 +111,38 @@ const headerIcons = {
<path d="m19.07 4.93-1.41 1.41" />
</svg>
),
moon: (
<svg {...headerIconProps}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg>
),
logout: (
<svg {...headerIconProps}>
<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="M21 12H9" />
</svg>
)
};
moon: (
<svg {...headerIconProps}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg>
),
autoTheme: (
<svg {...headerIconProps}>
<defs>
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
<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(#mainLayoutAutoThemeSunLeftHalf)" 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: (
<svg {...headerIconProps}>
<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="M21 12H9" />
</svg>
),
};
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
@@ -153,7 +186,7 @@ export function MainLayout() {
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
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 [sidebarOpen, setSidebarOpen] = useState(false);
@@ -187,7 +220,9 @@ export function MainLayout() {
updateHeaderHeight();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null;
typeof ResizeObserver !== 'undefined' && headerRef.current
? new ResizeObserver(updateHeaderHeight)
: null;
if (resizeObserver && 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: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
];
const handleRefreshAll = async () => {
@@ -370,7 +407,11 @@ export function MainLayout() {
<button
className="sidebar-toggle-header"
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}
</button>
@@ -400,20 +441,40 @@ export function MainLayout() {
</div>
<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}
</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}
</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}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language}
</Button>
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}>
{theme === 'dark' ? headerIcons.sun : headerIcons.moon}
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
@@ -423,7 +484,9 @@ export function MainLayout() {
</header>
<div className="main-body">
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="nav-section">
{navItems.map((item) => (
<NavLink
@@ -454,7 +517,9 @@ export function MainLayout() {
</span>
<span>
{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>
</footer>
</div>

View File

@@ -17,6 +17,7 @@ import styles from './AuthFilesPage.module.scss';
type ThemeColors = { bg: string; text: string; border?: string };
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
type ResolvedTheme = 'light' | 'dark';
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
const TYPE_COLORS: Record<string, TypeColorSet> = {
@@ -129,7 +130,7 @@ export function AuthFilesPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const theme = useThemeStore((state) => state.theme);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -488,7 +489,7 @@ export function AuthFilesPage() {
// 获取类型颜色
const getTypeColor = (type: string): ThemeColors => {
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return theme === 'dark' && set.dark ? set.dark : set.light;
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
// OAuth 排除相关方法
@@ -547,7 +548,7 @@ export function AuthFilesPage() {
{existingTypes.map((type) => {
const isActive = filter === type;
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
const activeTextColor = theme === 'dark' ? '#111827' : '#fff';
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
return (
<button
key={type}

View File

@@ -16,7 +16,7 @@ export function ConfigPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const theme = useThemeStore((state) => state.theme);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
@@ -289,7 +289,7 @@ export function ConfigPage() {
value={content}
onChange={handleChange}
extensions={extensions}
theme={theme === 'dark' ? 'dark' : 'light'}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
height="100%"

View File

@@ -64,8 +64,8 @@ interface UsagePayload {
export function UsagePage() {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const theme = useThemeStore((state) => state.theme);
const isDark = theme === 'dark';
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);

View File

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