mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 22:12:29 +08:00
✨ feat(theme): 新增跟随系统主题模式
- 增加主题偏好持久化并兼容旧版主题状态恢复 - 监听系统深浅色变化并同步窗口与应用主题 - 补齐主题设置文案与持久化相关测试 Fixes #407
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
|
||||
import { BrowserOpenURL, Environment, EventsOn, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetDarkTheme, WindowSetLightTheme, WindowSetPosition, WindowSetSize, WindowSetSystemDefaultTheme, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -166,6 +166,13 @@ const readCurrentVisibleViewport = () => ({
|
||||
availTop: (window.screen as Screen & { availTop?: number })?.availTop || 0,
|
||||
});
|
||||
|
||||
const getSystemThemeMode = (): 'light' | 'dark' => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return 'light';
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
|
||||
const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnection[]): SavedConnection[] => {
|
||||
const merged = new Map<string, SavedConnection>();
|
||||
@@ -223,7 +230,9 @@ function App() {
|
||||
const connectionModalWarmupDoneRef = useRef(false);
|
||||
const windowState = useStore(state => state.windowState);
|
||||
const themeMode = useStore(state => state.theme);
|
||||
const themePreference = useStore(state => state.themePreference);
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
const setThemePreference = useStore(state => state.setThemePreference);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const setAppearance = useStore(state => state.setAppearance);
|
||||
const uiScale = useStore(state => state.uiScale);
|
||||
@@ -240,6 +249,7 @@ function App() {
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const updateShortcut = useStore(state => state.updateShortcut);
|
||||
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
|
||||
const [systemThemeMode, setSystemThemeMode] = useState<'light' | 'dark'>(() => getSystemThemeMode());
|
||||
const darkMode = themeMode === 'dark';
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
|
||||
@@ -285,6 +295,44 @@ function App() {
|
||||
(key: TabDisplayElementKey) => t(TAB_DISPLAY_ELEMENT_META[key].descriptionKey),
|
||||
[t],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const applySystemTheme = (matches: boolean) => {
|
||||
setSystemThemeMode(matches ? 'dark' : 'light');
|
||||
};
|
||||
applySystemTheme(mediaQueryList.matches);
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
applySystemTheme(event.matches);
|
||||
};
|
||||
if (typeof mediaQueryList.addEventListener === 'function') {
|
||||
mediaQueryList.addEventListener('change', handleChange);
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener('change', handleChange);
|
||||
};
|
||||
}
|
||||
mediaQueryList.addListener(handleChange);
|
||||
return () => {
|
||||
mediaQueryList.removeListener(handleChange);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const resolvedTheme = themePreference === 'system' ? systemThemeMode : themePreference;
|
||||
if (themeMode !== resolvedTheme) {
|
||||
setTheme(resolvedTheme);
|
||||
}
|
||||
if (themePreference === 'system') {
|
||||
void safeWindowRuntimeCall(() => WindowSetSystemDefaultTheme(), undefined);
|
||||
return;
|
||||
}
|
||||
if (resolvedTheme === 'dark') {
|
||||
void safeWindowRuntimeCall(() => WindowSetDarkTheme(), undefined);
|
||||
return;
|
||||
}
|
||||
void safeWindowRuntimeCall(() => WindowSetLightTheme(), undefined);
|
||||
}, [setTheme, systemThemeMode, themeMode, themePreference]);
|
||||
const setTabDisplaySettings = useCallback((settings: Partial<TabDisplaySettings>) => {
|
||||
setAppearance({
|
||||
tabDisplay: applyTabDisplaySettingsPatch(tabDisplaySettings, settings),
|
||||
@@ -2814,7 +2862,7 @@ function App() {
|
||||
handleToggleLogPanel();
|
||||
break;
|
||||
case 'toggleTheme':
|
||||
setTheme(themeMode === 'dark' ? 'light' : 'dark');
|
||||
setThemePreference(themeMode === 'dark' ? 'light' : 'dark');
|
||||
break;
|
||||
case 'openShortcutManager':
|
||||
setIsShortcutModalOpen(true);
|
||||
@@ -2834,7 +2882,7 @@ function App() {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalShortcut, true);
|
||||
};
|
||||
}, [activeShortcutPlatform, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, shortcutOptions, switchActiveTabByOffset, themeMode, setTheme, toggleAIPanel, useNativeMacWindowControls]);
|
||||
}, [activeShortcutPlatform, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, shortcutOptions, switchActiveTabByOffset, themeMode, setThemePreference, toggleAIPanel, useNativeMacWindowControls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturingShortcutAction) {
|
||||
@@ -4573,17 +4621,18 @@ function App() {
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.theme.mode_title')}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
|
||||
{[
|
||||
{ key: 'light', label: t('app.theme.mode.light.label'), description: t('app.theme.mode.light.description') },
|
||||
{ key: 'dark', label: t('app.theme.mode.dark.label'), description: t('app.theme.mode.dark.description') },
|
||||
{ key: 'system', label: t('app.theme.mode.system.label'), description: t('app.theme.mode.system.description') },
|
||||
].map((item) => {
|
||||
const active = themeMode === item.key;
|
||||
const active = themePreference === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setTheme(item.key as 'light' | 'dark')}
|
||||
onClick={() => setThemePreference(item.key as 'light' | 'dark' | 'system')}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '14px 14px',
|
||||
|
||||
@@ -188,6 +188,8 @@ describe("i18n catalog", () => {
|
||||
"app.theme.mode.dark.label",
|
||||
"app.theme.mode.light.description",
|
||||
"app.theme.mode.light.label",
|
||||
"app.theme.mode.system.description",
|
||||
"app.theme.mode.system.label",
|
||||
"app.theme.mode_title",
|
||||
"app.theme.data_table.table_double_click_action",
|
||||
"app.theme.data_table.table_double_click_action.open_data",
|
||||
|
||||
@@ -199,6 +199,29 @@ describe('store appearance persistence', () => {
|
||||
expect(reloaded.useStore.getState().languagePreference).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('persists theme preference and falls back to the resolved theme when missing', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().setThemePreference('system');
|
||||
let persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.themePreference).toBe('system');
|
||||
|
||||
vi.resetModules();
|
||||
let reloaded = await importStore();
|
||||
expect(reloaded.useStore.getState().themePreference).toBe('system');
|
||||
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
theme: 'dark',
|
||||
},
|
||||
version: 13,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
reloaded = await importStore();
|
||||
expect(reloaded.useStore.getState().themePreference).toBe('dark');
|
||||
});
|
||||
|
||||
it('persists custom font families and sanitizes blank values', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ import {
|
||||
} from "./utils/queryEditorSplitLayout";
|
||||
|
||||
export type TableDoubleClickAction = "open-data" | "open-design";
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type ThemePreference = ThemeMode | "system";
|
||||
|
||||
export interface AppearanceSettings extends DataGridDisplaySettings {
|
||||
uiVersion: "legacy" | "v2";
|
||||
@@ -1275,7 +1277,8 @@ interface AppState {
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
externalSQLDirectories: ExternalSQLDirectory[];
|
||||
theme: "light" | "dark";
|
||||
theme: ThemeMode;
|
||||
themePreference: ThemePreference;
|
||||
languagePreference: LanguagePreference;
|
||||
appearance: AppearanceSettings;
|
||||
uiScale: number;
|
||||
@@ -1390,7 +1393,8 @@ interface AppState {
|
||||
saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void;
|
||||
deleteExternalSQLDirectory: (id: string) => void;
|
||||
|
||||
setTheme: (theme: "light" | "dark") => void;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
setThemePreference: (themePreference: ThemePreference) => void;
|
||||
setLanguagePreference: (languagePreference: LanguagePreference) => void;
|
||||
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
|
||||
setRedisDbAlias: (
|
||||
@@ -1968,9 +1972,14 @@ const hasLegacyConnectionSecrets = (
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeTheme = (value: unknown): "light" | "dark" =>
|
||||
const sanitizeTheme = (value: unknown): ThemeMode =>
|
||||
value === "dark" ? "dark" : "light";
|
||||
|
||||
const sanitizeThemePreference = (
|
||||
value: unknown,
|
||||
fallbackTheme: ThemeMode = "light",
|
||||
): ThemePreference => (value === "system" ? "system" : sanitizeTheme(value ?? fallbackTheme));
|
||||
|
||||
const sanitizeLanguagePreference = (value: unknown): LanguagePreference => {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
@@ -2450,6 +2459,7 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
theme: "light",
|
||||
themePreference: "light",
|
||||
languagePreference: DEFAULT_LANGUAGE_PREFERENCE,
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
uiScale: DEFAULT_UI_SCALE,
|
||||
@@ -3258,6 +3268,10 @@ export const useStore = create<AppState>()(
|
||||
})),
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setThemePreference: (themePreference) =>
|
||||
set({
|
||||
themePreference: sanitizeThemePreference(themePreference),
|
||||
}),
|
||||
setLanguagePreference: (languagePreference) =>
|
||||
set({
|
||||
languagePreference: sanitizeLanguagePreference(languagePreference),
|
||||
@@ -3769,6 +3783,10 @@ export const useStore = create<AppState>()(
|
||||
state.externalSQLDirectories,
|
||||
);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.themePreference = sanitizeThemePreference(
|
||||
state.themePreference,
|
||||
nextState.theme,
|
||||
);
|
||||
nextState.languagePreference = sanitizeLanguagePreference(
|
||||
state.languagePreference,
|
||||
);
|
||||
@@ -3870,6 +3888,10 @@ export const useStore = create<AppState>()(
|
||||
state.externalSQLDirectories,
|
||||
),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
themePreference: sanitizeThemePreference(
|
||||
state.themePreference,
|
||||
sanitizeTheme(state.theme),
|
||||
),
|
||||
languagePreference: sanitizeLanguagePreference(
|
||||
state.languagePreference,
|
||||
),
|
||||
@@ -3921,6 +3943,7 @@ export const useStore = create<AppState>()(
|
||||
sidebarRootOrder: state.sidebarRootOrder,
|
||||
externalSQLDirectories: state.externalSQLDirectories,
|
||||
theme: state.theme,
|
||||
themePreference: state.themePreference,
|
||||
languagePreference: state.languagePreference,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
|
||||
@@ -2720,6 +2720,8 @@
|
||||
"app.theme.mode.dark.label": "Dunkles Theme",
|
||||
"app.theme.mode.light.description": "Geeignet für helle Umgebungen mit leichterer visueller Hierarchie.",
|
||||
"app.theme.mode.light.label": "Helles Theme",
|
||||
"app.theme.mode.system.description": "Folgt dem Erscheinungsbild des Betriebssystems und wechselt automatisch.",
|
||||
"app.theme.mode.system.label": "System folgen",
|
||||
"app.theme.nav.appearance.description": "Skalierung, Schrift und Transparenz",
|
||||
"app.theme.nav.appearance.title": "Darstellungsparameter",
|
||||
"app.theme.nav.theme.description": "Wechsel zwischen hell und dunkel",
|
||||
|
||||
@@ -2720,6 +2720,8 @@
|
||||
"app.theme.mode.dark.label": "Dark Theme",
|
||||
"app.theme.mode.light.description": "Better for bright environments with lighter visual hierarchy.",
|
||||
"app.theme.mode.light.label": "Light Theme",
|
||||
"app.theme.mode.system.description": "Follow the operating system appearance and switch automatically.",
|
||||
"app.theme.mode.system.label": "Follow System",
|
||||
"app.theme.nav.appearance.description": "Scale, font, and transparency",
|
||||
"app.theme.nav.appearance.title": "Appearance Parameters",
|
||||
"app.theme.nav.theme.description": "Light and dark switching",
|
||||
|
||||
@@ -2720,6 +2720,8 @@
|
||||
"app.theme.mode.dark.label": "ダークテーマ",
|
||||
"app.theme.mode.light.description": "明るい環境に適し、軽やかな階層感になります。",
|
||||
"app.theme.mode.light.label": "ライトテーマ",
|
||||
"app.theme.mode.system.description": "OS の外観設定に追従して自動で切り替えます。",
|
||||
"app.theme.mode.system.label": "システムに追従",
|
||||
"app.theme.nav.appearance.description": "スケール、フォント、透明度",
|
||||
"app.theme.nav.appearance.title": "外観パラメータ",
|
||||
"app.theme.nav.theme.description": "ライト/ダーク切り替え",
|
||||
|
||||
@@ -2720,6 +2720,8 @@
|
||||
"app.theme.mode.dark.label": "Темная тема",
|
||||
"app.theme.mode.light.description": "Подходит для яркой среды и более легкой визуальной иерархии.",
|
||||
"app.theme.mode.light.label": "Светлая тема",
|
||||
"app.theme.mode.system.description": "Следует за оформлением системы и переключается автоматически.",
|
||||
"app.theme.mode.system.label": "Как в системе",
|
||||
"app.theme.nav.appearance.description": "Масштаб, шрифт и прозрачность",
|
||||
"app.theme.nav.appearance.title": "Параметры внешнего вида",
|
||||
"app.theme.nav.theme.description": "Переключение светлой и темной темы",
|
||||
|
||||
@@ -2720,6 +2720,8 @@
|
||||
"app.theme.mode.dark.label": "暗色主题",
|
||||
"app.theme.mode.light.description": "适合明亮环境,层次更轻。",
|
||||
"app.theme.mode.light.label": "亮色主题",
|
||||
"app.theme.mode.system.description": "跟随操作系统外观并自动切换。",
|
||||
"app.theme.mode.system.label": "跟随系统",
|
||||
"app.theme.nav.appearance.description": "缩放、字体与透明度",
|
||||
"app.theme.nav.appearance.title": "外观参数",
|
||||
"app.theme.nav.theme.description": "亮色与暗色切换",
|
||||
|
||||
@@ -2720,6 +2720,8 @@
|
||||
"app.theme.mode.dark.label": "深色主題",
|
||||
"app.theme.mode.light.description": "適合明亮環境,視覺層次更輕盈。",
|
||||
"app.theme.mode.light.label": "淺色主題",
|
||||
"app.theme.mode.system.description": "跟隨作業系統外觀並自動切換。",
|
||||
"app.theme.mode.system.label": "跟隨系統",
|
||||
"app.theme.nav.appearance.description": "縮放、字型與透明度",
|
||||
"app.theme.nav.appearance.title": "外觀參數",
|
||||
"app.theme.nav.theme.description": "淺色與深色切換",
|
||||
|
||||
Reference in New Issue
Block a user