feat(i18n): ③ 原型接单源 — I18N 块由 strings.json 生成,消除重复翻译

原型 design/prototype/screens/{ui-desktop,ui-mobile}.html 的 I18N 数据块不再手写,
改由 strings.json 生成(marker 包裹、生成器唯一写):
- alias.json:protoKey → flutterKey(产品串取单源 zh/en,单源赢);desktop 130 / mobile 143。
- overlay.json:原型专属(开发挡板 + app 没有的串,zh/en);desktop 98 / mobile 104。
- new-strings.json:4 个新产品串(分流规则/自定义/局域网直连/强制),6 语,已并入
  strings.json + app_text.dart 抽象声明 + 重生成 6 份 strings_*.dart。
- gen_proto_i18n.mjs:strings.json+alias+overlay → 两份 HTML 的 I18N 块;--check 只比对
  marker 块(unrelated HTML 编辑不误报)。
- check-proto-i18n.mjs:两份 HTML 所有 data-i18n key 的 zh/en 必须 resolve。
- ci/check-codegen-drift.sh:加原型 i18n 段(--check 漂移 + resolution 验收)。

同带入之前路由原型 WIP 的 i18n 收口:私有服务内置行→局域网/私网直连行、rr.* 接 data-i18n
(rr.lan→routingLanDirect 等)。路由页其余 UI(段选/添加规则页)仍为独立后续。

验收(自复核全绿):生成器跑通、data-i18n 0 unresolved、两生成器重生成零 diff、
flutter analyze lib/l10n 0 issue、flutter test 244 全绿、new-strings 无红线词。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
This commit is contained in:
wangjia
2026-07-27 09:59:18 +08:00
parent f46191ef4d
commit 75d23023e3
16 changed files with 2139 additions and 264 deletions
@@ -0,0 +1,76 @@
#!/usr/bin/env node
// check-proto-i18n.mjs — 原型 i18n 验收闸
//
// 解析 design/prototype/screens/{ui-desktop,ui-mobile}.html 里所有 data-i18n /
// data-i18n-ph 用到的 key,断言每个 key 在生成的 I18N 块里 **zh 与 en 都非空**
// (产品 key 经 alias 来自 strings.json,挡板 key 来自 overlay)。任一 unresolved
// → 非零退出并列出。
//
// 用法: node design/prototype/tools/check-proto-i18n.mjs
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..', '..');
const SCREENS = path.join(ROOT, 'design/prototype/screens');
// 允许 en 为空的装饰性 key(如 latin 品牌副标,en 态刻意留空以隐藏)
const EMPTY_EN_OK = { 'ui-mobile.html': new Set(['brandLatin']) };
// 从 marker 之间 eval 出 I18N 对象
function loadI18N(src) {
const s = src.indexOf('// <<<gen-i18n>>>');
const e = src.indexOf('// <<<end-gen-i18n>>>');
const block = src.slice(s, e);
// 锚定到大写 `I18N` 声明的 `{`(跳过 marker 注释里出现的小写 i18n/{...})
const braceStart = block.indexOf('{', block.indexOf('I18N'));
let depth = 0, i = braceStart, inStr = null;
for (; i < block.length; i++) {
const c = block[i], p = block[i - 1];
if (inStr) { if (c === inStr && p !== '\\') inStr = null; continue; }
if (c === "'" || c === '"' || c === '`') { inStr = c; continue; }
if (c === '{') depth++;
else if (c === '}') { depth--; if (depth === 0) { i++; break; } }
}
const obj = new Function('return (' + block.slice(braceStart, i) + ')')();
// desktop: {k:{zh,en}} → 归一; mobile: {zh:{k},en:{k}} → 归一
if (obj.zh && obj.en && typeof obj.zh === 'object') {
const flat = {};
for (const k of Object.keys(obj.zh)) flat[k] = { zh: obj.zh[k], en: obj.en[k] };
return flat;
}
return obj;
}
// 抽取 HTML 里所有 data-i18n / data-i18n-ph 引用的 key
function usedKeys(src) {
const set = new Set();
for (const m of src.matchAll(/data-i18n(?:-ph)?="([^"]+)"/g)) set.add(m[1]);
return set;
}
let failed = 0;
for (const file of ['ui-desktop.html', 'ui-mobile.html']) {
const src = fs.readFileSync(path.join(SCREENS, file), 'utf8');
const dict = loadI18N(src);
const used = usedKeys(src);
const emptyOk = EMPTY_EN_OK[file] || new Set();
const unresolved = [];
for (const k of [...used].sort()) {
const v = dict[k];
if (!v) { unresolved.push(`${k}: 缺失(不在生成的 I18N)`); continue; }
if (v.zh == null || v.zh === '') unresolved.push(`${k}: zh 为空`);
if ((v.en == null || v.en === '') && !emptyOk.has(k)) unresolved.push(`${k}: en 为空`);
}
if (unresolved.length) {
failed += unresolved.length;
console.error(`${file}: ${unresolved.length} unresolved`);
unresolved.forEach((u) => console.error(` ${u}`));
} else {
console.log(`${file}: ${used.size} data-i18n keys 全部 resolved(zh/en 非空)`);
}
}
if (failed) { console.error(`\n✗ 共 ${failed} 项 unresolved`); process.exit(1); }
console.log('✓ 原型 i18n 验收通过');