Files
wangjia e2646346a6 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>
2026-06-13 15:23:18 +08:00

90 lines
4.6 KiB
TypeScript

'use client';
// Redeem.tsx — 兑换激活码 + 购买渠道(本站不收款,资金流全走外部)。承袭 UCRedeem。
import React, { useState } from 'react';
import { Icon } from './icons';
import { card, input } from './shared';
import { ErrorLine } from './Login';
import type { TFn, Lang } from '../lib/i18n';
import { getClient } from '../lib/api/client';
import { bilingual } from '../lib/api/errors';
export default function Redeem({ t, lang, onRedeemed }: { t: TFn; lang: Lang; onRedeemed?: () => void }) {
const api = getClient();
const [code, setCode] = useState('');
const [ok, setOk] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const channels: { icon: string; name: string; sub: string; accent?: boolean; mono?: boolean }[] = [
{ icon: 'shopping-bag', name: t('chStore'), sub: t('chStoreSub'), accent: true },
{ icon: 'credit-card', name: 'USDT (TRC20)', sub: t('chUsdtSub') },
{ icon: 'send', name: 'Telegram', sub: '@Pangolin_bot', mono: true },
{ icon: 'message-circle', name: 'LINE', sub: '@pangolinvpn', mono: true },
{ icon: 'mail', name: t('chEmail'), sub: 'buy@pangolin.vpn', mono: true },
];
async function submit() {
if (busy || code.trim().length < 4) return;
setBusy(true);
setErr('');
try {
await api.redeem(code.trim());
setOk(true);
onRedeemed?.();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('navRedeem')}</div>
<div style={{ ...card, padding: '20px 22px' }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemTitle')}</div>
{ok ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 9, color: 'var(--success)', fontWeight: 600, fontSize: 15, padding: '4px 0' }}>
<Icon name="check-circle" size={19} color="var(--success)" />
{t('redeemOk')}
</div>
) : (
<>
<div style={{ display: 'flex', gap: 10 }}>
<input
value={code}
onChange={(e) => { setCode(e.target.value.toUpperCase()); setErr(''); }}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder={t('redeemPh')}
style={{ ...input, fontFamily: 'var(--font-mono)', letterSpacing: '0.08em' }}
/>
<button onClick={submit} disabled={busy} style={{ border: 'none', borderRadius: 'var(--radius-md)', padding: '0 22px', background: 'var(--accent)', color: '#fff', fontWeight: 700, fontSize: 14, cursor: busy ? 'wait' : 'pointer', flexShrink: 0 }}>
{busy ? t('loading') : t('redeemBtn')}
</button>
</div>
{err && <div style={{ marginTop: 12 }}><ErrorLine text={err} /></div>}
</>
)}
</div>
<div>
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 5 }}>{t('buyTitle')}</div>
<div style={{ fontSize: 12.5, color: 'var(--fg3)', lineHeight: 1.5, marginBottom: 13 }}>{t('buySub')}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(240px,1fr))', gap: 11 }}>
{channels.map((c) => (
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left', cursor: 'pointer', background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`, borderRadius: 'var(--radius-lg)', padding: '13px 15px', boxShadow: 'var(--shadow-sm)' }}>
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon name={c.icon} size={18} color={c.accent ? '#fff' : 'var(--accent)'} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--fg1)' }}>{c.name}</div>
<div style={{ fontSize: 11.5, color: 'var(--fg3)', fontFamily: c.mono ? 'var(--font-mono)' : 'var(--font-sans)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.sub}</div>
</div>
<Icon name="external-link" size={15} color="var(--fg3)" />
</button>
))}
</div>
</div>
</div>
);
}