feat(theme): 增加暗黑模式与主题切换

This commit is contained in:
liumangmang
2026-03-06 14:52:43 +08:00
parent ffb22e6f3b
commit 07b92f0d96
3 changed files with 190 additions and 2 deletions

View File

@@ -10,6 +10,14 @@
</span>
</div>
<div class="flex items-center gap-3 text-xs text-slate-600">
<button
type="button"
class="theme-toggle"
:title="`当前主题:${themeModeText}`"
@click="cycleThemeMode"
>
{{ themeModeText }}
</button>
<StatusDot :status="statusDot">
{{ statusLabel }}
</StatusDot>
@@ -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(() => {
}
});
</script>

View File

@@ -33,3 +33,75 @@ body {
.fade-leave-to {
opacity: 0;
}
.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);
}

View File

@@ -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<ThemeMode>(loadThemeMode());
const resolvedTheme = ref<ResolvedTheme>('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,
};
}