## 一、设计系统收敛 - 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
111 lines
5.5 KiB
JavaScript
111 lines
5.5 KiB
JavaScript
#!/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)
|