#!/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 的规则未显式声明 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. 文件清单与