From b0a808bc14c217d48b35962fd06d254adef82d6e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 13 May 2026 10:48:34 +0800 Subject: [PATCH] feat: add system theme mode --- src/App.vue | 5 +- src/components/layout/AppToolbar.vue | 49 +++++++++++++++---- src/composables/useTheme.ts | 72 +++++++++++++++++++++------- src/i18n/locales/en.ts | 4 ++ src/i18n/locales/es.ts | 4 ++ src/i18n/locales/zh-CN.ts | 4 ++ src/lib/appTheme.ts | 20 ++++++++ tests/appTheme.test.ts | 28 +++++++++++ 8 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 src/lib/appTheme.ts create mode 100644 tests/appTheme.test.ts diff --git a/src/App.vue b/src/App.vue index 320e3da272..66d4e083d6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -49,7 +49,7 @@ const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); const savedSqlStore = useSavedSqlStore(); const { message: toastMessage, visible: toastVisible, toast } = useToast(); -const { isDark, applyTheme, toggleTheme } = useTheme(); +const { isDark, themeMode, applyTheme, setThemeMode } = useTheme(); const { checkingUpdates, updateInfo, @@ -537,6 +537,7 @@ onUnmounted(() => {
{ :has-sql-file-connections="hasSqlFileConnections" @new-connection="showConnectionDialog = true" @new-query="newQuery" - @toggle-theme="toggleTheme" + @set-theme-mode="setThemeMode" @toggle-ai="toggleAiPanel" @toggle-history="showHistory = !showHistory" @open-github="openGitHub" diff --git a/src/components/layout/AppToolbar.vue b/src/components/layout/AppToolbar.vue index a0bcd24164..5acd13230b 100644 --- a/src/components/layout/AppToolbar.vue +++ b/src/components/layout/AppToolbar.vue @@ -7,6 +7,8 @@ import { Globe, Moon, Sun, + Monitor, + Check, History, Bot, ArrowLeftRight, @@ -27,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import WindowControls from "@/components/layout/WindowControls.vue"; import { useWindowControls } from "@/composables/useWindowControls"; import { currentLocale, setLocale, type Locale } from "@/i18n"; +import type { AppThemeMode } from "@/lib/appTheme"; const localeOptions: { value: Locale; flag: string; label: string }[] = [ { value: "en", flag: "🇺🇸", label: "English" }, @@ -36,6 +39,7 @@ const localeOptions: { value: Locale; flag: string; label: string }[] = [ defineProps<{ isDark: boolean; + themeMode: AppThemeMode; showAiPanel: boolean; showHistory: boolean; checkingUpdates: boolean; @@ -46,7 +50,7 @@ defineProps<{ const emit = defineEmits<{ "new-connection": []; "new-query": []; - "toggle-theme": []; + "set-theme-mode": [mode: AppThemeMode]; "toggle-ai": []; "toggle-history": []; "open-github": []; @@ -178,15 +182,44 @@ function onToolbarDblClick(e: MouseEvent) { AI - - - - - {{ isDark ? "Light" : "Dark" }} - + + + + + {{ t("toolbar.themeLight") }} + + + + + {{ t("toolbar.themeDark") }} + + + + + {{ t("toolbar.themeSystem") }} + + + + diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index cf4b4ae3e6..9e0f7308a6 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -1,25 +1,63 @@ -import { ref } from "vue"; +import { computed, ref } from "vue"; +import { + APP_THEME_STORAGE_KEY, + getTauriThemeForMode, + normalizeAppThemeMode, + resolveAppThemeAppearance, + type AppThemeMode, +} from "@/lib/appTheme"; import { isTauriRuntime } from "@/lib/tauriRuntime"; -import type { Theme } from "@tauri-apps/api/window"; + +const themeMode = ref( + normalizeAppThemeMode(typeof localStorage === "undefined" ? null : localStorage.getItem(APP_THEME_STORAGE_KEY)), +); +const systemPrefersDark = ref(readSystemPrefersDark()); +const isDark = computed(() => resolveAppThemeAppearance(themeMode.value, systemPrefersDark.value) === "dark"); + +let mediaQuery: MediaQueryList | null = null; +let isListeningForSystemTheme = false; + +function readSystemPrefersDark() { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +function setupSystemThemeListener() { + if (isListeningForSystemTheme || typeof window === "undefined" || typeof window.matchMedia !== "function") return; + mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + systemPrefersDark.value = mediaQuery.matches; + const onChange = (event: MediaQueryListEvent) => { + systemPrefersDark.value = event.matches; + if (themeMode.value === "system") applyTheme(); + }; + mediaQuery.addEventListener("change", onChange); + isListeningForSystemTheme = true; +} + +function applyTheme() { + if (typeof document !== "undefined") { + document.documentElement.classList.toggle("dark", isDark.value); + } + if (!isTauriRuntime()) return; + import("@tauri-apps/api/window").then(({ getCurrentWindow }) => { + getCurrentWindow() + .setTheme(getTauriThemeForMode(themeMode.value)) + .catch(() => {}); + }); +} + +function setThemeMode(mode: AppThemeMode) { + themeMode.value = mode; + localStorage.setItem(APP_THEME_STORAGE_KEY, mode); + applyTheme(); +} export function useTheme() { - const isDark = ref(localStorage.getItem("dbx-theme") === "dark"); - - function applyTheme() { - document.documentElement.classList.toggle("dark", isDark.value); - if (!isTauriRuntime()) return; - import("@tauri-apps/api/window").then(({ getCurrentWindow }) => { - getCurrentWindow() - .setTheme(isDark.value ? ("dark" as Theme) : ("light" as Theme)) - .catch(() => {}); - }); - } + setupSystemThemeListener(); function toggleTheme() { - isDark.value = !isDark.value; - localStorage.setItem("dbx-theme", isDark.value ? "dark" : "light"); - applyTheme(); + setThemeMode(isDark.value ? "light" : "dark"); } - return { isDark, applyTheme, toggleTheme }; + return { isDark, themeMode, applyTheme, setThemeMode, toggleTheme }; } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index aa920663ac..3bd876efb8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -34,6 +34,10 @@ export default { formatSqlFailed: "Failed to format SQL", saveSql: "Save to SQL Library", openSql: "Open SQL file", + theme: "Theme", + themeLight: "Light", + themeDark: "Dark", + themeSystem: "Follow System", sqlSaved: "SQL saved", sqlOpenFailed: "Failed to open file: {message}", sqlSaveFailed: "Failed to save file: {message}", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 7f33acbc02..71a08ea668 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -34,6 +34,10 @@ export default { formatSqlFailed: "Error al formatear el SQL", saveSql: "Guardar en biblioteca SQL", openSql: "Abrir archivo SQL", + theme: "Tema", + themeLight: "Claro", + themeDark: "Oscuro", + themeSystem: "Seguir sistema", sqlSaved: "SQL guardado", sqlOpenFailed: "Error al abrir el archivo: {message}", sqlSaveFailed: "Error al guardar el archivo: {message}", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e408dc6791..90fa92c0ba 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -34,6 +34,10 @@ export default { formatSqlFailed: "SQL 格式化失败", saveSql: "保存到 SQL 库", openSql: "打开 SQL 文件", + theme: "主题", + themeLight: "亮色", + themeDark: "暗色", + themeSystem: "跟随系统", sqlSaved: "SQL 已保存", sqlOpenFailed: "打开文件失败:{message}", sqlSaveFailed: "保存文件失败:{message}", diff --git a/src/lib/appTheme.ts b/src/lib/appTheme.ts new file mode 100644 index 0000000000..f25a624cce --- /dev/null +++ b/src/lib/appTheme.ts @@ -0,0 +1,20 @@ +import type { Theme } from "@tauri-apps/api/window"; + +export const APP_THEME_STORAGE_KEY = "dbx-theme"; + +export type AppThemeMode = "light" | "dark" | "system"; +export type AppThemeAppearance = "light" | "dark"; + +export function normalizeAppThemeMode(value: string | null): AppThemeMode { + if (value === "dark" || value === "light" || value === "system") return value; + return "light"; +} + +export function resolveAppThemeAppearance(mode: AppThemeMode, systemPrefersDark: boolean): AppThemeAppearance { + if (mode === "system") return systemPrefersDark ? "dark" : "light"; + return mode; +} + +export function getTauriThemeForMode(mode: AppThemeMode): Theme | null { + return mode === "system" ? null : mode; +} diff --git a/tests/appTheme.test.ts b/tests/appTheme.test.ts new file mode 100644 index 0000000000..8195bca999 --- /dev/null +++ b/tests/appTheme.test.ts @@ -0,0 +1,28 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + getTauriThemeForMode, + normalizeAppThemeMode, + resolveAppThemeAppearance, +} from "../src/lib/appTheme.ts"; + +test("normalizes stored app theme modes", () => { + assert.equal(normalizeAppThemeMode("dark"), "dark"); + assert.equal(normalizeAppThemeMode("light"), "light"); + assert.equal(normalizeAppThemeMode("system"), "system"); + assert.equal(normalizeAppThemeMode(null), "light"); + assert.equal(normalizeAppThemeMode("unexpected"), "light"); +}); + +test("resolves system app theme from current system preference", () => { + assert.equal(resolveAppThemeAppearance("light", true), "light"); + assert.equal(resolveAppThemeAppearance("dark", false), "dark"); + assert.equal(resolveAppThemeAppearance("system", true), "dark"); + assert.equal(resolveAppThemeAppearance("system", false), "light"); +}); + +test("maps system app theme mode to native automatic theme", () => { + assert.equal(getTauriThemeForMode("light"), "light"); + assert.equal(getTauriThemeForMode("dark"), "dark"); + assert.equal(getTauriThemeForMode("system"), null); +});