e2646346a6
新建 web/usercenter/:Next.js App Router + output:'export' 纯静态导出,
直接复用 design/ui_kits/usercenter React 源码(概览/订阅/兑换/邀请/设置)。
阶段 A(mock,本提交):
- 复刻五大页面,明/暗 × zh/en 四态;colors_and_type.css 原样链入;
顶栏主题切换 + 语言段控,移动端底部 Tab + 左右滑动切换。
- 设置页新增:偏好(语言/主题) + 设备管理(列表/移除/二次确认+刷新) +
TOTP 2FA(绑定二维码占位+密钥/验证/解禁),登录二段式 TOTP。
- 数据层 lib/api:ApiClient 抽象 + MockClient/HttpClient 双实现,构建期
NEXT_PUBLIC_API_MODE 切换;统一错误体 {code,message_zh,message_en} → 双语映射。
- 会话:access token 仅内存,refresh header token + localStorage,静默续期,
登出失效(取舍:静态导出无服务端 cookie 能力,详见 README)。
- 安全:构建期注入 SRI(sha384);_headers 严格 CSP + 安全基线;
红线词扫描(铁律13) CI 红线,零命中。
阶段 B(占位待联调):HttpClient 已写好域名池+退避重试+401 续期;
TOTP/登录二段式端点占位待 #1 契约增补;me/redeem/devices 切真实链路依赖 #2/#3/#4。
验证:npm run lint / redline / build 均通过,静态产物 out/ 无服务端依赖。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
64 lines
2.2 KiB
JavaScript
64 lines
2.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// redline-scan.mjs — 铁律 13 红线词扫描(CI 红线)。
|
|
// 扫描 UI 源面(app/ components/ lib/)中的红线词;命中即非零退出。
|
|
// 说明:「VPN」仅可出现在技术文档/代码注释,绝不进 UI 文案;域名占位 pangolin.vpn 为小写
|
|
// `.vpn`,与红线词大写 `VPN` 不冲突,故按大小写敏感匹配大写 VPN 以避开域名误报。
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import { join, extname } from 'node:path';
|
|
|
|
const ROOTS = ['app', 'components', 'lib'];
|
|
const EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.css']);
|
|
|
|
// 红线词(doc CLAUDE §1 铁律 13)。中文短语 + 大写英文术语。
|
|
const REDLINES = [
|
|
{ re: /VPN/g, label: 'VPN' },
|
|
{ re: /翻墙/g, label: '翻墙' },
|
|
{ re: /科学上网/g, label: '科学上网' },
|
|
{ re: /突破封锁/g, label: '突破封锁' },
|
|
{ re: /自由穿越/g, label: '自由穿越' },
|
|
{ re: /梯子/g, label: '梯子' },
|
|
{ re: /机场/g, label: '机场' },
|
|
{ re: /Go anywhere/gi, label: 'Go anywhere' },
|
|
];
|
|
|
|
async function walk(dir) {
|
|
let out = [];
|
|
let entries;
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return out;
|
|
}
|
|
for (const e of entries) {
|
|
if (e.name === 'node_modules' || e.name === 'out' || e.name === '.next') continue;
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) out = out.concat(await walk(p));
|
|
else if (EXT.has(extname(e.name))) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function main() {
|
|
let files = [];
|
|
for (const r of ROOTS) files = files.concat(await walk(r));
|
|
const hits = [];
|
|
for (const f of files) {
|
|
const text = await readFile(f, 'utf8');
|
|
const lines = text.split('\n');
|
|
lines.forEach((line, i) => {
|
|
for (const rl of REDLINES) {
|
|
rl.re.lastIndex = 0;
|
|
if (rl.re.test(line)) hits.push({ f, n: i + 1, word: rl.label, line: line.trim().slice(0, 100) });
|
|
}
|
|
});
|
|
}
|
|
if (hits.length) {
|
|
console.error(`✗ 红线词扫描命中 ${hits.length} 处:`);
|
|
for (const h of hits) console.error(` ${h.f}:${h.n} [${h.word}] ${h.line}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✓ 红线词扫描通过(扫描 ${files.length} 个文件,零命中)`);
|
|
}
|
|
|
|
main();
|