diff --git a/frontend/src/App.vue b/frontend/src/App.vue index dc28964..3641b16 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,6 +10,14 @@
+ {{ statusLabel }} @@ -26,8 +34,30 @@ import { computed } from 'vue'; import StatusDot from '@/components/ui/StatusDot.vue'; import { useWsStore } from '@/stores/wsStore'; +import { useTheme, type ThemeMode } from '@/composables/useTheme'; const wsStore = useWsStore(); +const { themeMode, setThemeMode } = useTheme(); + +const THEME_ORDER: ThemeMode[] = ['system', 'dark', 'light']; + +const themeModeText = computed(() => { + switch (themeMode.value) { + case 'dark': + return '深色'; + case 'light': + return '浅色'; + case 'system': + default: + return '跟随系统'; + } +}); + +function cycleThemeMode(): void { + const currentIndex = THEME_ORDER.indexOf(themeMode.value); + const nextIndex = (currentIndex + 1) % THEME_ORDER.length; + setThemeMode(THEME_ORDER[nextIndex]); +} const statusDot = computed(() => { switch (wsStore.status) { @@ -57,4 +87,3 @@ const statusLabel = computed(() => { } }); - diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 10b7f1b..90a5cba 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -32,4 +32,76 @@ body { .fade-enter-from, .fade-leave-to { opacity: 0; -} \ No newline at end of file +} + +.theme-toggle { + @apply inline-flex items-center px-2.5 h-7 rounded-[var(--dt-radius-btn)] border border-slate-300 bg-white text-slate-700 transition; +} + +.theme-toggle:hover { + @apply border-slate-400 bg-slate-50; +} + +html.dark body { + color: rgb(241 245 249); + background-color: rgb(2 6 23); +} + +html.dark .bg-white { + background-color: rgb(15 23 42); +} + +html.dark .bg-white\/80 { + background-color: rgb(15 23 42 / 0.8); +} + +html.dark .bg-slate-50 { + background-color: rgb(15 23 42); +} + +html.dark .bg-slate-100\/80 { + background-color: rgb(15 23 42 / 0.8); +} + +html.dark .text-slate-900 { + color: rgb(241 245 249); +} + +html.dark .text-slate-700 { + color: rgb(226 232 240); +} + +html.dark .text-slate-600 { + color: rgb(203 213 225); +} + +html.dark .text-slate-500 { + color: rgb(148 163 184); +} + +html.dark .border-slate-200 { + border-color: rgb(51 65 85); +} + +html.dark .border-slate-300 { + border-color: rgb(71 85 105); +} + +html.dark .border-dashed { + border-style: dashed; +} + +html.dark .placeholder\:text-slate-400::placeholder { + color: rgb(148 163 184); +} + +html.dark .theme-toggle { + border-color: rgb(71 85 105); + background-color: rgb(30 41 59); + color: rgb(226 232 240); +} + +html.dark .theme-toggle:hover { + border-color: rgb(100 116 139); + background-color: rgb(51 65 85); +} diff --git a/frontend/src/composables/useTheme.ts b/frontend/src/composables/useTheme.ts new file mode 100644 index 0000000..ac9fa13 --- /dev/null +++ b/frontend/src/composables/useTheme.ts @@ -0,0 +1,87 @@ +import { computed, readonly, ref } from 'vue'; + +type ResolvedTheme = 'light' | 'dark'; +export type ThemeMode = 'system' | 'light' | 'dark'; + +const STORAGE_KEY = 'DataTool-theme-mode'; + +const themeMode = ref(loadThemeMode()); +const resolvedTheme = ref('light'); + +let mediaQuery: MediaQueryList | null = null; +let initialized = false; + +function loadThemeMode(): ThemeMode { + if (typeof window === 'undefined') { + return 'system'; + } + const saved = window.localStorage.getItem(STORAGE_KEY); + if (saved === 'light' || saved === 'dark' || saved === 'system') { + return saved; + } + return 'system'; +} + +function getSystemTheme(): ResolvedTheme { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return 'light'; + } + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme(theme: ResolvedTheme): void { + if (typeof document === 'undefined') { + return; + } + const root = document.documentElement; + root.classList.toggle('dark', theme === 'dark'); + root.style.colorScheme = theme; +} + +function resolveTheme(mode: ThemeMode): ResolvedTheme { + if (mode === 'system') { + return getSystemTheme(); + } + return mode; +} + +function syncTheme(): void { + const next = resolveTheme(themeMode.value); + resolvedTheme.value = next; + applyTheme(next); +} + +function ensureInit(): void { + if (initialized || typeof window === 'undefined') { + return; + } + initialized = true; + mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', () => { + if (themeMode.value === 'system') { + syncTheme(); + } + }); + syncTheme(); +} + +function setThemeMode(nextMode: ThemeMode): void { + themeMode.value = nextMode; + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, nextMode); + } + syncTheme(); +} + +export function useTheme() { + ensureInit(); + + const isDark = computed(() => resolvedTheme.value === 'dark'); + + return { + themeMode: readonly(themeMode), + resolvedTheme: readonly(resolvedTheme), + isDark, + setThemeMode, + }; +}