feat(ui): add persistent light and dark theme support
This commit is contained in:
1 parent
7b9566ef73
commit
a9342e5efe
6 files changed
+1070
-676
No files matched your search
+24
@@ -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}
|
||||
|
||||
@@ -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 の内訳',
|
||||
|
||||
@@ -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,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
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user