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,
+ };
+}