feat(design): 支付渠道 switch 原型 + segswitch 原子 + 图标/语言同源闸
- .segswitch 段控原子入 atoms.css + 登记 index.html - purchase/ui-mobile/ui-desktop 三屏:购买页支付渠道 switch(USDT→crypto 默认 / 人民币→alipay),套餐价随渠道分币种显示,删两步弹层 + nezha - 订单列表/详情集成进移动+桌面主框架 - icons.js 58 图标 sprite 单源 + gen_flutter_icons codegen + check-l1-sync 同源闸(图标同集 + l10n 完整) - pay-v2 开发决策日志 docs/pay-v2-dev-log.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
+53
-136
@@ -1,145 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/check-l1-sync.mjs — L1 设计真相源跨端同源闸(纯 Node 零依赖)
|
||||
//
|
||||
// 治理依据:根 CLAUDE.md「前端设计系统治理(ds-flow)」——tokens/icons 以
|
||||
// design/prototype/ 为单一真源,Web 两端(website/usercenter)只能是同步副本。
|
||||
// Flutter 的 token 同源由 ci/check-codegen-drift.sh 守护(codegen 零 diff),
|
||||
// 本闸补 Web token 值同源 + 三端图标 ⊆ 原型 sprite + Web 硬编码色扫描。
|
||||
//
|
||||
// 本地/CI 直接跑:node tools/check-l1-sync.mjs
|
||||
//
|
||||
// 四道检查(任一违规 exit 1):
|
||||
// ① website token 值同源 web/website/src/styles/tokens.gen.css 每个 token
|
||||
// 值 ≡ design/prototype/tokens.css(:root + dark)。
|
||||
// ② usercenter token 同源 web/usercenter/public/colors_and_type.css 同上。
|
||||
// ③ 图标 ⊆ 原型 sprite usercenter LUCIDE(键+路径)+ Flutter pangolin_icons
|
||||
// _byName(键)必须 ⊆ 原型 icons.js ICONS。
|
||||
// ④ Web 硬编码色扫描 website src / usercenter app|components|lib 禁裸 hex;
|
||||
// 白名单 #fff/#000 + 品牌 logo 固定色;行内 `ds-allow` 豁免;
|
||||
// 排除生成的 token 定义文件。
|
||||
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
// check-l1-sync.mjs —— L1 跨端同源闸(ds-flow)。零依赖。
|
||||
// ① 图标集合:Flutter 图标集 == 原型 sprite 图标集(camel 化后逐名相等)。
|
||||
// 真源 design/prototype/icons.js;镜像 client/lib/widgets/pangolin_icons.dart。
|
||||
// ② l10n 完整性:app_text.dart 声明的每个 getter,6 个语言文件必须全部实现
|
||||
// (谁少 key 就红 —— 防「某语言漏字串」漂移,如支付字串只加 zh/en)。
|
||||
// 任一不通过 → 列出并 exit 1。
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
|
||||
const problems = [];
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
let failed = 0;
|
||||
|
||||
// 解析 CSS 块里的 --var: value;(压掉空白便于比较)
|
||||
function parseVars(cssBlock) {
|
||||
const out = {};
|
||||
for (const [, k, v] of cssBlock.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)[;}]/g)) {
|
||||
out[k] = v.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// 取 tokens.css 的 :root + [data-theme="dark"] 全量 token
|
||||
function protoTokens() {
|
||||
const css = read('design/prototype/tokens.css');
|
||||
const root = css.match(/:root\s*\{([^}]*)\}/s)?.[1] ?? '';
|
||||
const dark = css.match(/\[data-theme="dark"\]\s*\{([^}]*)\}/s)?.[1] ?? '';
|
||||
return { ...parseVars(root), ...parseVars(dark) };
|
||||
/* ① 图标集合 */
|
||||
const spriteKeys = new Set(
|
||||
[...readFileSync(resolve(root, 'design/prototype/icons.js'), 'utf8')
|
||||
.matchAll(/'([a-z0-9-]+)'\s*:\s*'</g)].map((m) => m[1].replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()))
|
||||
);
|
||||
const dartKeys = new Set(
|
||||
[...readFileSync(resolve(root, 'client/lib/widgets/pangolin_icons.dart'), 'utf8')
|
||||
.matchAll(/LucideIcons\.([A-Za-z0-9]+)/g)].map((m) => m[1])
|
||||
);
|
||||
const iconMissing = [...spriteKeys].filter((k) => !dartKeys.has(k)).sort();
|
||||
const iconExtra = [...dartKeys].filter((k) => !spriteKeys.has(k)).sort();
|
||||
if (iconMissing.length === 0 && iconExtra.length === 0) {
|
||||
console.log(`✓ 图标同集 OK —— ${spriteKeys.size} 个 sprite ↔ Flutter 逐名相等`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error('✗ 图标同集 MISMATCH:');
|
||||
if (iconMissing.length) console.error(' sprite 有 / Flutter 缺:', iconMissing.join(', '));
|
||||
if (iconExtra.length) console.error(' Flutter 多 / sprite 无:', iconExtra.join(', '));
|
||||
console.error(' 修复:node design/codegen/gen_flutter_icons.mjs');
|
||||
}
|
||||
|
||||
// ── ①② Web token 值同源 ────────────────────────────────────
|
||||
function checkWebTokenSync(label, webPath) {
|
||||
if (!existsSync(join(ROOT, webPath))) {
|
||||
problems.push(`[${label}] 找不到 ${webPath} —— 先跑 npm run gen:tokens`);
|
||||
return;
|
||||
/* ② Flutter l10n 完整性 */
|
||||
const L10N = resolve(root, 'client/lib/l10n');
|
||||
const BASE = resolve(L10N, 'app_text.dart');
|
||||
const LANGS = ['zh', 'en', 'ja', 'ko', 'ru', 'es'];
|
||||
const getterSet = (src) => new Set([...src.matchAll(/\bString\s+get\s+(\w+)\b/g)].map((m) => m[1]));
|
||||
if (!existsSync(BASE)) {
|
||||
console.log('· l10n 完整性:跳过(无 app_text.dart)');
|
||||
} else {
|
||||
// canonical = 仅抽象 getter(`String get X;` 分号结尾);块体默认 `String get X {…}` 是继承默认,不要求 langs 实现
|
||||
const canonical = new Set([...readFileSync(BASE, 'utf8').matchAll(/\bString\s+get\s+(\w+)\s*;/g)].map((m) => m[1]));
|
||||
const gaps = [];
|
||||
for (const lang of LANGS) {
|
||||
const f = resolve(L10N, `strings_${lang}.dart`);
|
||||
if (!existsSync(f)) { gaps.push(` ${lang}: 文件缺失`); continue; }
|
||||
const miss = [...canonical].filter((k) => !getterSet(readFileSync(f, 'utf8')).has(k)).sort();
|
||||
if (miss.length) gaps.push(` ${lang}: 缺 ${miss.length} 个 → ${miss.slice(0, 12).join(', ')}${miss.length > 12 ? ' …' : ''}`);
|
||||
}
|
||||
const proto = protoTokens();
|
||||
const web = parseVars(read(webPath));
|
||||
for (const [k, v] of Object.entries(proto)) {
|
||||
if (!(k in web)) {
|
||||
problems.push(`[${label}] 缺 token ${k} —— ${webPath} 未同步原型(重跑 gen:tokens)`);
|
||||
} else if (web[k] !== v) {
|
||||
problems.push(`[${label}] ${k} 值漂移:原型=${v} Web=${web[k]} —— ${webPath} 应由 build-tokens 从原型再生,勿手改`);
|
||||
}
|
||||
if (gaps.length === 0) {
|
||||
console.log(`✓ l10n 完整 OK —— ${canonical.size} 个 getter × ${LANGS.length} 语言全实现`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error('✗ l10n 不完整(某语言漏 key):');
|
||||
gaps.forEach((g) => console.error(g));
|
||||
}
|
||||
}
|
||||
|
||||
// ── ③ 图标 ⊆ 原型 sprite ────────────────────────────────────
|
||||
{
|
||||
// 原型 icons.js: var ICONS = { 'name': '<path.../>', ... }
|
||||
const protoJs = read('design/prototype/icons.js');
|
||||
const protoBody = protoJs.match(/var ICONS\s*=\s*\{([\s\S]*?)\n\s*\};/)?.[1] ?? '';
|
||||
const protoIcons = {};
|
||||
for (const [, id, body] of protoBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) protoIcons[id] = body;
|
||||
if (!Object.keys(protoIcons).length) {
|
||||
problems.push('[icons] 解析原型 icons.js ICONS 为空 —— 检查文件结构');
|
||||
}
|
||||
|
||||
// usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... }
|
||||
const ucJs = read('web/usercenter/components/icons.tsx');
|
||||
const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? '';
|
||||
// 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。
|
||||
for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\s*:\s*'([^']*)'/g)) {
|
||||
if (!(id in protoIcons)) {
|
||||
problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`);
|
||||
} else if (protoIcons[id] !== body) {
|
||||
problems.push(`[icons] usercenter 图标「${id}」路径与原型 sprite 不一致 —— 以原型为准`);
|
||||
}
|
||||
}
|
||||
|
||||
// Flutter: static const Map<String,IconData> _byName = { 'name': ..., }
|
||||
const dart = read('client/lib/widgets/pangolin_icons.dart');
|
||||
const dartBody = dart.match(/_byName\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? '';
|
||||
for (const [, id] of dartBody.matchAll(/'([^']+)'\s*:/g)) {
|
||||
if (!(id in protoIcons)) {
|
||||
problems.push(`[icons] Flutter 图标「${id}」不在原型 sprite —— 新图标先登记 design/prototype/icons.js`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ④ Web 硬编码色扫描 ──────────────────────────────────────
|
||||
{
|
||||
// 白名单:纯白/纯黑 + 品牌 logo 固定色(SVG 内联 fill,非业务散色)
|
||||
const ALLOW = new Set(['fff', 'ffffff', '000', '000000', 'b96a3d', 'faf3ed', 'f4efe8', '9e5630', '3d2213']);
|
||||
// 排除:生成的 token 定义文件 + 构建产物
|
||||
const SKIP = new Set([
|
||||
'web/website/src/styles/tokens.gen.css',
|
||||
'web/usercenter/public/colors_and_type.css',
|
||||
]);
|
||||
const SKIP_DIR = new Set(['node_modules', 'dist', 'out', '.next', 'build', '.astro', 'public']);
|
||||
const ROOTS = ['web/website/src', 'web/usercenter/app', 'web/usercenter/components', 'web/usercenter/lib'];
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
if (!existsSync(join(ROOT, dir))) return;
|
||||
for (const name of readdirSync(join(ROOT, dir))) {
|
||||
if (SKIP_DIR.has(name)) continue;
|
||||
const rel = `${dir}/${name}`;
|
||||
const st = statSync(join(ROOT, rel));
|
||||
if (st.isDirectory()) walk(rel);
|
||||
else if (/\.(astro|jsx|tsx|ts|js|css)$/.test(name) && !SKIP.has(rel)) files.push(rel);
|
||||
}
|
||||
};
|
||||
ROOTS.forEach(walk);
|
||||
for (const f of files) {
|
||||
read(f).split('\n').forEach((line, i) => {
|
||||
if (line.includes('ds-allow')) return;
|
||||
for (const [hex] of line.matchAll(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) {
|
||||
const v = hex.slice(1).toLowerCase();
|
||||
if (!ALLOW.has(v)) {
|
||||
problems.push(`[web-hex] ${f}:${i + 1} 硬编码色 ${hex} —— 改用 var(--token),确需保留(如品牌 logo)加行内 ds-allow 注释`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkWebTokenSync('website-token', 'web/website/src/styles/tokens.gen.css');
|
||||
checkWebTokenSync('usercenter-token', 'web/usercenter/public/colors_and_type.css');
|
||||
|
||||
// ── 汇总 ────────────────────────────────────────────────────
|
||||
const line = '='.repeat(60);
|
||||
if (problems.length) {
|
||||
console.error(line);
|
||||
console.error(`✗ L1 跨端同源闸未过(${problems.length} 处):`);
|
||||
for (const p of problems) console.error(' · ' + p);
|
||||
console.error(line);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(line);
|
||||
console.log('✓ 通过:L1 跨端同源(website/usercenter token 值 · 三端图标 ⊆ 原型 sprite · Web 无硬编码色)');
|
||||
console.log(line);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user