refactor: 使用 Naive UI 重构后台布局与黑白双主题

This commit is contained in:
GouJ
2026-09-01 16:31:21 +08:00
parent 55516537bf
commit db8c176b55
51 changed files with 3277 additions and 2442 deletions
+91
View File
@@ -0,0 +1,91 @@
import { effectScope, nextTick, type EffectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTheme } from './useTheme'
let scope: EffectScope | undefined
afterEach(() => {
scope?.stop()
scope = undefined
localStorage.clear()
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
/** 可控系统主题,验证跟随模式与事件清理,不依赖测试机器偏好。 */
function setup(dark = false) {
const listeners = new Set<(event: MediaQueryListEvent) => void>()
vi.stubGlobal('matchMedia', () => ({
matches: dark,
addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener)
}))
scope = effectScope()
const state = scope.run(useTheme)!
return {
...state,
listeners,
change: (matches: boolean) => listeners.forEach(listener => listener({ matches } as MediaQueryListEvent))
}
}
describe('黑白双主题', () => {
it('默认跟随系统并实时响应,销毁时移除监听', async () => {
const state = setup()
expect(state.preference.value).toBe('system')
expect(document.documentElement.dataset.theme).toBe('light')
state.change(true)
await nextTick()
expect(state.isDark.value).toBe(true)
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(state.listeners.size).toBe(1)
scope!.stop()
expect(state.listeners.size).toBe(0)
})
it('手动选择优先于系统,并持久化到下一次挂载', async () => {
const state = setup(true)
state.preference.value = 'light'
await nextTick()
state.change(true)
await nextTick()
expect(state.isDark.value).toBe(false)
expect(localStorage.getItem('drama-studio-theme')).toBe('light')
scope!.stop()
const restored = setup(true)
expect(restored.preference.value).toBe('light')
expect(restored.theme.value).toBeNull()
})
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
const state = setup()
expect(state.overrides.value.common?.primaryColor).toBe('#202020')
state.preference.value = 'dark'
await nextTick()
expect(state.theme.value?.name).toBe('dark')
expect(state.overrides.value.common?.primaryColor).toBe('#f5f5f5')
expect(state.overrides.value.Button?.textColorPrimary).toBe('#171717')
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('无效存储回退系统模式', () => {
localStorage.setItem('drama-studio-theme', 'unexpected')
const state = setup(true)
expect(state.preference.value).toBe('system')
expect(state.isDark.value).toBe(true)
})
it('禁用存储仍能切换当前会话的主题', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('storage blocked')
})
const state = setup()
state.preference.value = 'dark'
await nextTick()
expect(document.documentElement.dataset.theme).toBe('dark')
})
})
+88
View File
@@ -0,0 +1,88 @@
import { computed, onScopeDispose, ref, watchEffect } from 'vue'
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
/** 用户主题偏好;system 会跟随系统的实时深浅色变化。 */
export type ThemePreference = 'light' | 'dark' | 'system'
const storageKey = 'drama-studio-theme'
/** 读取主题时兼容隐私模式或禁用存储的浏览器。 */
function readPreference(): ThemePreference {
try {
const value = localStorage.getItem(storageKey)
return value === 'light' || value === 'dark' ? value : 'system'
} catch {
return 'system'
}
}
/** 主题只影响展示,不修改项目配置;卸载时移除系统主题监听。 */
export function useTheme() {
const preference = ref<ThemePreference>(readPreference())
const media = window.matchMedia('(prefers-color-scheme: dark)')
const systemDark = ref(media.matches)
const isDark = computed(() => preference.value === 'dark' || (preference.value === 'system' && systemDark.value))
const theme = computed(() => (isDark.value ? darkTheme : null))
const overrides = computed<GlobalThemeOverrides>(() => {
const dark = isDark.value
const primary = dark ? '#f5f5f5' : '#202020'
const hover = dark ? '#d4d4d4' : '#444444'
return {
common: {
primaryColor: primary,
primaryColorHover: hover,
primaryColorPressed: hover,
primaryColorSuppl: primary,
infoColor: primary,
infoColorHover: hover,
infoColorPressed: hover,
infoColorSuppl: primary,
borderRadius: '6px',
bodyColor: dark ? '#141414' : '#f5f5f5',
cardColor: dark ? '#1c1c1c' : '#ffffff',
modalColor: dark ? '#202020' : '#ffffff',
popoverColor: dark ? '#262626' : '#ffffff',
tableColor: dark ? '#1c1c1c' : '#ffffff',
borderColor: dark ? '#363636' : '#e5e5e5',
textColorBase: dark ? '#ededed' : '#202020',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
},
Button: {
textColorPrimary: dark ? '#171717' : '#ffffff',
textColorHoverPrimary: dark ? '#171717' : '#ffffff',
textColorPressedPrimary: dark ? '#171717' : '#ffffff'
},
Layout: {
color: dark ? '#141414' : '#f5f5f5',
headerColor: dark ? '#1c1c1c' : '#ffffff',
siderColor: dark ? '#1c1c1c' : '#ffffff'
},
Menu: {
itemColorActive: dark ? '#333333' : '#eeeeee',
itemColorActiveHover: dark ? '#383838' : '#e7e7e7',
itemTextColorActive: primary,
itemTextColorActiveHover: primary,
itemIconColorActive: primary,
itemIconColorActiveHover: primary
},
Checkbox: { checkMarkColor: dark ? '#171717' : '#ffffff' },
Progress: { fillColor: primary }
}
})
/** 系统切换只更新 system 模式下的实际显示。 */
function syncSystem(event: MediaQueryListEvent) {
systemDark.value = event.matches
}
media.addEventListener('change', syncSystem)
onScopeDispose(() => media.removeEventListener('change', syncSystem))
watchEffect(() => {
document.documentElement.dataset.theme = isDark.value ? 'dark' : 'light'
document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light'
try {
localStorage.setItem(storageKey, preference.value)
} catch {
/* 存储不可用时保留当前会话偏好。 */
}
})
return { preference, isDark, theme, overrides }
}