Files
pangolin/tools/check-l1-sync.mjs
wangjia f37f1f3b59
ci-pangolin / Lint — shellcheck (pull_request) Successful in 10s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 29s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 32s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 21s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 24s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 21s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 10s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Successful in 10s
Deploy Site / deploy-site (push) Successful in 2m33s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m33s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 19s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 13m16s
feat(web): 修语言下拉漂移(class 化)+ 主页登录态用户中心按钮 + 用户中心返回主页
#1 官网语言下拉漂移:根因=官网 CSP 无 unsafe-inline,Header.jsx 下拉全用内联
style= 被浏览器拦→菜单飘视口右边。修:搬进 website.css 的 .langwrap/.langsel
/.langmenu/.langmenu a(全走 var(--token),right:0 锚按钮下方,min-width 160
加宽),Header.jsx 删净内联 style。

#2 主页登录态右上角「用户中心」按钮:Header.jsx 读同源 localStorage
'pg_uc_refresh',有则 .linklogin 位显「用户中心」(→ /user/)、无则「Log in」。
i18n nav.center 6 语言。

#3 用户中心「返回主页」按钮:UserCenter.tsx 顶栏加 <a href="/">(home 图标),
i18n backHome 6 语言。

顺带堵同源闸漏洞:check-l1-sync ③ 原只匹配带引号图标键('refresh-cw'),漏了
裸标识符键(home),导致新加的 home 未被校验就通过。修解析器匹配裸键;并按
ds-flow 把 home 登记进 design/prototype/icons.js。同源闸复跑绿(裸键现全受检)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:16:13 +08:00

146 lines
7.0 KiB
JavaScript

#!/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';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
const problems = [];
// 解析 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) };
}
// ── ①② Web token 值同源 ────────────────────────────────────
function checkWebTokenSync(label, webPath) {
if (!existsSync(join(ROOT, webPath))) {
problems.push(`[${label}] 找不到 ${webPath} —— 先跑 npm run gen:tokens`);
return;
}
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 从原型再生,勿手改`);
}
}
}
// ── ③ 图标 ⊆ 原型 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);