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:
wangjia
2026-06-13 15:23:18 +08:00
parent 30e73b31c2
commit e2646346a6
32 changed files with 7925 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
// client.ts — 数据层入口:构建期环境开关切换 mock / http。
import type { ApiClient } from './types';
import { MockClient } from './mock';
import { HttpClient } from './http';
export * from './types';
let singleton: ApiClient | null = null;
export function apiMode(): 'mock' | 'http' {
return process.env.NEXT_PUBLIC_API_MODE === 'http' ? 'http' : 'mock';
}
/** 单例客户端:mock 模式保留内存状态(订阅重置/设备移除等可连续演示) */
export function getClient(): ApiClient {
if (!singleton) singleton = apiMode() === 'http' ? new HttpClient() : new MockClient();
return singleton;
}
+34
View File
@@ -0,0 +1,34 @@
// errors.ts — 错误码 → 双语文案映射表(阶段 B 落地)
import { ApiError } from './types';
import type { Lang } from '../i18n';
/**
* 后端统一返回 {code, message_zh, message_en};前端优先用后端文案,
* 兜底用本表(后端文案缺失/网络层错误时)。codes 与 #1 openapi 对齐。
*/
export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down' },
account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required' },
totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
code_invalid: { zh: '激活码无效', en: 'Invalid activation code' },
code_used: { zh: '激活码已被使用', en: 'Activation code already used' },
code_expired: { zh: '激活码已过期', en: 'Activation code expired' },
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
device_not_found: { zh: '设备不存在', en: 'Device not found' },
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
};
/** 把任意错误转成当前语言文案(后端文案优先,再查表,最后 unknown) */
export function bilingual(err: unknown, lang: Lang): string {
if (err instanceof ApiError) {
const fromServer = lang === 'zh' ? err.message_zh : err.message_en;
if (fromServer) return fromServer;
const m = ERROR_TEXT[err.code];
if (m) return m[lang];
}
return ERROR_TEXT.unknown[lang];
}
+170
View File
@@ -0,0 +1,170 @@
// http.ts — HttpClient:真实后端薄客户端(阶段 B 联调)。
// 特性:API 域名池(故障转移) + 指数退避重试 + 统一错误体解析 + 静默续期。
import {
ApiClient,
ApiError,
ApiErrorBody,
Device,
LoginResult,
Me,
RedeemResult,
Session,
SubscriptionInfo,
TotpSetup,
} from './types';
import {
accessValid,
clearSession,
getAccessToken,
getRefreshToken,
setSession,
} from './session';
function domains(): string[] {
const raw = process.env.NEXT_PUBLIC_API_DOMAINS || '';
const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
return list.length ? list : ['/api'];
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface ReqOpts {
method?: string;
body?: unknown;
auth?: boolean;
/** 401 时是否尝试静默续期后重放(默认 true,refresh 自身置 false 防递归) */
allowRefresh?: boolean;
refreshToken?: string;
}
export class HttpClient implements ApiClient {
private refreshing: Promise<Session> | null = null;
/** 跨域名池 + 退避的核心请求 */
private async request<T>(path: string, opts: ReqOpts = {}): Promise<T> {
const { method = 'GET', body, auth = true, allowRefresh = true } = opts;
if (auth && !accessValid() && getRefreshToken()) {
await this.ensureFresh();
}
const pool = domains();
let lastErr: unknown;
for (let attempt = 0; attempt < pool.length * 2; attempt++) {
const base = pool[attempt % pool.length];
try {
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const token = getAccessToken();
if (auth && token) headers['Authorization'] = `Bearer ${token}`;
if (opts.refreshToken) headers['X-Refresh-Token'] = opts.refreshToken;
const res = await fetch(base + path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: 'omit',
});
if (res.status === 401 && auth && allowRefresh && getRefreshToken()) {
await this.ensureFresh(true);
// 续期后重放一次(不再允许二次续期)
return this.request<T>(path, { ...opts, allowRefresh: false });
}
if (!res.ok) throw await this.toApiError(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
} catch (err) {
lastErr = err;
// 业务错误(ApiError)不重试;仅网络/5xx 才退避换域名
if (err instanceof ApiError && err.code !== 'network') throw err;
await sleep(Math.min(1000 * 2 ** attempt, 4000));
}
}
throw lastErr instanceof ApiError
? lastErr
: new ApiError({ code: 'network', message_zh: '网络异常,请稍后重试', message_en: 'Network error, please retry' });
}
private async toApiError(res: Response): Promise<ApiError> {
let bodyJson: Partial<ApiErrorBody> = {};
try {
bodyJson = await res.json();
} catch {
/* non-json error */
}
const code = bodyJson.code || (res.status === 401 ? 'unauthorized' : 'unknown');
const retryAfter = Number(res.headers.get('Retry-After')) || undefined;
return new ApiError(
{
code,
message_zh: bodyJson.message_zh || '',
message_en: bodyJson.message_en || '',
},
retryAfter,
);
}
private ensureFresh(force = false): Promise<Session> {
if (!force && accessValid()) return Promise.resolve(null as unknown as Session);
if (this.refreshing) return this.refreshing;
this.refreshing = this.refresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
async login(email: string, password: string): Promise<LoginResult> {
const r = await this.request<
{ kind: 'session'; session: Session } | { kind: 'totp_required'; pending_token: string }
>('/v1/auth/login', { method: 'POST', body: { email, password }, auth: false });
if ('pending_token' in r) return { kind: 'totp_required', pendingToken: r.pending_token };
setSession(r.session);
return r;
}
async loginTotp(pendingToken: string, code: string): Promise<Session> {
const s = await this.request<Session>('/v1/auth/login/totp', {
method: 'POST',
body: { pending_token: pendingToken, code },
auth: false,
});
setSession(s);
return s;
}
async refresh(): Promise<Session> {
const rt = getRefreshToken();
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
const s = await this.request<Session>('/v1/auth/refresh', {
method: 'POST',
auth: false,
allowRefresh: false,
refreshToken: rt,
});
setSession(s);
return s;
}
getMe = () => this.request<Me>('/v1/me');
getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription');
resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' });
listDevices = () => this.request<Device[]>('/v1/me/devices');
removeDevice = (id: string) => this.request<void>(`/v1/me/devices/${encodeURIComponent(id)}`, { method: 'DELETE' });
redeem = (code: string) => this.request<RedeemResult>('/v1/me/redeem', { method: 'POST', body: { code } });
// 契约增补待 #1 合入:POST /v1/me/totp/setup|verify|disable
totpSetup = () => this.request<TotpSetup>('/v1/me/totp/setup', { method: 'POST' });
totpVerify = (code: string) => this.request<void>('/v1/me/totp/verify', { method: 'POST', body: { code } });
totpDisable = (code: string) => this.request<void>('/v1/me/totp/disable', { method: 'POST', body: { code } });
async logout(): Promise<void> {
try {
await this.request<void>('/v1/auth/logout', { method: 'POST' });
} finally {
clearSession();
}
}
}
+158
View File
@@ -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();
}
}
+55
View File
@@ -0,0 +1,55 @@
// session.ts — 会话存储
// access token:仅内存(防 XSS 持久化窃取)refresh tokenlocalStorage(header token 方案)。
// 取舍:doc/05 §2 给「HttpOnly+Secure cookie 或 header token」两选;纯静态导出无服务端
// 设置 cookie 的能力,故取 header token + localStorage,并配合静默续期与登出失效收敛风险。
import type { Session } from './types';
const REFRESH_KEY = 'pg_uc_refresh';
let accessToken: string | null = null;
let accessExpiresAt = 0;
export function getAccessToken(): string | null {
return accessToken;
}
export function accessValid(skewMs = 15_000): boolean {
return !!accessToken && Date.now() < accessExpiresAt - skewMs;
}
export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(REFRESH_KEY);
} catch {
return null;
}
}
export function setSession(s: Session): void {
accessToken = s.accessToken;
accessExpiresAt = s.accessExpiresAt;
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(REFRESH_KEY, s.refreshToken);
} catch {
/* ignore quota / privacy mode */
}
}
}
export function clearSession(): void {
accessToken = null;
accessExpiresAt = 0;
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(REFRESH_KEY);
} catch {
/* ignore */
}
}
}
export function hasRefresh(): boolean {
return !!getRefreshToken();
}
+96
View File
@@ -0,0 +1,96 @@
// types.ts — 数据层契约(mock / http 双实现共用)
export type Plan = 'free' | 'pro';
export interface Session {
/** access token:仅存内存,绝不落盘 */
accessToken: string;
/** refresh tokenlocalStorage(header token 方案,见 README 取舍说明) */
refreshToken: string;
/** access 过期时间戳(ms) */
accessExpiresAt: number;
}
export interface Me {
email: string;
plan: Plan;
/** PRO 到期日 YYYY-MM-DD;免费版为 null */
expiresAt: string | null;
devicesUsed: number;
devicesMax: number;
/** 今日剩余时长(分钟);PRO 为 null 表示不限 */
quotaTodayMin: number | null;
dataTodayGB: number;
/** 近 7 日流量(GB) */
weeklyGB: number[];
totpEnabled: boolean;
}
export interface SubscriptionInfo {
url: string;
}
export interface Device {
id: string;
name: string;
platform: string;
lastActive: string;
current: boolean;
}
export interface RedeemResult {
plan: Plan;
expiresAt: string | null;
}
export interface TotpSetup {
/** base32 密钥 */
secret: string;
/** otpauth:// URI(可渲染二维码) */
otpauthUri: string;
}
/** 统一错误体 —— 与后端约定 {code, message_zh, message_en} */
export interface ApiErrorBody {
code: string;
message_zh: string;
message_en: string;
}
export class ApiError extends Error {
code: string;
message_zh: string;
message_en: string;
/** 锁定类错误:剩余秒数 */
retryAfter?: number;
constructor(body: ApiErrorBody, retryAfter?: number) {
super(body.code);
this.name = 'ApiError';
this.code = body.code;
this.message_zh = body.message_zh;
this.message_en = body.message_en;
this.retryAfter = retryAfter;
}
}
/** 登录第一段结果:成功直接给 session,或要求二段 TOTP */
export type LoginResult =
| { kind: 'session'; session: Session }
| { kind: 'totp_required'; pendingToken: string };
export interface ApiClient {
login(email: string, password: string): Promise<LoginResult>;
/** 登录二段式:提交 TOTP 动态码换取 session */
loginTotp(pendingToken: string, code: string): Promise<Session>;
refresh(): Promise<Session>;
getMe(): Promise<Me>;
getSubscription(): Promise<SubscriptionInfo>;
resetSubscription(): Promise<SubscriptionInfo>;
listDevices(): Promise<Device[]>;
removeDevice(id: string): Promise<void>;
redeem(code: string): Promise<RedeemResult>;
totpSetup(): Promise<TotpSetup>;
totpVerify(code: string): Promise<void>;
totpDisable(code: string): Promise<void>;
logout(): Promise<void>;
}