feat(ui): add persistent light and dark theme support

This commit is contained in:
lzt404 committed 2026-07-29 01:53:22 +08:00
1 parent 7b9566ef73
commit a9342e5efe
6 files changed
+1070 -676

No files matched your search

+24
View File
@@ -14,10 +14,12 @@ import {
Languages,
LogIn,
MessageCircle,
Moon,
Network,
PackageOpen,
ServerCog,
Settings,
Sun,
X,
} from 'lucide-react';
import appLogo from './assets/logo.jpg';
@@ -35,6 +37,7 @@ import { languageOptions, useI18n } from './i18n';
import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate';
import { appUpdateIndicatorState } from './appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from './navigation';
import { detectInitialTheme, saveTheme, type AppTheme } from './theme';
const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG';
@@ -132,6 +135,7 @@ function AppContent() {
const { info: appUpdateInfo, hasUpdate, processing: appUpdateProcessing } = useAppUpdate();
const [active, setActive] = useState<PageId>('home');
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [theme, setTheme] = useState<AppTheme>(detectInitialTheme);
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
const closeDialogRef = useRef<HTMLElement>(null);
const languageMenuRef = useRef<HTMLDivElement>(null);
@@ -143,6 +147,10 @@ function AppContent() {
const selectedLanguage = languageOptions.find((option) => option.value === locale)
?? languageOptions[0];
useEffect(() => {
saveTheme(theme);
}, [theme]);
useEffect(() => {
if (!canOpenAppPage(active, coreRunning)) {
setActive('home');
@@ -300,6 +308,22 @@ function AppContent() {
</nav>
<div className="sidebar-bottom">
<button
type="button"
className="sidebar-theme-toggle"
aria-label={theme === 'dark' ? t('app.theme.switchToLight') : t('app.theme.switchToDark')}
aria-pressed={theme === 'dark'}
title={theme === 'dark' ? t('app.theme.switchToLight') : t('app.theme.switchToDark')}
onClick={() => setTheme((current) => current === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark'
? <Moon size={16} aria-hidden="true" />
: <Sun size={16} aria-hidden="true" />}
<span>{theme === 'dark' ? t('app.theme.dark') : t('app.theme.light')}</span>
<i className="theme-switch-track" aria-hidden="true">
<b />
</i>
</button>
<div ref={languageMenuRef} className="sidebar-language">
<button
ref={languageButtonRef}
+33
View File
@@ -2,6 +2,10 @@ import type { MessageKey } from './resources';
export const jaOverrides = {
'app.language': '言語',
'app.theme.light': 'ライトモード',
'app.theme.dark': 'ダークモード',
'app.theme.switchToLight': 'ライトモードに切り替え',
'app.theme.switchToDark': 'ダークモードに切り替え',
'app.desktopConsole': 'デスクトップコンソール',
'app.navigation': 'メインナビゲーション',
'app.nav.home': 'ホーム',
@@ -95,6 +99,7 @@ export const jaOverrides = {
'usage.tab.overview': '概要',
'usage.tab.analysis': '分析',
'usage.tab.events': 'リクエスト詳細',
'usage.tab.pricing': '料金統計',
'usage.filter.timeRange': '期間',
'usage.range.4h': '過去 4 時間',
'usage.range.24h': '過去 24 時間',
@@ -124,6 +129,34 @@ export const jaOverrides = {
'usage.stat.successRate': '成功率',
'usage.stat.reasoningMeta': '推論 {tokens} Token',
'usage.stat.averageLatency': '平均レイテンシ',
'usage.stat.tps': 'TPS',
'usage.stat.performanceMeta': '{rpm} RPM · 平均レイテンシ {latency} ms',
'usage.stat.cacheHitRate': 'キャッシュヒット率',
'usage.stat.cacheHitMeta': 'ヒット {hit} / 入力 {input} Token',
'usage.stat.estimatedCost': '推定コスト',
'usage.stat.costMeta': '{priced} / {total} 件を計算済み · 参考値',
'usage.pricing.coverage': '{priced} / {total} 件を計算済み · 利用可能なモデル料金 {saved} 件',
'usage.pricing.search': 'モデルを検索',
'usage.pricing.add': '手動で追加',
'usage.pricing.sync': '料金を同期',
'usage.pricing.syncing': '同期中',
'usage.pricing.syncResult': '料金を更新しました:{imported} 件を追加、手動料金 {skipped} 件を保持、{unmatched} 件は未一致',
'usage.pricing.syncFallback': 'リモート料金を読み込めなかったため、内蔵料金を引き続き使用します',
'usage.pricing.model': 'モデル',
'usage.pricing.calls': 'リクエスト数',
'usage.pricing.cost': '推定コスト',
'usage.pricing.prompt': '入力料金 ($/1M)',
'usage.pricing.completion': '出力料金 ($/1M)',
'usage.pricing.cache': '互換キャッシュ料金 ($/1M)',
'usage.pricing.cacheRead': 'キャッシュ読み取り料金 ($/1M)',
'usage.pricing.cacheCreation': 'キャッシュ作成料金 ($/1M)',
'usage.pricing.actions': '操作',
'usage.pricing.optional': '任意。空欄の場合は代替料金を使用',
'usage.pricing.modelRequired': 'モデル名を入力してください',
'usage.pricing.saving': '保存中',
'usage.pricing.saved': 'モデル料金を保存し、概要のコストを更新しました',
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
'usage.trend.title': 'リクエストと Token の推移',
'usage.trend.description': 'システムのローカル時刻ごとに集計',
'usage.token.title': 'Token の内訳',
+8
View File
@@ -5,6 +5,10 @@ export type MessageVariables = Record<string, string | number>;
export const zhCN = {
'app.language': '语言',
'app.theme.light': '日间模式',
'app.theme.dark': '夜间模式',
'app.theme.switchToLight': '切换到日间模式',
'app.theme.switchToDark': '切换到夜间模式',
'app.desktopConsole': '桌面控制台',
'app.navigation': '主导航',
'app.nav.home': '首页',
@@ -849,6 +853,10 @@ export type MessageKey = keyof typeof zhCN;
export const en: Record<MessageKey, string> = {
'app.language': 'Language',
'app.theme.light': 'Light mode',
'app.theme.dark': 'Dark mode',
'app.theme.switchToLight': 'Switch to light mode',
'app.theme.switchToDark': 'Switch to dark mode',
'app.desktopConsole': 'Desktop Console',
'app.navigation': 'Main navigation',
'app.nav.home': 'Home',
+3
View File
@@ -3,8 +3,11 @@ import { createRoot } from 'react-dom/client';
import { AppErrorBoundary } from './AppErrorBoundary';
import App from './App';
import { I18nProvider } from './i18n';
import { applyTheme, detectInitialTheme } from './theme';
import './styles.css';
applyTheme(detectInitialTheme());
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nProvider>
+971 -676
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
export type AppTheme = 'light' | 'dark';
const STORAGE_KEY = 'easy-cli-proxy-api.theme';
export function detectInitialTheme(): AppTheme {
if (typeof window === 'undefined') return 'light';
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === 'light' || saved === 'dark') return saved;
} catch {
// Storage may be unavailable in a restricted WebView; use the OS preference.
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function applyTheme(theme: AppTheme): void {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}
export function saveTheme(theme: AppTheme): void {
applyTheme(theme);
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
// The in-memory theme still works when persistent storage is unavailable.
}
}