Files
danyang-match/scripts/run-ui-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

111 lines
5.5 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
// UI 契约门禁统一执行器(本地 + CI 共用入口)
//
// 背景:ui-tests/*.mjs 是一组「源码结构与关键行为断言」守卫(纯 node 内建模块、零 npm 依赖),
// 2026-09-18 之前**只在本地手工逐个执行** —— 没有 CI 把关,改坏了没人知道。
// 本脚本把「逐个跑 + 汇总 + 非零退出」固化下来:
//
// node scripts/run-ui-contracts.mjs # 跑 ui-tests/ 下全部守卫(默认)
// node scripts/run-ui-contracts.mjs <file...> # 只跑指定守卫(排查用)
//
// 三条约定(都对应踩过的坑):
// 1) **子进程 cwd 固定为仓库根**。守卫分两类路径风格:多数用 `import.meta.dirname` 自锚定,
// 但 phase03/04/06/07、tabbar-icons、miniapp-auth 用相对路径 —— 在别处调用会假红。
// 2) **守卫数量下限硬校验**:防止「守卫被删除/改名」后依然报绿(Phase 19 教训:扫描清单本身会漏)。
// 新增守卫要上调 MIN_GUARDS;有意删减则下调,二者都必须显式改这行,不能默默变绿。
// 3) 任一守卫非零退出 → 本脚本非零退出(CI 才会红),并打印该守卫的原始输出便于定位。
import { spawnSync } from 'node:child_process'
import { readdirSync } from 'node:fs'
import { basename, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const UI_TESTS_DIR = resolve(root, 'ui-tests')
// 2026-09-19 基线:ui-tests/ 下 18 个守卫
// 14 → 15:新增 wxml-structure-contracts(补「WXML 能否编译」这一层 ——
// 此前 14 绿时小程序仍有两个页面编译失败);
// 15 → 17:新增 miniapp-self-method-contracts(`this._x()` 无定义 → 运行期 TypeError,
// 且该调用位于 resolve 之前时会让所有请求的 Promise 永不 settle)、
// wxss-component-selector-contracts(组件 wxss 用了标签/ID/属性选择器);
// 17 → 18:新增 miniapp-api-client-behavior(**唯一一组行为断言**:桩掉 wx.request,
// 真跑 api.js,验证成功/失败/幂等键释放/收尾容错 4 组行为)。
// 18 → 19:新增 admin-design-tokens(管理端设计 token 守卫:未定义 token / 硬编码色值 /
// 字号下限 / 非法字重 / EP 调色板残留 / **对比度实算** —— 最后一项是此前
// 完全无人把关的一类,且人工目视很难覆盖 24 个页面)。
// 19 → 20:新增 admin-reactivity-contracts(管理端响应式契约:**非响应式状态**——
// 普通 `const x = [...]` 被直接改元素且绑到 v-for 时,改动了但渲染不更新,
// 界面显示陈旧/默认值。2026-09-23 实测数据统计页漏斗卡恒为 0 即此因。
// 该缺陷无 TS/lint/构建报错,前 19 个守卫全绿也抓不到,只有真跑起来看渲染
// 结果才暴露 → 必须由源码结构断言接管)。
// 新增/删减守卫时同步修改本默认值。
// 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 20)
const cliArgs = process.argv.slice(2)
const explicit = cliArgs.length > 0
const guards = explicit
? cliArgs.map((p) => resolve(root, p))
: readdirSync(UI_TESTS_DIR)
.filter((f) => f.endsWith('.mjs'))
.sort()
.map((f) => resolve(UI_TESTS_DIR, f))
if (guards.length === 0) {
console.error('ERROR: 未找到任何守卫(ui-tests/*.mjs)')
process.exit(1)
}
if (!explicit && guards.length < MIN_GUARDS) {
console.error(
`ERROR: 只发现 ${guards.length} 个守卫,少于下限 ${MIN_GUARDS} —— 守卫可能被删除或改名,拒绝报绿。`,
)
console.error(' 新增守卫请上调 MIN_GUARDS;确为有意删减请下调(并说明原因)。')
process.exit(1)
}
console.log(`UI 契约门禁(源码结构与关键行为断言)— ${guards.length} 个守卫\n`)
const pad = String(guards.length).length
const failures = []
const t0 = Date.now()
guards.forEach((file, i) => {
const name = basename(file)
const started = Date.now()
const r = spawnSync(process.execPath, [file], {
cwd: root,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
})
const ms = Date.now() - started
const tag = `[${String(i + 1).padStart(pad)}/${guards.length}]`
if (r.status === 0) {
console.log(`${tag} PASS ${name} (${ms}ms)`)
} else {
console.log(`${tag} FAIL ${name} (${ms}ms)`)
failures.push({ name, status: r.status, stdout: r.stdout, stderr: r.stderr })
}
})
if (failures.length > 0) {
console.log(`\n${'='.repeat(64)}\n失败详情(守卫原始输出)\n${'='.repeat(64)}`)
for (const f of failures) {
console.log(`\n--- ${f.name}(exit ${f.status ?? 'null'})---`)
const out = [f.stdout, f.stderr].filter(Boolean).join('\n').trim()
console.log(out || '(无输出 —— 守卫自身异常退出,需人工排查)')
}
}
const secs = ((Date.now() - t0) / 1000).toFixed(1)
console.log('')
if (failures.length === 0) {
console.log(`✅ PASS ${guards.length} / ${guards.length},0 失败,用时 ${secs}s`)
process.exit(0)
}
console.log(`❌ PASS ${guards.length - failures.length} / ${guards.length},${failures.length} 失败,用时 ${secs}s`)
process.exit(1)