Files
danyang-match/ui-tests/admin-design-tokens.mjs
T

413 lines
18 KiB
JavaScript
Raw Permalink Normal View History

#!/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 已全部落地,无硬编码色值 / 无越界字号 / 对比度达标')