Files
jiu/tools/fidelity.mjs
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

147 lines
7.3 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
/**
* fidelity.mjs — 还原保真闸:Flutter golden vs 原型(真·跟原型比,替掉 golden 自比)
*
* 背景:Flutter golden 是「自己渲染→存基准→再渲染→比基准」,结构上永远发现不了
* 「跟原型长得不一样」。本脚本把每屏的 **原型截图当基准**,与 **Flutter golden** 做
* pixelmatch diff,超「逐屏校准阈值」即红 —— 这才是真正的保真证据。
*
* 跨渲染器(原型 Chromium ↔ Flutter Skia)做不到 0% 像素,故:
* ① 两边统一字体:原型注入 Noto Sans SC 覆盖 --font/--font-mono(见 _inject CSS),
* Flutter 侧 ThemeData.fontFamily 已是 NotoSansSCpubspec 打包)。
* ② 阈值逐屏校准(密表/外壳噪声不同),抓「缺顶栏/缺卡片/错列」类结构性差异;
* 像素级细节靠 ds-compare 人工 montage 目检兜底。
*
* 前置:先生成对应 Flutter golden
* cd client && flutter test --update-goldens test/golden/<x>_golden_test.dart
*
* 用法(仓库根运行):
* node tools/fidelity.mjs # 跑注册表里所有屏 × 所有主题
* node tools/fidelity.mjs inventory # 只跑某屏
* node tools/fidelity.mjs inventory --themes a # 指定主题
* node tools/fidelity.mjs --update # 仅刷新原型基准图(proto/),不判定
*
* 产物:
* client/test/golden/proto/<prefix>_<theme>.png 原型基准(入库,prototype 变更时刷新)
* design/_fidelity/<prefix>_<theme>_diff.png 差异图(gitignore,可重生)
*
* 退出码:任一屏×主题超阈 → 1(可作 pre-commit / CI 闸)。
*/
import { parseArgs } from 'node:util';
import { execFileSync } from 'node:child_process';
import { mkdirSync, existsSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { SCREENS } from './screens.mjs'; // 屏注册表单一真源(与 ds-compare 共用)
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
themes: { type: 'string', default: 'a,b,c' },
update: { type: 'boolean', default: false }, // 仅刷新原型基准,不判定
tolerance: { type: 'string', default: '0.1' },
help: { type: 'boolean', default: false },
},
});
if (values.help) {
console.log(`用法: node tools/fidelity.mjs [screen] [--themes a,b,c] [--update] [--tolerance 0.1]\n已注册屏: ${Object.keys(SCREENS).join(', ')}`);
process.exit(0);
}
const SKILL = process.env.DD_SKILL || join(homedir(), '.claude/skills/design-distill');
const shoot = join(SKILL, 'tools/shoot-prototype.mjs');
const diff = join(SKILL, 'tools/diff.mjs');
for (const t of [shoot, diff]) {
if (!existsSync(t)) { console.error(`[fidelity] 找不到 skill 工具: ${t}(设 DD_SKILL 覆盖)`); process.exit(2); }
}
const protoDir = 'client/test/golden/proto';
const diffDir = 'design/_fidelity';
mkdirSync(protoDir, { recursive: true });
mkdirSync(diffDir, { recursive: true });
// 统一字体注入:sans 覆盖为 NotoSansSC(与 Flutter golden 加载的测试字体一致);
// mono 覆盖为 Menlo(与 AppFonts.mono 一致——golden/真机的等宽都是 Menlo
// 对比环必须同源,否则等宽列多的屏会虚高 diff)。原型全部字体走
// var(--font)/var(--font-mono)(无硬编码,已核查),故只改 :root 变量 +
// html 继承兜底即可,不能用 `*{!important}` blanket(会把 mono 也砸成 Noto)。
// 字体经 shoot 临时 http serverroot=cwd)按 /client/... 路径供给。
const injectCss = join(tmpdir(), 'jiu-fidelity-font.css');
writeFileSync(injectCss, [
`@font-face{font-family:'NotoSansSC';src:url('/client/assets/fonts/NotoSansSC.ttf');font-weight:100 900;font-display:block;}`,
`@font-face{font-family:'JetBrainsMono';src:url('/client/assets/fonts/JetBrainsMono-Regular.ttf');font-weight:400;font-display:block;}`,
`@font-face{font-family:'JetBrainsMono';src:url('/client/assets/fonts/JetBrainsMono-Bold.ttf');font-weight:700;font-display:block;}`,
`:root{--font:'NotoSansSC',sans-serif!important;--font-mono:'JetBrainsMono',monospace!important;}`,
`html,body{font-family:'NotoSansSC',sans-serif!important;}`,
].join('\n'));
const wantThemes = values.themes.split(',').map((s) => s.trim()).filter(Boolean);
const targets = positionals.length ? positionals : Object.keys(SCREENS);
let failed = 0, ran = 0;
for (const name of targets) {
const s = SCREENS[name];
if (!s) { console.error(`[fidelity] 未注册屏: ${name}(有: ${Object.keys(SCREENS).join(', ')}`); failed++; continue; }
for (const theme of wantThemes) {
const proto = join(protoDir, `${s.prefix}_${theme}.png`);
const golden = resolve('client/test/golden/goldens', `${s.prefix}_${theme}.png`);
const out = join(diffDir, `${s.prefix}_${theme}_diff.png`);
// ① 原型基准图(注入统一字体)
execFileSync('node', [shoot, s.html, proto,
'--width', String(s.width), '--height', String(s.height), '--dpr', String(s.dpr),
'--theme', theme, '--lang', 'zh', '--wait-for', s.waitFor,
'--inject-css', injectCss, '--wait', '1200'],
{ stdio: 'inherit' });
if (values.update) { console.log(`[fidelity] 🔄 刷新基准 ${proto}`); continue; }
// ② Flutter golden 必须已生成
if (!existsSync(golden)) {
console.error(`[fidelity] ⚠ 缺 Flutter golden: ${golden} —— 先 flutter test --update-goldens。判定失败。`);
failed++; continue;
}
ran++;
if (s.zones && s.zones.length) {
// 分区域判定:全屏 diff 仅参考(不判定,避免内容区差异淹没局部错位),
// 真正判定逐 zone(顶栏/侧栏/状态栏各自紧阈),抓全屏百分比看不到的局部偏移。
try {
execFileSync('node', [diff, proto, golden, out,
'--threshold', '1', '--tolerance', values.tolerance], { stdio: 'pipe' });
} catch { /* 全屏仅参考 */ }
let zoneFail = 0;
for (const z of s.zones) {
const zout = join(diffDir, `${s.prefix}_${theme}_${z.name}_diff.png`);
const crop = [z.x * s.dpr, z.y * s.dpr, z.w * s.dpr, z.h * s.dpr].join(',');
try {
execFileSync('node', [diff, proto, golden, zout,
'--threshold', String(z.threshold), '--tolerance', values.tolerance,
'--crop', crop], { stdio: 'inherit' });
console.log(`[fidelity] ✅ ${name}·${theme}·${z.name}${(z.threshold * 100).toFixed(1)}%`);
} catch {
console.error(`[fidelity] ❌ ${name}·${theme}·${z.name} 超阈 ${(z.threshold * 100).toFixed(1)}% — 看 ${zout}`);
zoneFail++;
}
}
if (zoneFail) failed++;
} else {
// ③ 整屏 diff,超「该屏校准阈值」即红
try {
execFileSync('node', [diff, proto, golden, out,
'--threshold', String(s.threshold), '--tolerance', values.tolerance],
{ stdio: 'inherit' });
console.log(`[fidelity] ✅ ${name}·${theme} ≤ 阈值 ${(s.threshold * 100).toFixed(1)}%`);
} catch {
console.error(`[fidelity] ❌ ${name}·${theme} 超阈 ${(s.threshold * 100).toFixed(1)}% — 看 ${out}`);
failed++;
}
}
}
}
if (values.update) { console.log(`[fidelity] 基准刷新完成。`); process.exit(0); }
console.log(`\n[fidelity] 跑了 ${ran} 屏×主题,失败 ${failed}`);
process.exit(failed ? 1 : 0);