feat: 调整微信风格明暗主题并完整展示详情图片

This commit is contained in:
GouJ
2026-09-01 19:09:35 +08:00
parent 3a60eebae0
commit 565442c9b1
11 changed files with 202 additions and 70 deletions
+76 -4
View File
@@ -1,6 +1,7 @@
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(() => {
@@ -30,7 +31,22 @@ function setup(dark = false) {
}
}
describe('黑白双主题', () => {
/** 将十六进制颜色换算为 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('默认跟随系统并实时响应,销毁时移除监听', async () => {
const state = setup()
expect(state.preference.value).toBe('system')
@@ -60,15 +76,71 @@ describe('黑白双主题', () => {
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
const state = setup()
expect(state.overrides.value.common?.primaryColor).toBe('#202020')
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('#f5f5f5')
expect(state.overrides.value.Button?.textColorPrimary).toBe('#171717')
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
expect(state.overrides.value.common?.bodyColor).toBe('#111111')
expect(state.overrides.value.Button?.textColorPrimary).toBe('#082b17')
expect(document.documentElement.dataset.theme).toBe('dark')
})
it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => {
const state = setup(dark)
const { common, Menu, Button, Layout } = 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({ itemColorActive: tokens.selected, itemTextColorActive: tokens['accent-text'] })
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
for (const background of [common!.primaryColor!, common!.primaryColorHover!, common!.primaryColorPressed!]) {
expect(contrast(String(Button!.textColorPrimary), 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)
+70 -27
View File
@@ -24,48 +24,88 @@ export function useTheme() {
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'
// 微信风格灰阶表面与绿色操作态;文字绿独立加深,避免浅底小字对比不足。
const primary = '#07c160'
const hover = '#2dcb79'
const pressed = '#06ad56'
const onPrimary = '#082b17'
const accentText = dark ? '#5cd693' : '#087c42'
const body = dark ? '#111111' : '#ededed'
const surface = dark ? '#191919' : '#ffffff'
const subtle = dark ? '#232323' : '#f7f7f7'
const text = dark ? '#d9d9d9' : '#191919'
const muted = dark ? '#a3a3a3' : '#707070'
const selected = dark ? '#173527' : '#e4f4e9'
const info = dark ? '#91a8d2' : '#576b95'
const danger = dark ? '#fa7373' : '#a93232'
const warning = dark ? '#e8b66a' : '#916019'
return {
common: {
primaryColor: primary,
primaryColorHover: hover,
primaryColorPressed: hover,
primaryColorPressed: pressed,
primaryColorSuppl: primary,
infoColor: primary,
infoColorHover: hover,
infoColorPressed: hover,
infoColorSuppl: primary,
infoColor: info,
infoColorHover: info,
infoColorPressed: info,
infoColorSuppl: info,
successColor: accentText,
successColorHover: accentText,
successColorPressed: accentText,
successColorSuppl: accentText,
errorColor: danger,
errorColorHover: danger,
errorColorPressed: danger,
errorColorSuppl: danger,
warningColor: warning,
warningColorHover: warning,
warningColorPressed: warning,
warningColorSuppl: warning,
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',
bodyColor: body,
cardColor: surface,
modalColor: surface,
popoverColor: dark ? '#2c2c2c' : surface,
tableColor: surface,
inputColor: surface,
actionColor: subtle,
borderColor: dark ? '#333333' : '#e5e5e5',
dividerColor: dark ? '#333333' : '#e5e5e5',
hoverColor: subtle,
textColorBase: text,
textColor1: text,
textColor2: text,
textColor3: muted,
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'
textColorPrimary: onPrimary,
textColorHoverPrimary: onPrimary,
textColorPressedPrimary: onPrimary,
textColorFocusPrimary: onPrimary,
textColorDisabledPrimary: onPrimary,
textColorTextPrimary: accentText,
textColorGhostPrimary: accentText
},
Layout: {
color: dark ? '#141414' : '#f5f5f5',
headerColor: dark ? '#1c1c1c' : '#ffffff',
siderColor: dark ? '#1c1c1c' : '#ffffff'
color: body,
headerColor: surface,
siderColor: subtle
},
Menu: {
itemColorActive: dark ? '#333333' : '#eeeeee',
itemColorActiveHover: dark ? '#383838' : '#e7e7e7',
itemTextColorActive: primary,
itemTextColorActiveHover: primary,
itemIconColorActive: primary,
itemIconColorActiveHover: primary
itemColorActive: selected,
itemColorActiveHover: selected,
itemColorHover: dark ? '#2c2c2c' : '#e8e8e8',
itemTextColorActive: accentText,
itemTextColorActiveHover: accentText,
itemIconColorActive: accentText,
itemIconColorActiveHover: accentText
},
Checkbox: { checkMarkColor: dark ? '#171717' : '#ffffff' },
Tabs: { tabTextColorActiveLine: accentText, tabTextColorHoverLine: accentText, barColor: primary },
Tag: { textColorPrimary: accentText },
Checkbox: { checkMarkColor: onPrimary },
Switch: { railColorActive: primary },
Progress: { fillColor: primary }
}
})
@@ -78,6 +118,9 @@ export function useTheme() {
watchEffect(() => {
document.documentElement.dataset.theme = isDark.value ? 'dark' : 'light'
document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light'
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDark.value ? '#111111' : '#ededed')
try {
localStorage.setItem(storageKey, preference.value)
} catch {