6238b86dcb
- 登录/注册(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
147 lines
7.3 KiB
JavaScript
147 lines
7.3 KiB
JavaScript
#!/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 已是 NotoSansSC(pubspec 打包)。
|
||
* ② 阈值逐屏校准(密表/外壳噪声不同),抓「缺顶栏/缺卡片/错列」类结构性差异;
|
||
* 像素级细节靠 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 server(root=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);
|