feat: 收口组件样式并调整移动端侧栏
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePolling } from './usePolling'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('异步轮询生命周期', () => {
|
||||
it('关闭定时刷新后仍支持首次读取、手动刷新及查询目标切换', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, false))!
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('first')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
await query.refresh()
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('second')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('响应式暂停清除旧定时器,恢复只创建一个轮询链', async () => {
|
||||
vi.useFakeTimers()
|
||||
const interval = ref<number | false>(1000)
|
||||
const loader = vi.fn<() => Promise<string>>(async () => '数据')
|
||||
const scope = effectScope()
|
||||
scope.run(() => usePolling(ref('p'), loader, interval))
|
||||
await Promise.resolve()
|
||||
interval.value = false
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
interval.value = 1000
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const finish = new Map<string, (value: string) => void>()
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(
|
||||
(id: string) =>
|
||||
new Promise<string>(resolve => {
|
||||
finish.set(id, resolve)
|
||||
})
|
||||
)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, 1000))!
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
finish.get('second')!('新项目')
|
||||
await Promise.resolve()
|
||||
finish.get('first')!('旧项目')
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('新项目')
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('失败时保留已有数据,并向页面显示错误', async () => {
|
||||
const scope = effectScope()
|
||||
const loader = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce('上次成功数据')
|
||||
.mockRejectedValueOnce(new Error('连接已断开'))
|
||||
const query = scope.run(() => usePolling(ref('p'), loader))!
|
||||
await Promise.resolve()
|
||||
await query.refresh()
|
||||
expect(query.data.value).toBe('上次成功数据')
|
||||
expect(query.error.value).toBe('连接已断开')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
import { effectScope, nextTick, type EffectScope } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useTheme } from './useTheme'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/** 将十六进制颜色换算为 sRGB 相对亮度。 */
|
||||
function luminance(hex: string) {
|
||||
const channels = [1, 3, 5].map(index => {
|
||||
const value = parseInt(hex.slice(index, index + 2), 16) / 255
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722
|
||||
}
|
||||
|
||||
/** 检查主题文字对比度,避免绿色按钮和浅色选中项难以辨认。 */
|
||||
function contrast(foreground: string, background: string) {
|
||||
const values = [luminance(foreground), luminance(background)]
|
||||
return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05)
|
||||
}
|
||||
|
||||
describe('微信风格明暗主题', () => {
|
||||
it('浅色 Tabs 分隔线使用约定的 #dadada,与页面和内容表面保留灰阶差异', () => {
|
||||
const { overrides } = setup()
|
||||
const color = overrides.value.Tabs!.tabBorderColor!
|
||||
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
|
||||
expect(color).toBe('#dadada')
|
||||
expect(contrast(color, '#ededed')).toBeGreaterThan(1.15)
|
||||
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
|
||||
})
|
||||
|
||||
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('#07c160')
|
||||
expect(state.overrides.value.common?.bodyColor).toBe('#ededed')
|
||||
state.preference.value = 'dark'
|
||||
await nextTick()
|
||||
expect(state.theme.value?.name).toBe('dark')
|
||||
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
|
||||
expect(state.overrides.value.common?.bodyColor).toBe('#111111')
|
||||
expect(state.overrides.value.Button?.textColorPrimary).toBe('#ffffff')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => {
|
||||
const state = setup(dark)
|
||||
const { common, Menu, Button, Layout, Input, Select } = state.overrides.value
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
const lightTokens = css.match(/:root\s*\{([^}]+)\}/)![1]!
|
||||
const darkTokens = css.match(/:root\[data-theme='dark'\]\s*\{([^}]+)\}/)![1]!
|
||||
const tokens = Object.fromEntries(
|
||||
[...(lightTokens + (dark ? darkTokens : '')).matchAll(/--app-([\w-]+):\s*(#[\da-f]+);/g)].map(match => [
|
||||
match[1],
|
||||
match[2]
|
||||
])
|
||||
)
|
||||
expect(common).toMatchObject({
|
||||
bodyColor: tokens.body,
|
||||
cardColor: tokens.surface,
|
||||
modalColor: tokens.surface,
|
||||
tableColor: tokens.surface,
|
||||
inputColor: tokens.surface,
|
||||
borderColor: tokens.border,
|
||||
textColor1: tokens.ink,
|
||||
textColor3: tokens.muted,
|
||||
primaryColor: tokens.accent,
|
||||
successColor: tokens.success,
|
||||
errorColor: tokens.danger
|
||||
})
|
||||
expect(Layout).toMatchObject({ color: tokens.body, headerColor: tokens.surface, siderColor: tokens.subtle })
|
||||
expect(Menu).toMatchObject({
|
||||
borderRadius: '0px',
|
||||
itemColorActive: tokens.selected,
|
||||
itemColorActiveCollapsed: tokens.selected,
|
||||
itemTextColorActive: tokens['accent-text']
|
||||
})
|
||||
// 全站直角;输入区域与普通按钮共享底色,保持明暗模式一致。
|
||||
expect(common?.borderRadius).toBe('0px')
|
||||
expect(common?.borderRadiusSmall).toBe('0px')
|
||||
expect(Input?.color).toBe('var(--app-field)')
|
||||
expect(Button?.color).toBe(tokens.control)
|
||||
expect(Button?.colorHover).toBe(tokens['control-hover'])
|
||||
expect(Select?.peers?.InternalSelection?.color).toBe('var(--app-field)')
|
||||
expect(contrast(tokens.field!, tokens.body!)).toBeGreaterThan(1.1)
|
||||
expect(contrast(tokens.control!, tokens.surface!)).toBeGreaterThan(1.1)
|
||||
expect(state.overrides.value.Checkbox?.checkMarkColor).toBe('#ffffff')
|
||||
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
|
||||
expect(Button?.colorPrimary).toBe(tokens['button-primary'])
|
||||
expect(Button?.colorHoverPrimary).toBe(tokens['button-hover'])
|
||||
expect(Button?.colorPressedPrimary).toBe(tokens['button-pressed'])
|
||||
expect(Button?.colorDisabledPrimary).toBe(tokens['disabled-bg'])
|
||||
expect(Button?.textColorDisabledPrimary).toBe(tokens['disabled-text'])
|
||||
for (const background of [Button!.colorPrimary!, Button!.colorHoverPrimary!, Button!.colorPressedPrimary!]) {
|
||||
expect(contrast(String(Button!.textColorPrimary), String(background))).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
expect(contrast(tokens.ink!, tokens.surface!)).toBeGreaterThanOrEqual(4.5)
|
||||
expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5)
|
||||
expect(contrast(tokens['accent-text']!, tokens.selected!)).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
it('系统切换时浏览器主题色与页面同步', async () => {
|
||||
const meta = document.createElement('meta')
|
||||
meta.name = 'theme-color'
|
||||
document.head.appendChild(meta)
|
||||
try {
|
||||
const state = setup()
|
||||
expect(meta.content).toBe('#ededed')
|
||||
state.change(true)
|
||||
await nextTick()
|
||||
expect(meta.content).toBe('#111111')
|
||||
state.preference.value = 'light'
|
||||
await nextTick()
|
||||
expect(meta.content).toBe('#ededed')
|
||||
} finally {
|
||||
meta.remove()
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user