feat(theme): 增加暗黑模式与主题切换
This commit is contained in:
@@ -10,6 +10,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
<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">
|
<StatusDot :status="statusDot">
|
||||||
{{ statusLabel }}
|
{{ statusLabel }}
|
||||||
</StatusDot>
|
</StatusDot>
|
||||||
@@ -26,8 +34,30 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import StatusDot from '@/components/ui/StatusDot.vue';
|
import StatusDot from '@/components/ui/StatusDot.vue';
|
||||||
import { useWsStore } from '@/stores/wsStore';
|
import { useWsStore } from '@/stores/wsStore';
|
||||||
|
import { useTheme, type ThemeMode } from '@/composables/useTheme';
|
||||||
|
|
||||||
const wsStore = useWsStore();
|
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(() => {
|
const statusDot = computed(() => {
|
||||||
switch (wsStore.status) {
|
switch (wsStore.status) {
|
||||||
@@ -57,4 +87,3 @@ const statusLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -32,4 +32,76 @@ body {
|
|||||||
.fade-enter-from,
|
.fade-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
87
frontend/src/composables/useTheme.ts
Normal file
87
frontend/src/composables/useTheme.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user