管理端:设计系统 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 已全部落地,无硬编码色值 / 无越界字号 / 对比度达标')
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 管理端响应式契约守卫(admin-web)
|
||||
*
|
||||
* 为什么需要它:
|
||||
* 2026-09-23 渲染态实测(Edge 无头 + CDP 取计算样式/DOM 文本)发现数据统计页
|
||||
* **漏斗卡恒显示 0**,而正下方同一张卡里的汇总表却显示 12846 / 9217 / 8462 / 3812
|
||||
* —— 同一个页面、同一个数据源,两处互相矛盾。
|
||||
*
|
||||
* 根因不是取数失败(控制台 0 错误、网络 0 失败):这三组是
|
||||
* const funnelStages = [ { value: 0, ... } ] // 普通数组,非响应式
|
||||
* funnelStages[0].value = userData.totalRegistered // 直接改元素
|
||||
* 而模板读的就是同一个数组 —— **非响应式写入不触发重渲染**,Vue 看不到变化。
|
||||
* 对比之下,凡走 `ref` 的值(汇总表、V11 区块、日程)全部正常。
|
||||
*
|
||||
* 这类缺陷的可怕之处:
|
||||
* - 源码里完全合法(无 TS 报错、无 lint 报错、构建通过);
|
||||
* - 19 个既有守卫全绿(都是源码结构断言,看不出「渲染后没更新」);
|
||||
* - 只有**真跑起来看渲染结果**才暴露 —— 即「全绿 ≠ 有效」的又一例。
|
||||
* 所以必须把它变成可执行断言:**非响应式声明 + 被当状态写 + 出现在模板里 = 失败**。
|
||||
*
|
||||
* 三项检查:
|
||||
* 1) 非响应式状态(核心)—— 普通 `const x = [...]` / `= {}` 若在 <script> 里被写
|
||||
* (`x[i] = `、`x.k = `、`.push()/.splice()` 等)且出现在 <template> 中,
|
||||
* 则渲染不会更新。注意:**未被写过的静态常量清单(如 ACTIVITY_TYPES)是合法的**,
|
||||
* 初始化后不再变化,不需要响应式 —— 故按「是否被写」区分,不做一刀切。
|
||||
* 同时排除 `router.push()` 这类**实例方法调用**(判据:只有字面量数组/对象/Map/Set
|
||||
* 才把 `.push()` 视为状态写入),以及模板局部变量(外层 v-for 解构出来的)。
|
||||
* 2) 数据统计页同源契约 —— 漏斗卡与汇总表必须取自同一变量(卡片不得硬编码演示数字),
|
||||
* 且 Phase 20 补齐的 4 个端点不得被删。
|
||||
* 3) 颜色助手遮蔽 —— 从 @/theme/tokens **非别名**导入 `token` 时,不得再有同名局部
|
||||
* 变量(`const token = localStorage.getItem('accessToken')` 是本项目常见写法)。
|
||||
* 若有人在该函数内调用 token('--color-x') 会运行期崩溃。
|
||||
* 已用 `token as tokenColor` 的文件不算遮蔽(检查会识别别名)。
|
||||
*
|
||||
* 实现注意:分析前**必须剥离注释**。否则注释里出现的 `const token = ...` 或
|
||||
* `x.push(` 会造成误报(本守卫首次运行即被自己的说明注释误伤)。
|
||||
*
|
||||
* 退出码:0 通过;1 有失败项(含守卫自身异常)。
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const SRC = resolve(root, 'admin-web/src')
|
||||
const ANALYTICS = resolve(SRC, 'modules/analytics/AnalyticsView.vue')
|
||||
|
||||
assert.ok(existsSync(SRC), `找不到管理端源码目录 ${SRC}`)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具
|
||||
// ---------------------------------------------------------------------------
|
||||
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 (e.name.endsWith('.vue')) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 抽 SFC 的根级块。注意嵌套 <template #header> 会让非贪婪匹配提前截断。 */
|
||||
function block(src, tag, attrs = '') {
|
||||
const m = src.match(new RegExp(`^<${tag}${attrs}[^>]*>([\\s\\S]*?)^</${tag}>`, 'm'))
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
/** 剥离 JS 注释(含字符串感知,避免 comment 里的内容参与判定) */
|
||||
function stripComments(code) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
let quote = null
|
||||
while (i < code.length) {
|
||||
const c = code[i]
|
||||
const n = code[i + 1]
|
||||
if (quote) {
|
||||
if (c === '\\') { out += c + (n ?? ''); i += 2; continue }
|
||||
if (c === quote) quote = null
|
||||
out += c; i++; continue
|
||||
}
|
||||
if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; continue }
|
||||
if (c === '/' && n === '/') { while (i < code.length && code[i] !== '\n') i++; continue }
|
||||
if (c === '/' && n === '*') {
|
||||
i += 2
|
||||
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++
|
||||
i += 2
|
||||
out += ' '
|
||||
continue
|
||||
}
|
||||
out += c; i++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const stripHtmlComments = (s) => s.replace(/<!--[\s\S]*?-->/g, '')
|
||||
|
||||
const REACTIVE_CTORS =
|
||||
/^(ref|reactive|computed|shallowRef|shallowReactive|toRef|toRefs|customRef|readonly|markRaw)\s*[(<]/
|
||||
|
||||
/** 非别名导入颜色助手 → 存在遮蔽风险 */
|
||||
const IMPORTS_TOKEN_HELPER = /import\s*\{[^}]*\btoken\b(?!\s+as\b)[^}]*\}\s*from\s*'@\/theme\/tokens'/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 扫全量 .vue
|
||||
// ---------------------------------------------------------------------------
|
||||
const files = walk(SRC)
|
||||
let totalVfor = 0
|
||||
let reactiveOk = 0
|
||||
let staticConst = 0
|
||||
let skipped = 0
|
||||
const violations = []
|
||||
const shadowed = []
|
||||
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, 'utf8')
|
||||
const rel = relative(SRC, file).replace(/\\/g, '/')
|
||||
const rawScr = block(src, 'script', ' setup')
|
||||
const rawTpl = block(src, 'template')
|
||||
if (!rawScr) continue
|
||||
const scr = stripComments(rawScr)
|
||||
const tpl = rawTpl ? stripHtmlComments(rawTpl) : null
|
||||
|
||||
// --- 3) 颜色助手遮蔽 ---
|
||||
if (IMPORTS_TOKEN_HELPER.test(scr) && /(?:const|let|var)\s+token\s*=/.test(scr)) {
|
||||
shadowed.push(rel)
|
||||
}
|
||||
|
||||
if (!tpl) continue
|
||||
|
||||
// --- 声明表 ---
|
||||
const kind = new Map()
|
||||
const init = new Map()
|
||||
for (const m of scr.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*([^\n]*)/g)) {
|
||||
if (kind.has(m[1])) continue
|
||||
kind.set(m[1], REACTIVE_CTORS.test(m[2].trim()) ? 'reactive' : 'plain')
|
||||
init.set(m[1], m[2])
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否在 <script> 里被「当状态写」。声明行本身不算。
|
||||
* `.push()/.splice()` 这类只有当容器是**字面量数组/对象/Map/Set** 时才算状态写入,
|
||||
* 否则 `router.push()`(useRouter() 实例方法)会被误判。
|
||||
*/
|
||||
const isMutated = (name) => {
|
||||
const esc = name.replace(/[$]/g, '\\$')
|
||||
const literal = /^\s*[[{]|^\s*new\s+(?:Map|Set|Array|WeakMap|WeakSet)\b/.test(init.get(name) ?? '')
|
||||
const mutator = literal
|
||||
? '|\\\\.(?:push|pop|shift|unshift|splice|sort|reverse|set|add|delete)\\('
|
||||
: ''
|
||||
const write = new RegExp(
|
||||
`^\\s*(?:if|while|for|return|await|void|el|else)?\\s*` +
|
||||
`${esc}(?:\\[[^\\]]*\\]|\\.[A-Za-z_$][\\w$]*)*` +
|
||||
`\\s*(?:=[^=]|\\+=|-=|\\+\\+|--${mutator})`,
|
||||
)
|
||||
return scr
|
||||
.split('\n')
|
||||
.some((l) => !/^\s*(?:const|let|var)\s/.test(l) && write.test(l))
|
||||
}
|
||||
|
||||
/** 模板里是否引用了该标识符(排除属性名/字符串内的误匹配) */
|
||||
const inTemplate = (name) =>
|
||||
new RegExp(`(?<![\\w$.'"])${name}(?![\\w$])`).test(tpl)
|
||||
|
||||
// --- 1a) v-for 绑定非响应式集合 ---
|
||||
for (const m of tpl.matchAll(/v-for\s*=\s*"([^"]*)"/g)) {
|
||||
totalVfor++
|
||||
const parts = m[1].split(/\s+in\s+|\s+of\s+/)
|
||||
if (parts.length < 2) continue
|
||||
const iter = parts[parts.length - 1].trim().replace(/^\(|\)$/g, '').trim()
|
||||
// 成员表达式 / 函数调用 / 下标 → 不是本守卫能判定的裸标识符,跳过
|
||||
if (/[.[\]()]/.test(iter)) { skipped++; continue }
|
||||
const bare = iter.replace(/[!?]+$/, '')
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(bare)) { skipped++; continue }
|
||||
// 模板局部(外层 v-for 解构)不在此次声明表中 → 跳过
|
||||
if (!kind.has(bare)) { skipped++; continue }
|
||||
if (kind.get(bare) === 'reactive') { reactiveOk++; continue }
|
||||
if (!isMutated(bare)) { staticConst++; continue }
|
||||
violations.push({ rel, name: bare, via: 'v-for' })
|
||||
}
|
||||
|
||||
// --- 1b) 非响应式 + 被写 + 模板引用(插值 / :prop 同样读不到更新) ---
|
||||
for (const [name, k] of kind) {
|
||||
if (k !== 'plain' || !isMutated(name) || !inTemplate(name)) continue
|
||||
if (violations.some((v) => v.rel === rel && v.name === name)) continue
|
||||
violations.push({ rel, name, via: 'template-ref' })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) 数据统计页同源契约
|
||||
// ---------------------------------------------------------------------------
|
||||
assert.ok(existsSync(ANALYTICS), '缺少数据统计页 admin-web/src/modules/analytics/AnalyticsView.vue')
|
||||
const avRaw = readFileSync(ANALYTICS, 'utf8')
|
||||
const avScr = stripComments(block(avRaw, 'script', ' setup') ?? '')
|
||||
const avTpl = stripHtmlComments(block(avRaw, 'template') ?? '')
|
||||
|
||||
for (const ep of ['user-funnel', 'match-funnel', 'safety', 'v11-dashboard']) {
|
||||
assert.match(avScr, new RegExp(`/operator/analytics/${ep}`), `数据统计页必须调用 /operator/analytics/${ep}`)
|
||||
}
|
||||
for (const name of ['funnelStages', 'matchStages', 'safetyCards']) {
|
||||
assert.match(
|
||||
avScr,
|
||||
new RegExp(`const ${name} = computed\\(`),
|
||||
`${name} 必须用 computed 从 ref 派生(否则卡片与汇总表各持一份、无法同步)`,
|
||||
)
|
||||
}
|
||||
// 同源:卡片取 ref,汇总表也必须渲染**同一个 ref 的同一字段**
|
||||
for (const n of ['totalRegistered', 'verifiedCount', 'profileApproved', 'activeUsers']) {
|
||||
assert.ok(avScr.includes(`funnelStats.value.${n}`), `漏斗卡字段 ${n} 必须取自 funnelStats(同源)`)
|
||||
assert.ok(avTpl.includes(`funnelStats.${n}`), `同卡汇总表必须渲染 funnelStats.${n}(与卡片同源)`)
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
avScr,
|
||||
/label:\s*'累计注册'[^\n]*value:\s*[\d,]/,
|
||||
'卡片不得硬编码演示数字(应为 ref 派生)',
|
||||
)
|
||||
|
||||
assert.deepEqual(shadowed, [], `局部变量 token 遮蔽了 @/theme/tokens 导入的颜色助手:${shadowed.join(', ')}`)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 结果
|
||||
// ---------------------------------------------------------------------------
|
||||
if (violations.length > 0) {
|
||||
console.error('发现非响应式状态(改动了但渲染不会更新,界面会显示陈旧/默认值):\n')
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.rel} → ${v.name} [${v.via}]`)
|
||||
}
|
||||
console.error('\n修法:改用 ref()/reactive() 持有状态;或更彻底地 computed() 从单一数据源派生,')
|
||||
console.error(' 让「同一份数据的两处展示」结构上不可能对不上。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Admin reactivity contracts passed')
|
||||
console.log(` 扫描 .vue : ${files.length}`)
|
||||
console.log(` v-for 绑定点 : ${totalVfor}(响应式 ${reactiveOk} / 静态常量 ${staticConst} / 跳过 ${skipped})`)
|
||||
console.log(' 非响应式状态 : 0')
|
||||
console.log(' 颜色助手遮蔽 : 0')
|
||||
Reference in New Issue
Block a user