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,158 @@
|
||||
// mock.ts — MockClient:演示数据,供阶段 A 的 UI 验收使用,零网络依赖。
|
||||
import {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
Device,
|
||||
LoginResult,
|
||||
Me,
|
||||
RedeemResult,
|
||||
Session,
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import { setSession, clearSession } from './session';
|
||||
|
||||
const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function makeSession(): Session {
|
||||
return {
|
||||
accessToken: 'mock-access-' + Math.random().toString(36).slice(2),
|
||||
refreshToken: 'mock-refresh-' + Math.random().toString(36).slice(2),
|
||||
accessExpiresAt: Date.now() + 10 * 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
export class MockClient implements ApiClient {
|
||||
// 演示态:PRO 会员,已开启 2FA(故登录走二段式)
|
||||
private totpEnabled = true;
|
||||
private pending: string | null = null;
|
||||
private subUrl = 'https://sub.pangolin.vpn/s/8f3kx92m';
|
||||
private devices: Device[] = [
|
||||
{ id: 'd1', name: 'iPhone 15 Pro', platform: 'iOS 18', lastActive: '刚刚', current: true },
|
||||
{ id: 'd2', name: 'MacBook Air', platform: 'macOS 15', lastActive: '2 小时前', current: false },
|
||||
];
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
await delay();
|
||||
// 演示锁定/失败:用特定凭证触发,便于验收双语文案
|
||||
if (password === 'locked') {
|
||||
throw new ApiError(
|
||||
{ code: 'account_locked', message_zh: '失败次数过多,账户已临时锁定', message_en: 'Too many attempts — account temporarily locked' },
|
||||
30,
|
||||
);
|
||||
}
|
||||
if (password === 'wrong' || !/\S+@\S+\.\S+/.test(email)) {
|
||||
throw new ApiError({ code: 'invalid_credentials', message_zh: '邮箱或密码错误', message_en: 'Wrong email or password' });
|
||||
}
|
||||
if (this.totpEnabled) {
|
||||
this.pending = 'mock-pending-' + Math.random().toString(36).slice(2);
|
||||
return { kind: 'totp_required', pendingToken: this.pending };
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return { kind: 'session', session: s };
|
||||
}
|
||||
|
||||
async loginTotp(_pendingToken: string, code: string): Promise<Session> {
|
||||
await delay();
|
||||
if (code === '000000') {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
this.pending = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
await delay(150);
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async getMe(): Promise<Me> {
|
||||
await delay(260);
|
||||
return {
|
||||
email: 'me@pangolin.vpn',
|
||||
plan: 'pro',
|
||||
expiresAt: '2026-12-31',
|
||||
devicesUsed: 2,
|
||||
devicesMax: 5,
|
||||
quotaTodayMin: null,
|
||||
dataTodayGB: 0.8,
|
||||
weeklyGB: [1.8, 2.4, 1.2, 3.1, 4.0, 5.2, 2.6],
|
||||
totpEnabled: this.totpEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async getSubscription(): Promise<SubscriptionInfo> {
|
||||
await delay(160);
|
||||
return { url: this.subUrl };
|
||||
}
|
||||
|
||||
async resetSubscription(): Promise<SubscriptionInfo> {
|
||||
await delay(500);
|
||||
this.subUrl = 'https://sub.pangolin.vpn/s/' + Math.random().toString(36).slice(2, 10);
|
||||
return { url: this.subUrl };
|
||||
}
|
||||
|
||||
async listDevices(): Promise<Device[]> {
|
||||
await delay(220);
|
||||
return [...this.devices];
|
||||
}
|
||||
|
||||
async removeDevice(id: string): Promise<void> {
|
||||
await delay(360);
|
||||
this.devices = this.devices.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
async redeem(code: string): Promise<RedeemResult> {
|
||||
await delay(520);
|
||||
const c = code.trim().toUpperCase();
|
||||
if (c === 'LOCKED') {
|
||||
throw new ApiError(
|
||||
{ code: 'rate_limited', message_zh: '操作过于频繁,请稍后再试', message_en: 'Too many requests, slow down' },
|
||||
20,
|
||||
);
|
||||
}
|
||||
if (c === 'USED') {
|
||||
throw new ApiError({ code: 'code_used', message_zh: '激活码已被使用', message_en: 'Activation code already used' });
|
||||
}
|
||||
if (c.length < 4 || c === 'INVALID') {
|
||||
throw new ApiError({ code: 'code_invalid', message_zh: '激活码无效', message_en: 'Invalid activation code' });
|
||||
}
|
||||
return { plan: 'pro', expiresAt: '2027-12-31' };
|
||||
}
|
||||
|
||||
async totpSetup(): Promise<TotpSetup> {
|
||||
await delay(260);
|
||||
const secret = 'JBSWY3DPEHPK3PXP';
|
||||
return {
|
||||
secret,
|
||||
otpauthUri: `otpauth://totp/Pangolin:me@pangolin.vpn?secret=${secret}&issuer=Pangolin`,
|
||||
};
|
||||
}
|
||||
|
||||
async totpVerify(code: string): Promise<void> {
|
||||
await delay(300);
|
||||
if (code === '000000' || code.length !== 6) {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
this.totpEnabled = true;
|
||||
}
|
||||
|
||||
async totpDisable(code: string): Promise<void> {
|
||||
await delay(300);
|
||||
if (code === '000000' || code.length !== 6) {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
this.totpEnabled = false;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await delay(120);
|
||||
this.pending = null;
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user