feat(theme): 新增跟随系统主题模式

- 增加主题偏好持久化并兼容旧版主题状态恢复
- 监听系统深浅色变化并同步窗口与应用主题
- 补齐主题设置文案与持久化相关测试

Fixes #407
This commit is contained in:
Syngnat
2026-06-29 19:52:22 +08:00
parent bedd1eceb5
commit a7fdab5bb2
10 changed files with 118 additions and 9 deletions

View File

@@ -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',

View File

@@ -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",

View File

@@ -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();

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "ライト/ダーク切り替え",

View File

@@ -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": "Переключение светлой и темной темы",

View File

@@ -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": "亮色与暗色切换",

View File

@@ -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": "淺色與深色切換",