refactor: 使用 Naive UI 重构后台布局与黑白双主题
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user