管理端:设计系统 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
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
#!/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 已全部落地,无硬编码色值 / 无越界字号 / 对比度达标')
|
||||
Reference in New Issue
Block a user