feat(web/usercenter): Next.js 用户中心静态导出 + mock/http 双数据层 (tsk_3FIPC8lSnAfJ)
新建 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>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// add-sri.mjs — 构建后为导出 HTML 中的本地 <script>/<link rel=stylesheet> 注入
|
||||
// Subresource Integrity (sha384) + crossorigin。满足 doc/05 §2「用户中心开启 SRI」。
|
||||
// 纯静态导出:Next 不内置 SRI,故在 out/ 上后处理。外链(Google Fonts 等)不注入,由 CSP 约束。
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join, normalize } from 'node:path';
|
||||
|
||||
const OUT = 'out';
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const e of entries) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) files.push(...(await walk(p)));
|
||||
else if (e.name.endsWith('.html')) files.push(p);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const cache = new Map();
|
||||
async function sriFor(localPath) {
|
||||
if (cache.has(localPath)) return cache.get(localPath);
|
||||
try {
|
||||
const buf = await readFile(localPath);
|
||||
const hash = 'sha384-' + createHash('sha384').update(buf).digest('base64');
|
||||
cache.set(localPath, hash);
|
||||
return hash;
|
||||
} catch {
|
||||
cache.set(localPath, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocal(url) {
|
||||
return url && !/^https?:\/\//i.test(url) && !url.startsWith('//') && !url.startsWith('data:');
|
||||
}
|
||||
|
||||
async function resolveAsset(url) {
|
||||
const clean = url.split('?')[0].split('#')[0];
|
||||
const rel = clean.startsWith('/') ? clean.slice(1) : clean;
|
||||
const candidate = normalize(join(OUT, rel));
|
||||
try {
|
||||
await stat(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function processHtml(file) {
|
||||
let html = await readFile(file, 'utf8');
|
||||
let count = 0;
|
||||
|
||||
// <script ... src="...">
|
||||
const scriptRe = /<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*><\/script>/gi;
|
||||
const linkRe = /<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi;
|
||||
|
||||
const replacements = [];
|
||||
let m;
|
||||
while ((m = scriptRe.exec(html))) replacements.push({ tag: m[0], url: m[1] });
|
||||
while ((m = linkRe.exec(html))) {
|
||||
const href = /href=["']([^"']+)["']/i.exec(m[0]);
|
||||
if (href) replacements.push({ tag: m[0], url: href[1] });
|
||||
}
|
||||
|
||||
for (const { tag, url } of replacements) {
|
||||
if (!isLocal(url) || /integrity=/.test(tag)) continue;
|
||||
const asset = await resolveAsset(url);
|
||||
if (!asset) continue;
|
||||
const hash = await sriFor(asset);
|
||||
if (!hash) continue;
|
||||
const newTag = tag.replace(/(\s*\/?>)(\s*(?:<\/script>)?)\s*$/, (mm, close, rest) => ` integrity="${hash}" crossorigin="anonymous"${close}${rest}`);
|
||||
if (newTag !== tag) {
|
||||
html = html.replace(tag, newTag);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(file, html);
|
||||
return count;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let files;
|
||||
try {
|
||||
files = await walk(OUT);
|
||||
} catch {
|
||||
console.error(`[sri] 找不到 ${OUT}/,请先 next build`);
|
||||
process.exit(1);
|
||||
}
|
||||
let total = 0;
|
||||
for (const f of files) total += await processHtml(f);
|
||||
console.log(`[sri] 处理 ${files.length} 个 HTML,注入 integrity ${total} 处`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user