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>;
}
+143
View File
@@ -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;
};
}
+69
View File
@@ -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;
}