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,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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// session.ts — 会话存储
|
||||
// access token:仅内存(防 XSS 持久化窃取);refresh token:localStorage(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();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// types.ts — 数据层契约(mock / http 双实现共用)
|
||||
|
||||
export type Plan = 'free' | 'pro';
|
||||
|
||||
export interface Session {
|
||||
/** access token:仅存内存,绝不落盘 */
|
||||
accessToken: string;
|
||||
/** refresh token:localStorage(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>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// i18n.ts — 穿山甲 Web 用户中心 · 双语字串(单显,绝不并排)
|
||||
// 直接承袭 design/ui_kits/usercenter/ucparts.jsx 的 UCSTRINGS,并补充设置/设备/TOTP 键。
|
||||
|
||||
export type Lang = 'zh' | 'en';
|
||||
|
||||
type Entry = { zh: string; en: string };
|
||||
|
||||
export const STRINGS: Record<string, Entry> = {
|
||||
navOverview: { zh: '概览', en: 'Overview' },
|
||||
navSub: { zh: '订阅', en: 'Subscription' },
|
||||
navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy' },
|
||||
navInvite: { zh: '邀请返利', en: 'Referral' },
|
||||
navSettings: { zh: '设置', en: 'Settings' },
|
||||
signOut: { zh: '退出', en: 'Sign out' },
|
||||
|
||||
/* login */
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account' },
|
||||
loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage' },
|
||||
emailLabel: { zh: '邮箱', en: 'Email' },
|
||||
emailPh: { zh: '你的邮箱地址', en: 'your@email.com' },
|
||||
pwLabel: { zh: '密码', en: 'Password' },
|
||||
pwPh: { zh: '输入密码', en: 'Enter password' },
|
||||
doLogin: { zh: '登录', en: 'Log in' },
|
||||
forgotPw: { zh: '忘记密码?', en: 'Forgot password?' },
|
||||
noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app' },
|
||||
loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
|
||||
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
|
||||
|
||||
/* overview */
|
||||
greeting: { zh: '欢迎回来', en: 'Welcome back' },
|
||||
curPlan: { zh: '当前套餐', en: 'Current plan' },
|
||||
freePlan: { zh: '免费版', en: 'Free' },
|
||||
proMember: { zh: 'PRO 会员', en: 'PRO member' },
|
||||
expires: { zh: '有效期至', en: 'Expires' },
|
||||
renew: { zh: '续费 / 升级', en: 'Renew / Upgrade' },
|
||||
quotaToday: { zh: '今日剩余时长', en: 'Time left today' },
|
||||
dataToday: { zh: '今日已用流量', en: 'Data used today' },
|
||||
devices: { zh: '在线设备', en: 'Devices online' },
|
||||
quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day' },
|
||||
usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)' },
|
||||
quickSub: { zh: '快速操作', en: 'Quick actions' },
|
||||
qaSub: { zh: '获取订阅链接', en: 'Get subscription' },
|
||||
qaRedeem: { zh: '兑换激活码', en: 'Redeem a code' },
|
||||
qaApp: { zh: '下载 App', en: 'Download apps' },
|
||||
|
||||
/* subscription */
|
||||
subTitle: { zh: '我的订阅', en: 'My subscription' },
|
||||
subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.' },
|
||||
subCopy: { zh: '复制链接', en: 'Copy link' },
|
||||
subCopied: { zh: '已复制', en: 'Copied' },
|
||||
subReset: { zh: '重置链接', en: 'Reset link' },
|
||||
subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices' },
|
||||
subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices' },
|
||||
scanTitle: { zh: '扫码导入', en: 'Scan to import' },
|
||||
scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client' },
|
||||
importTitle: { zh: '一键导入三方客户端', en: 'One-tap import' },
|
||||
importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.' },
|
||||
fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.' },
|
||||
|
||||
/* redeem */
|
||||
redeemTitle: { zh: '兑换激活码', en: 'Redeem a code' },
|
||||
redeemPh: { zh: '输入激活码', en: 'Enter activation code' },
|
||||
redeemBtn: { zh: '激活', en: 'Redeem' },
|
||||
redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied' },
|
||||
buyTitle: { zh: '购买渠道', en: 'Where to buy' },
|
||||
buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:' },
|
||||
chStore: { zh: '自助发卡商店', en: 'Self-serve store' },
|
||||
chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code' },
|
||||
chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code' },
|
||||
chEmail: { zh: '邮箱客服', en: 'Email support' },
|
||||
|
||||
/* invite */
|
||||
inviteTitle: { zh: '邀请返利', en: 'Referral' },
|
||||
inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.' },
|
||||
inviteLink: { zh: '我的邀请链接', en: 'My invite link' },
|
||||
invited: { zh: '已邀请', en: 'Invited' },
|
||||
paidUsers: { zh: '已付费', en: 'Converted' },
|
||||
earned: { zh: '累计返利', en: 'Credit earned' },
|
||||
inviteRecord: { zh: '邀请记录', en: 'History' },
|
||||
recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet' },
|
||||
recRegistered: { zh: '已注册', en: 'Registered' },
|
||||
recPaid: { zh: '已付费', en: 'Paid' },
|
||||
|
||||
/* 2FA login step */
|
||||
twoFATitle: { zh: '双重认证', en: 'Two-factor auth' },
|
||||
twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.' },
|
||||
twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in' },
|
||||
twoFABack: { zh: '返回上一步', en: 'Back' },
|
||||
twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
|
||||
|
||||
/* settings */
|
||||
settingsTitle: { zh: '设置', en: 'Settings' },
|
||||
prefTitle: { zh: '偏好', en: 'Preferences' },
|
||||
prefLang: { zh: '显示语言', en: 'Language' },
|
||||
prefTheme: { zh: '外观主题', en: 'Theme' },
|
||||
themeLight: { zh: '浅色', en: 'Light' },
|
||||
themeDark: { zh: '深色', en: 'Dark' },
|
||||
|
||||
/* devices */
|
||||
devTitle: { zh: '设备管理', en: 'Devices' },
|
||||
devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.' },
|
||||
devCurrent: { zh: '当前设备', en: 'This device' },
|
||||
devLastActive: { zh: '最近活跃', en: 'Last active' },
|
||||
devRemove: { zh: '移除', en: 'Remove' },
|
||||
devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?' },
|
||||
devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.' },
|
||||
devEmpty: { zh: '暂无其他设备', en: 'No devices yet' },
|
||||
cancel: { zh: '取消', en: 'Cancel' },
|
||||
confirm: { zh: '确认移除', en: 'Remove' },
|
||||
|
||||
/* TOTP management */
|
||||
totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)' },
|
||||
totpOff: { zh: '未开启', en: 'Off' },
|
||||
totpOn: { zh: '已开启', en: 'On' },
|
||||
totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.' },
|
||||
totpEnable: { zh: '开启双重认证', en: 'Enable 2FA' },
|
||||
totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA' },
|
||||
totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually' },
|
||||
totpSecretLabel: { zh: '手动密钥', en: 'Manual key' },
|
||||
totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm' },
|
||||
totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable' },
|
||||
totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled' },
|
||||
totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled' },
|
||||
totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.' },
|
||||
copyKey: { zh: '复制密钥', en: 'Copy key' },
|
||||
|
||||
/* generic errors */
|
||||
errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
loading: { zh: '加载中…', en: 'Loading…' },
|
||||
};
|
||||
|
||||
export type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export function makeT(lang: Lang): TFn {
|
||||
return (key, vars) => {
|
||||
const e = STRINGS[key];
|
||||
let s = e ? e[lang] || key : key;
|
||||
if (vars) for (const k of Object.keys(vars)) s = s.replace(`{${k}}`, String(vars[k]));
|
||||
return s;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
// theme.tsx — 语言 + 明暗主题上下文(localStorage 持久化,SSR 安全)
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { Lang } from './i18n';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface Ctx {
|
||||
lang: Lang;
|
||||
setLang: (l: Lang) => void;
|
||||
theme: Theme;
|
||||
setTheme: (t: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeCtx = createContext<Ctx | null>(null);
|
||||
const LANG_KEY = 'pg_uc_lang';
|
||||
const THEME_KEY = 'pg_uc_theme';
|
||||
|
||||
export function UIProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>('zh');
|
||||
const [theme, setThemeState] = useState<Theme>('light');
|
||||
|
||||
// 挂载后读取持久化(避免 hydration 不一致)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const l = window.localStorage.getItem(LANG_KEY) as Lang | null;
|
||||
const t = window.localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (l === 'zh' || l === 'en') setLangState(l);
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
setThemeState(initial);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh' : 'en');
|
||||
}, [theme, lang]);
|
||||
|
||||
const setLang = useCallback((l: Lang) => {
|
||||
setLangState(l);
|
||||
try {
|
||||
window.localStorage.setItem(LANG_KEY, l);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
setThemeState(t);
|
||||
try {
|
||||
window.localStorage.setItem(THEME_KEY, t);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => setTheme(theme === 'dark' ? 'light' : 'dark'), [theme, setTheme]);
|
||||
|
||||
return <ThemeCtx.Provider value={{ lang, setLang, theme, setTheme, toggleTheme }}>{children}</ThemeCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useUI(): Ctx {
|
||||
const c = useContext(ThemeCtx);
|
||||
if (!c) throw new Error('useUI must be used within UIProvider');
|
||||
return c;
|
||||
}
|
||||
Reference in New Issue
Block a user