Files
danyang-match/ui-tests/admin-reactivity-contracts.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

241 lines
11 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
/**
* 管理端响应式契约守卫(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')