Files
danyang-match/ui-tests/admin-design-tokens.mjs
T
WorkBuddy 631e3000d3 管理端:设计系统 v2 收敛 + 修复漏斗卡恒为 0 的响应式缺陷
## 一、设计系统收敛

- theme/index.css 重写为 token 底座:品牌三级、语义五组、文字四级、
  边框/背景/圆角/阴影/动效/热区/排版刻度
- 修复 Element Plus 派生色阶残留:--el-color-*-light-N 是 SCSS 编译期静态生成,
  只覆盖基色不会带动它们 —— 数据统计页实测 133 处元素仍在用 EP 默认调色板
  (其中主色蓝 8 处)。已按 mix(#fff,base,N*10%) / mix(#000,base,20%) 补全全部档位
- 新增 src/theme/tokens.ts:模板里 :style 需拼 8 位 hex(color + '15'),用不了 var(),
  故以 TS 常量镜像色值;守卫会双向校验它与 index.css 同名取值一致
- 24 个 .vue 收敛:硬编码色值 97 处/67 种 → 0;越界字号 88 处(最低 9px)→ 0;
  非法字重 650×6 → 0;触摸目标 17 处 <44px → 全部 ≥44px
- 对比度:text-tertiary 3.54:1、warning 2.33:1、coral 作文字 3.14:1 → 全部 ≥4.5:1
- 新增守卫 ui-tests/admin-design-tokens.mjs(含 WCAG 对比度实算)

## 二、修复既有功能性缺陷:数据统计页漏斗卡与安全卡恒显示 0

- 现象:同一张卡片里,上方漏斗数字全是 0,下方汇总表却是 12846/9217/8462/3812,
  界面自相矛盾
- 根因:funnelStages / matchStages / safetyCards 是普通数组(const x = [...]),
  loadFunnel() 直接改元素属性,模板读的却是同一个数组 —— 非响应式写入不触发重渲染。
  同页凡走 ref 的值(汇总表、V11 区块)全部正常,规律清晰
- 修法(按根因):三个集合改为 computed() 从既有 ref 派生,卡片与汇总表因此同源,
  结构上不可能再出现「同一份数据的两处展示对不上」;另把 loadFunnel() 内遮蔽
  颜色助手的局部变量 const token 改名 authToken
- 该缺陷无 TS/lint/构建报错,前 18 个守卫全绿也抓不到 —— 只有真跑起来看渲染结果才暴露
- 新增守卫 ui-tests/admin-reactivity-contracts.mjs:
  ①非响应式状态(普通声明 + 被当状态写 + 出现在模板里;未被写过的静态常量清单合法)
  ②数据统计页同源契约 ③颜色助手遮蔽

## 验证

- UI 契约门禁 20/20 PASS(MIN_GUARDS 18→20)
- 负向注入 3 例全部 FAIL(exit 1),还原后复跑 exit 0(证明守卫有判别力)
- vue-tsc --noEmit exit 0
- vite build 成功(17.74s)
- 渲染态实测(Edge 无头 + CDP 读渲染后 DOM,同一 mock、同一路由,只差代码):
  修复前漏斗卡/安全卡全 0、比率全 '-' → 修复后 12846/9217/8462/3812/
  48210/51236/7420/4380/1926/1284,安全卡 17/214/23/9

交付物:artifacts/admin-design-v2-preview.html
2026-09-23 01:06:57 +08:00

413 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 管理端设计 token 守卫(admin-web)
*
* 为什么需要它:
* admin-web 的样式是「裸变量 + 内联硬编码」的老底子。2026-09-22 审计实测:
* 97 处硬编码颜色 / 67 种色值、88 处硬编码字号(20 处 ≤11px,最低 9px)、
* 6 处非法字重 650、17 处热区 <44px,且 --color-text-tertiary(#888) 白底只有
* 3.54:1、--color-warning(#E59A32) 只有 2.33:1 —— 均不达 WCAG AA 4.5:1。
* 更隐蔽的一类:Element Plus 的 --el-color-*-light-N 是编译期静态生成,
* 只覆盖 --el-color-primary 会让开关/滑块/焦点环/分页/标签仍是 EP 默认蓝。
*
* 本守卫把上面每一类都变成可执行断言。宁可在此处失败,也不要等到上线后
* 靠人工目视发现「有些控件是蓝的」「这行字看不清」。
*
* 八项检查:
* 1) 未定义 token 引用 —— var(--x) 找不到定义(样式会静默失效,最难排查)
* 2) 硬编码色值 —— app 层 style 里出现字面 hex / rgb(a)
* 3) 字号下限 —— 正文 <12px、角标 <11px
* 3b) <small> 字号降级风险 —— 选中 small 的规则未显式声明 font-size 时,
* 浏览器的 UA 默认 `font-size: smaller`(0.833em) 会按父级再缩一档:
* 父级 12px → 实际 10px、11px → 9.17px。源码里看不到任何非法值,
* 静态检查天然抓不到(2026-09-22 由渲染态实测才发现),故单独守卫。
* 3c) 相对单位字号 —— font-size 用 em/%/vw/smaller 等时,实际像素取决于父级与
* 视口,无法用统一下限约束;一律改用 px token。
* 4) 非法字重 —— 非 400/500/600/700(跨平台缺字重会回退,渲染不一致)
* 5) EP 调色板残留 —— EP 默认色阶值不得再出现
* 6) 对比度实算 —— 同一规则块内 color 与 background 均可解析时,按 WCAG
* 相对亮度公式实算,正文 <4.5:1、≥24px 大字 <3:1 判失败
* 另有:tokens.ts ↔ theme/index.css 同名 token 取值必须一致(避免两处漂移)。
*
* 退出码:0 通过;1 有失败项。
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { resolve, dirname, relative, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const ADMIN = join(root, 'admin-web')
const SRC = join(ADMIN, 'src')
const THEME = join(SRC, 'theme', 'index.css')
const TOKENS_TS = join(SRC, 'theme', 'tokens.ts')
if (!existsSync(THEME)) {
console.error(`ERROR: 找不到设计系统底座 ${THEME}`)
process.exit(1)
}
// ---------------------------------------------------------------------------
// 工具
// ---------------------------------------------------------------------------
function walk(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (/\.(vue|css)$/.test(e.name)) out.push(p)
}
return out
}
function hexToRgb(h) {
let s = h.replace('#', '')
if (s.length === 3) s = s.split('').map((c) => c + c).join('')
return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
}
function relLuminance(rgb) {
const [r, g, b] = rgb.map((v) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function contrastRatio(fg, bg) {
const l1 = relLuminance(hexToRgb(fg))
const l2 = relLuminance(hexToRgb(bg))
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]
return (hi + 0.05) / (lo + 0.05)
}
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '')
// ---------------------------------------------------------------------------
// 1. 解析设计系统底座:token -> 值(递归解 alias)
// ---------------------------------------------------------------------------
const themeRaw = readFileSync(THEME, 'utf8')
const themeCss = stripComments(themeRaw)
const declMap = new Map() // name -> raw value
for (const m of themeCss.matchAll(/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/g)) {
// 同名重复声明时后写覆盖(CSS 语义)
declMap.set(m[1], m[2].trim())
}
const resolveCache = new Map()
/**
* 解析一个 token 为 { kind: 'color'|'gradient'|'other', hex? }
*/
function resolveToken(name, localMap = null, seen = new Set()) {
if (seen.has(name)) return { kind: 'other' }
seen.add(name)
const cacheKey = localMap ? null : name
if (cacheKey && resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
let raw = localMap?.get(name) ?? declMap.get(name)
if (raw == null || !raw) return { kind: 'other' }
const result = resolveValue(raw, localMap, seen)
if (cacheKey) resolveCache.set(cacheKey, result)
return result
}
function resolveValue(raw, localMap, seen = new Set()) {
const v = raw.trim()
const ref = /^var\(\s*(--[a-zA-Z0-9-]+)\s*\)$/.exec(v)
if (ref) return resolveToken(ref[1], localMap, seen)
if (/gradient\(/i.test(v)) return { kind: 'gradient' }
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) {
return v.length === 9 ? { kind: 'gradient' } : { kind: 'color', hex: v.toUpperCase() }
}
const rgba = /^rgba?\(\s*([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)(?:[ ,/]+([0-9.]+%?))?\s*\)$/.exec(v)
if (rgba) {
const a = rgba[4]
if (a && a !== '1' && a !== '100%') return { kind: 'other' } // 半透明:无法判定实际底
const toHex = (n) => Math.round(Number(n)).toString(16).padStart(2, '0')
return { kind: 'color', hex: `#${toHex(rgba[1])}${toHex(rgba[2])}${toHex(rgba[3])}`.toUpperCase() }
}
return { kind: 'other' }
}
// ---------------------------------------------------------------------------
// 2. 文件清单与 <style> 区域
// ---------------------------------------------------------------------------
const files = walk(SRC)
const rel = (p) => relative(root, p).replace(/\\/g, '/')
const themeRel = rel(THEME)
function styleRegions(file) {
const src = readFileSync(file, 'utf8')
if (file.endsWith('.css')) return [{ css: src, offset: 0, src }]
const regions = []
for (const m of src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
regions.push({ css: m[1], offset: m.index + m[0].indexOf(m[1]), src })
}
// 模板里内联 style="..." / :style="..." 的字面色值
const tpl = src.replace(/<style[\s\S]*?<\/style>/g, '')
for (const m of tpl.matchAll(/:?style="([^"]*)"/g)) {
regions.push({ css: m[1], offset: m.index + m[0].indexOf(m[1]), src, inline: true })
}
return regions
}
const lineOf = (src, abs) => src.slice(0, abs).split('\n').length
// ---------------------------------------------------------------------------
// 3. 逐项检查
// ---------------------------------------------------------------------------
const failures = []
const notes = []
const fail = (file, line, kind, msg) => failures.push({ file, line, kind, msg })
// 每文件的局部自定义属性(:deep 之后的选择器块内赋值,如 --tone-ink)
function localPropsOf(file) {
const src = readFileSync(file, 'utf8')
const map = new Map()
for (const m of src.matchAll(/(--[a-zA-Z0-9-]+)\s*:\s*([^;{}]+);/g)) {
if (declMap.has(m[1])) continue // 全局同名以全局为准
map.set(m[1], m[2].trim())
}
return map
}
const FLOOR_BODY = 12
const FLOOR_BADGE = 11
const BADGE_HINT = /(badge|dot|count|tag|chip|mark|indicator|arrow|caret|icon|star|pill)/i
const EP_PALETTE = new Set(
[
'#409eff', '#79bbff', '#a0cfff', '#c6e2ff', '#d9ecff', '#ecf5ff', '#337ecc',
'#67c23a', '#95d475', '#b3e19d', '#d1edc4', '#e1f3d8', '#f0f9eb', '#529b2e',
'#e6a23c', '#eebe77', '#f3d19e', '#f8e3c5', '#faeecd', '#fdf6ec', '#b88230',
'#f56c6c', '#f89898', '#fab6b6', '#fcd3d3', '#fde2e2', '#fef0f0', '#c45656',
'#909399', '#a6a9ad', '#b1b3b8', '#c8c9cc', '#dcdde0', '#f4f4f5', '#73767a',
].map((c) => c.toUpperCase()),
)
const LEGAL_WEIGHTS = new Set(['400', '500', '600', '700'])
const shadowOnlyRgba = [] // 观察项:阴影里的 rgba(有 token 更佳,但不阻塞)
let undefinedRefs = 0
let hardcodedColors = 0
let tinyFonts = 0
let badWeights = 0
let uaShrinkRisks = 0
let relativeFonts = 0
let epLeaks = 0
let contrastChecks = 0
let contrastSkipped = 0
const contrastSkips = []
for (const file of files) {
const r = rel(file)
if (r === themeRel) continue // 底座是 token 定义处,允许写具体值
const localProps = localPropsOf(file)
for (const region of styleRegions(file)) {
const css = stripComments(region.css)
// --- 1) 未定义 token ---
for (const m of css.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)) {
const name = m[1]
if (name.startsWith('--el-')) continue // Element Plus 运行时/第三方
if (declMap.has(name) || localProps.has(name)) continue
undefinedRefs++
fail(r, region.inline ? undefined : lineOf(region.src, region.offset + m.index), '未定义 token',
`var(${name}) 无定义 —— 该声明会被浏览器整条丢弃`)
}
// --- 2) 硬编码色值 ---
for (const m of css.matchAll(/(#[0-9a-fA-F]{3,8})\b/g)) {
const hex = m[1].toUpperCase()
hardcodedColors++
const isEp = EP_PALETTE.has(hex)
if (isEp) epLeaks++
fail(r, region.inline ? undefined : lineOf(region.src, region.offset + m.index), '硬编码色值',
`${m[1]}${isEp ? ' ← Element Plus 默认调色板残留' : ''}(应改用 var(--token))`)
}
for (const m of css.matchAll(/(^|[;{\s:])((?:color|background(?:-color)?|border(?:-[a-z]+)?|fill|stroke)\s*:\s*[^;{}]*?\brgba?\([^)]*\))/g)) {
hardcodedColors++
fail(r, region.inline ? undefined : lineOf(region.src, region.offset + m.index), '硬编码色值',
`${m[2].trim()}(应改用 var(--token))`)
}
for (const m of css.matchAll(/box-shadow\s*:\s*([^;{}]*rgba?\([^)]*\))/g)) {
shadowOnlyRgba.push(`${r} ${m[1].trim()}`)
}
// --- 3) 字号下限 / 4) 非法字重 ---
// 按规则块逐个判定,才能拿到「选择器 + 该块内的字号」上下文
for (const block of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selector = block[1].replace(/\s+/g, ' ').trim()
const body = block[2]
const fsm = /font-size\s*:\s*([0-9.]+)px/.exec(body)
if (fsm) {
const px = parseFloat(fsm[1])
const isBadge = BADGE_HINT.test(selector)
const floor = isBadge ? FLOOR_BADGE : FLOOR_BODY
if (px < floor) {
tinyFonts++
fail(r, undefined, '字号下限',
`${selector} → font-size:${px}px 低于下限 ${floor}px${isBadge ? '(角标类)' : ''}`)
}
}
for (const wm of body.matchAll(/font-weight\s*:\s*([0-9]{3})/g)) {
if (LEGAL_WEIGHTS.has(wm[1])) continue
badWeights++
fail(r, undefined, '非法字重',
`${selector} → font-weight:${wm[1]}(只允许 400/500/600/700,其余在缺字重平台会回退)`)
}
// --- 3b) <small> 依赖 UA 默认字号会跌破下限(渲染态才可见) ---
// 选择器里出现 small 元素选择器(排除 .small / --small 这类词片段)。
// 注意:判定「有没有声明字号」必须用宽松匹配(token 写法 var(--font-size-x) 不含 px),
// 否则会把已经声明过的规则误报成裸奔。
const declaresFontSize = /font-size\s*:/.test(body)
const targetsSmall = /(^|[\s,>+~])small(\s*$|[\s,>+~:.[])/.test(selector + ' ')
if (targetsSmall && !declaresFontSize) {
uaShrinkRisks++
fail(r, undefined, '<small> 字号降级风险',
`${selector} 未声明 font-size —— <small> 的 UA 默认 smaller(0.833em) 会按父级再缩一档,` +
`实测会落到 10px / 9.17px,跌破 ${FLOOR_BADGE}px 下限`)
}
// --- 3c) 相对单位字号:实际像素取决于父级/视口,无法用统一下限约束 ---
const relFs = /font-size\s*:\s*(?:-?[0-9.]+(?:em|%|ex|ch|vw|vh|vmin|vmax)|smaller|larger|x-small|xx-small|x-large|xx-large)\b/.exec(body)
if (relFs) {
relativeFonts++
fail(r, undefined, '相对单位字号',
`${selector} → font-size:${relFs[1] ?? relFs[0].split(':')[1].trim()}(改用 var(--font-size-*) 像素刻度)`)
}
// --- 6) 对比度实算:仅当同一规则块内同时给出 color 与 background 时才判定 ---
const fgDecl = /(?:^|;)\s*color\s*:\s*([^;{}]+)/.exec(body)
const bgDecl = /(?:^|;)\s*background(?:-color)?\s*:\s*([^;{}]+)/.exec(body)
if (fgDecl && bgDecl) {
const fg = resolveValue(fgDecl[1].trim(), localProps)
const bgRaw = bgDecl[1].trim()
// background 简写里带 url()/center/cover 等时仍可取出首个颜色
const bgPart = /\bvar\([^)]*\)|#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)/.exec(bgRaw)
const bg = bgPart ? resolveValue(bgPart[0], localProps) : { kind: 'other' }
if (fg.kind === 'color' && bg.kind === 'color') {
const ratio = contrastRatio(fg.hex, bg.hex)
const large = fsm ? parseFloat(fsm[1]) >= 24 : false
const need = large ? 3 : 4.5
contrastChecks++
if (ratio < need) {
fail(r, undefined, '对比度',
`${selector} → ${fg.hex} on ${bg.hex} = ${ratio.toFixed(2)}:1,低于 ${need}:1`)
}
} else {
contrastSkipped++
contrastSkips.push(`${r} ${selector} → color:${fgDecl[1].trim()} background:${bgRaw}`)
}
}
}
}
}
// --- 2b) 模板 / 脚本里的硬编码色值 ---
// 典型形态:`<el-progress color="#EE666B">`、TS 数据里的 `color: '#409EFF'`。
// 这类值不会出现在 <style> 里,但同样是「色值漂移」的来源(TS 侧应走 src/theme/tokens.ts)。
for (const file of files) {
const r = rel(file)
if (r === themeRel) continue
const src = readFileSync(file, 'utf8')
const outsideStyle = src
.replace(/<style[\s\S]*?<\/style>/g, '')
.replace(/:?style="[^"]*"/g, '') // 已由上面的内联 style 扫描覆盖,避免重复计数
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*(\/\/|\*).*$/gm, '') // 去掉行注释,避免注释里的示例色值误报
for (const m of outsideStyle.matchAll(/(#[0-9a-fA-F]{3,8})\b/g)) {
const hex = m[1].toUpperCase()
const isEp = EP_PALETTE.has(hex)
if (isEp) epLeaks++
hardcodedColors++
fail(r, lineOf(src, m.index), '硬编码色值(模板/脚本)',
`${m[1]}${isEp ? ' ← Element Plus 默认调色板残留' : ''}(style 用 var(--token);TS 用 src/theme/tokens.ts 的 token())`)
}
}
// --- 5) EP 色阶只允许在底座里定义 ---
for (const file of files) {
const r = rel(file)
if (r === themeRel) continue
const src = stripComments(readFileSync(file, 'utf8'))
for (const m of src.matchAll(/(--el-color-[a-z0-9-]+)\s*:/g)) {
epLeaks++
fail(r, undefined, 'EP 色阶越权',
`${m[1]} 只应在 src/theme/index.css 定义(散落定义会造成组件配色不一致)`)
}
}
// --- tokens.ts ↔ theme/index.css 一致性 ---
if (existsSync(TOKENS_TS)) {
const ts = readFileSync(TOKENS_TS, 'utf8')
let checked = 0
for (const m of ts.matchAll(/'(--[a-zA-Z0-9-]+)'\s*:\s*'(#[0-9a-fA-F]{3,8})'/g)) {
const [, name, hex] = m
const resolved = resolveToken(name)
checked++
if (resolved.kind !== 'color') {
fail(rel(TOKENS_TS), undefined, 'tokens.ts 漂移', `${name} 在 theme/index.css 中不是可解析的颜色(实际 kind=${resolved.kind})`)
} else if (resolved.hex !== hex.toUpperCase()) {
fail(rel(TOKENS_TS), undefined, 'tokens.ts 漂移',
`${name}: tokens.ts=${hex} 但 theme/index.css=${resolved.hex} —— 两处必须同步`)
}
}
notes.push(`tokens.ts ↔ theme/index.css 一致性校验:${checked} 项`)
}
// ---------------------------------------------------------------------------
// 4. 汇总
// ---------------------------------------------------------------------------
const byKind = new Map()
for (const f of failures) byKind.set(f.kind, (byKind.get(f.kind) || 0) + 1)
console.log('=========== admin-web 设计 token 守卫 ===========')
console.log(`扫描文件: ${files.length}(.vue / .css,排除 node_modules)`)
console.log(`底座 token 数: ${declMap.size}`)
console.log('')
console.log(`1) 未定义 token 引用 : ${undefinedRefs}`)
console.log(`2) 硬编码色值 : ${hardcodedColors}${epLeaks ? `(含 EP 调色板残留 ${epLeaks})` : ''}`)
console.log(`3) 低于下限的字号 : ${tinyFonts}`)
console.log(`3b) <small> 降级风险 : ${uaShrinkRisks}`)
console.log(`3c) 相对单位字号 : ${relativeFonts}`)
console.log(`4) 非法字重 : ${badWeights}`)
console.log(`5) 对比度实算 : ${contrastChecks} 组判定 / ${contrastSkipped} 组跳过(跨选择器变量或渐变)`)
if (shadowOnlyRgba.length) {
console.log(`观察项) 阴影内联 rgba : ${shadowOnlyRgba.length} 处(有对应 token 更佳,暂不阻塞)`)
}
// 跳过项必须可审计:设 ADMIN_GATE_VERBOSE=1 打印明细,确认跳过理由都是
// 「跨选择器变量(如 tone 组)」或「渐变底」,而不是把真正的低对比度放过去了。
if (process.env.ADMIN_GATE_VERBOSE && contrastSkips.length) {
console.log(`跳过明细(${contrastSkips.length}):`)
for (const s of contrastSkips) console.log(` · ${s}`)
}
for (const n of notes) console.log(` · ${n}`)
console.log('')
if (failures.length) {
console.log(`失败项 ${failures.length} 处:`)
const grouped = new Map()
for (const f of failures) {
if (!grouped.has(f.file)) grouped.set(f.file, [])
grouped.get(f.file).push(f)
}
for (const [file, list] of [...grouped.entries()].sort()) {
console.log(`\n--- ${file} (${list.length})`)
for (const f of list.slice(0, 25)) {
console.log(` ${f.line ? `L${f.line} ` : ''}[${f.kind}] ${f.msg}`)
}
if (list.length > 25) console.log(` … 另有 ${list.length - 25} 处`)
}
console.log('')
console.log(`RESULT: FAIL —— ${[...byKind.entries()].map(([k, v]) => `${k} ${v}`).join(' / ')}`)
process.exit(1)
}
console.log('RESULT: PASS —— 设计 token 已全部落地,无硬编码色值 / 无越界字号 / 对比度达标')