From af68b20f873392917aa7defca3f3622a9dcacf9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 12:15:10 +0800 Subject: [PATCH] =?UTF-8?q?task-4(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E5=AE=9A=E4=B9=89=E6=B7=B1=E8=89=B2=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E4=B8=8E=E6=B5=85=E8=89=B2=E5=86=85=E5=AE=B9=E5=8C=BA?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=20token=20=E5=B9=B6=E9=94=81=E5=AE=9A?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 src/styles/theme.ts 语义 token 单源 + 相对亮度/对比度/isDarkSurface, tests 校验侧边栏暗、内容区亮、正文 WCAG AA、与 main.css 变量交叉一致防漂移。 --- admin-frontend-vue/src/styles/theme.ts | 69 +++++++++++++++++++++++ admin-frontend-vue/tests/task-4.test.ts | 75 +++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 admin-frontend-vue/src/styles/theme.ts create mode 100644 admin-frontend-vue/tests/task-4.test.ts diff --git a/admin-frontend-vue/src/styles/theme.ts b/admin-frontend-vue/src/styles/theme.ts new file mode 100644 index 00000000..d09484a8 --- /dev/null +++ b/admin-frontend-vue/src/styles/theme.ts @@ -0,0 +1,69 @@ +/** + * Admin 主题 token 单源(任务 4):深色侧边栏 + 浅色内容区。 + * main.css 使用同名 --admin-* CSS 变量承载渲染值;tests 用本模块校验 + * “侧边栏为暗、内容区为亮、正文对比度达标”的不变量,防止误改成浅色侧栏。 + */ +export const ADMIN_THEME = { + sidebar: '#192132', + sidebarDeep: '#121827', + primary: '#6366f1', + primarySoft: '#eef0ff', + bg: '#f3f5f9', + border: '#e5e7ef', + text: '#1f2937', + muted: '#7b8495', +} as const + +export type AdminThemeKey = keyof typeof ADMIN_THEME + +export const ADMIN_THEME_KEYS = Object.keys(ADMIN_THEME) as AdminThemeKey[] + +/** CSS 变量名:camelCase -> kebab(sidebarDeep -> --admin-sidebar-deep)。 */ +export function cssVarName(key: AdminThemeKey): string { + const kebab = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`) + return `--admin-${kebab}` +} + +function parseHex(hex: string): { r: number; g: number; b: number } { + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) { + throw new Error(`主题色必须是 #rrggbb 六位十六进制: ${hex}`) + } + const raw = hex.slice(1) + return { + r: parseInt(raw.slice(0, 2), 16), + g: parseInt(raw.slice(2, 4), 16), + b: parseInt(raw.slice(4, 6), 16), + } +} + +/** sRGB 通道 -> 线性亮度。 */ +function channelLuminance(channel: number): number { + const s = channel / 255 + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 +} + +/** 相对亮度(0 纯黑 ~ 1 纯白)。 */ +export function relativeLuminance(hex: string): number { + const { r, g, b } = parseHex(hex) + return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b) +} + +export function isDarkSurface(hex: string, threshold = 0.2): boolean { + return relativeLuminance(hex) < threshold +} + +/** WCAG 对比度(1 ~ 21),fg/bg 可为 8 位 hex。 */ +export function contrastRatio(fgHex: string, bgHex: string): number { + const lighter = Math.max(relativeLuminance(fgHex), relativeLuminance(bgHex)) + const darker = Math.min(relativeLuminance(fgHex), relativeLuminance(bgHex)) + return (lighter + 0.05) / (darker + 0.05) +} + +/** 取某个语义 token 的 hex;未知 key 给出可操作错误。 */ +export function tokenHex(key: AdminThemeKey): string { + const value = ADMIN_THEME[key] + if (!value) { + throw new Error(`未知 Admin 主题 token: ${String(key)},允许值: ${ADMIN_THEME_KEYS.join(', ')}`) + } + return value +} diff --git a/admin-frontend-vue/tests/task-4.test.ts b/admin-frontend-vue/tests/task-4.test.ts new file mode 100644 index 00000000..6c67d694 --- /dev/null +++ b/admin-frontend-vue/tests/task-4.test.ts @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + ADMIN_THEME, + ADMIN_THEME_KEYS, + contrastRatio, + cssVarName, + isDarkSurface, + relativeLuminance, + tokenHex, +} from '../src/styles/theme.ts' + +test('test_task_004_theme_tokens_normal_primary_path', () => { + // 正常主路径:侧边栏与加深面是暗色,内容区/卡片底是亮色。 + assert.equal(isDarkSurface(ADMIN_THEME.sidebar), true, '侧边栏必须为暗色') + assert.equal(isDarkSurface(ADMIN_THEME.sidebarDeep), true, '侧边栏加深面必须为暗色') + assert.equal(isDarkSurface(ADMIN_THEME.bg), false, '内容区背景必须为亮色') + assert.equal(isDarkSurface(ADMIN_THEME.border), false, '边框须为亮面中性色') +}) + +test('test_task_004_theme_tokens_normal_variant_input', () => { + // 正常变体:强调色与软底/正文高对比,正文 token 自成一致体系。 + assert.equal(relativeLuminance(ADMIN_THEME.primary), relativeLuminance('#6366f1')) + assert.ok(contrastRatio(ADMIN_THEME.text, ADMIN_THEME.bg) >= 4.5, '正文/内容区需满足 WCAG AA 正文') + assert.ok(contrastRatio(ADMIN_THEME.primary, ADMIN_THEME.bg) >= 3.0, '品牌色按钮须达到 3:1') +}) + +test('test_task_004_theme_tokens_normal_repeated_operation_is_idempotent', () => { + // 正常重复:亮度与对比度计算幂等、无状态污染。 + assert.equal(relativeLuminance(ADMIN_THEME.bg), relativeLuminance(ADMIN_THEME.bg)) + assert.equal(isDarkSurface(ADMIN_THEME.sidebar), isDarkSurface(ADMIN_THEME.sidebar)) + assert.ok(contrastRatio(ADMIN_THEME.text, ADMIN_THEME.bg) === contrastRatio(ADMIN_THEME.text, ADMIN_THEME.bg)) +}) + +test('test_task_004_theme_tokens_boundary_empty_input', () => { + // 边界空值:token 表非空、无空值、无未知多余键、键集合稳定。 + assert.ok(ADMIN_THEME_KEYS.length >= 7, '必须覆盖侧边栏/内容区/正文等核心语义面') + for (const key of ADMIN_THEME_KEYS) { + assert.match(ADMIN_THEME[key], /^#[0-9a-fA-F]{6}$/, `${key} 须为 #rrggbb`) + } +}) + +test('test_task_004_theme_tokens_boundary_single_item', () => { + // 边界单元素:单个中性边界 token 与内容区可区分;侧边栏深于内容区。 + assert.notEqual(ADMIN_THEME.border, ADMIN_THEME.bg) + assert.ok(relativeLuminance(ADMIN_THEME.sidebar) < relativeLuminance(ADMIN_THEME.bg)) +}) + +test('test_task_004_theme_tokens_boundary_limit_or_missing_field', () => { + // 边界上限:次要文本 muted 在内容区上仍可达 3:1;CSS 变量名映射无重复斜杠。 + assert.ok(contrastRatio(ADMIN_THEME.muted, ADMIN_THEME.bg) >= 3.0) + assert.equal(cssVarName('sidebar'), '--admin-sidebar') + assert.equal(cssVarName('sidebarDeep'), '--admin-sidebar-deep') + assert.ok(!cssVarName('primary').includes('--admin-admin-')) +}) + +test('test_task_004_theme_tokens_invalid_input_rejected', () => { + // 异常输入:非法 hex、未知 token 拒绝并报错;CSS 不得引用旧后台整份样式。 + assert.throws(() => relativeLuminance('#12'), /#rrggbb/) + assert.throws(() => relativeLuminance('sidebar'), /#rrggbb/) + assert.throws(() => tokenHex('unknown' as never), /未知 Admin 主题 token/) + const css = readSource('src/styles/main.css') + assert.equal(/@import/.test(css), false, 'main.css 不得 @import 旧后台整份样式') +}) + +test('test_task_004_theme_tokens_dependency_failure_returns_actionable_message', () => { + // 依赖失败:token 与 main.css 渲染变量交叉校验必须一致(防双源漂移)。 + const css = readSource('src/styles/main.css') + const missing = ADMIN_THEME_KEYS.filter((key) => { + const tokenVar = `--admin-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}` + return !css.includes(`${tokenVar}: ${ADMIN_THEME[key]}`) + }) + assert.deepEqual(missing, [], `theme.ts 与 main.css 不一致,缺失变量: ${missing.join(', ')}`) +})