Files
pangolin/web/usercenter/components/Settings.tsx
T
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

336 lines
16 KiB
TypeScript

'use client';
// Settings.tsx — 设置:偏好(语言/主题) + 设备管理(列表/移除/二次确认) + TOTP 2FA(绑定/验证/解禁)。
// 设计稿 §5:Web 用户中心「设置(含 2FA/TOTP 开关,用户可自选开启)」。
import React, { useEffect, useState } from 'react';
import { Icon } from './icons';
import { card, input } from './shared';
import { ErrorLine } from './Login';
import { useUI } from '../lib/theme';
import type { TFn, Lang } from '../lib/i18n';
import { getClient } from '../lib/api/client';
import { bilingual } from '../lib/api/errors';
import type { Device, TotpSetup } from '../lib/api/types';
export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TFn; lang: Lang; totpEnabled: boolean; onTotpChange: () => void }) {
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('settingsTitle')}</div>
<Preferences t={t} />
<TotpSection t={t} lang={lang} enabled={totpEnabled} onChange={onTotpChange} />
<Devices t={t} lang={lang} />
</div>
);
}
const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' };
function Preferences({ t }: { t: TFn }) {
const { lang, setLang, theme, setTheme } = useUI();
return (
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={sectionTitle}>{t('prefTitle')}</div>
<Row label={t('prefLang')}>
<Seg
value={lang}
options={[['zh', '中文'], ['en', 'EN']]}
onPick={(v) => setLang(v as Lang)}
/>
</Row>
<Row label={t('prefTheme')}>
<Seg
value={theme}
options={[['light', t('themeLight')], ['dark', t('themeDark')]]}
icons={{ light: 'sun', dark: 'moon' }}
onPick={(v) => setTheme(v as 'light' | 'dark')}
/>
</Row>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg2)' }}>{label}</span>
{children}
</div>
);
}
function Seg({ value, options, onPick, icons }: { value: string; options: [string, string][]; onPick: (v: string) => void; icons?: Record<string, string> }) {
return (
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
{options.map(([v, l]) => (
<button
key={v}
onClick={() => onPick(v)}
aria-pressed={value === v}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', cursor: 'pointer', borderRadius: 999, padding: '6px 14px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700, background: value === v ? 'var(--accent)' : 'transparent', color: value === v ? 'var(--fg-on-accent)' : 'var(--fg3)' }}
>
{icons && <Icon name={icons[v]} size={14} color={value === v ? 'var(--fg-on-accent)' : 'var(--fg3)'} />}
{l}
</button>
))}
</div>
);
}
/* ───────── TOTP 2FA ───────── */
function TotpSection({ t, lang, enabled, onChange }: { t: TFn; lang: Lang; enabled: boolean; onChange: () => void }) {
const api = getClient();
const [mode, setMode] = useState<'idle' | 'setup' | 'disable'>('idle');
const [setup, setSetup] = useState<TotpSetup | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [flash, setFlash] = useState('');
function reset() {
setMode('idle');
setCode('');
setErr('');
setSetup(null);
}
async function startSetup() {
setBusy(true);
setErr('');
try {
const s = await api.totpSetup();
setSetup(s);
setMode('setup');
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
async function verify() {
if (code.length !== 6 || busy) return;
setBusy(true);
setErr('');
try {
await api.totpVerify(code);
reset();
setFlash(t('totpEnabledOk'));
onChange();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
async function disable() {
if (code.length !== 6 || busy) return;
setBusy(true);
setErr('');
try {
await api.totpDisable(code);
reset();
setFlash(t('totpDisabledOk'));
onChange();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
return (
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name="shield-check" size={19} color="var(--accent)" />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={sectionTitle}>{t('totpTitle')}</span>
<span style={{ fontSize: 10.5, fontWeight: 700, padding: '2px 9px', borderRadius: 999, background: enabled ? 'var(--success-subtle)' : 'var(--bg-subtle)', color: enabled ? 'var(--success)' : 'var(--fg3)' }}>
{enabled ? t('totpOn') : t('totpOff')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--fg3)', marginTop: 4, lineHeight: 1.5, maxWidth: 380 }}>{t('totpDesc')}</div>
</div>
</div>
{mode === 'idle' && (
enabled ? (
<button onClick={() => { setMode('disable'); setFlash(''); }} style={ghostBtn}>{t('totpDisableBtn')}</button>
) : (
<button onClick={startSetup} disabled={busy} style={accentBtn}>{busy ? t('loading') : t('totpEnable')}</button>
)
)}
</div>
{flash && (
<div style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--success)', fontSize: 12.5, fontWeight: 600 }}>
<Icon name="check-circle" size={14} color="var(--success)" />{flash}
</div>
)}
{mode === 'setup' && setup && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)' }}>{t('totpStep1')}</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<QrPlaceholder uri={setup.otpauthUri} />
<div style={{ minWidth: 200 }}>
<div style={{ fontSize: 11.5, color: 'var(--fg3)', fontWeight: 600, marginBottom: 6 }}>{t('totpSecretLabel')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: '10px 12px' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 14, letterSpacing: '0.12em', color: 'var(--fg1)', wordBreak: 'break-all' }}>{setup.secret}</span>
<button onClick={() => navigator.clipboard?.writeText(setup.secret).catch(() => {})} title={t('copyKey')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 2, display: 'flex' }}>
<Icon name="copy" size={15} color="var(--accent)" />
</button>
</div>
</div>
</div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginTop: 2 }}>{t('totpStep2')}</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<input
value={code}
onChange={(e) => { setCode(e.target.value.replace(/\D/g, '').slice(0, 6)); setErr(''); }}
onKeyDown={(e) => e.key === 'Enter' && verify()}
placeholder="······"
style={{ ...input, width: 160, fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: '0.4em', textAlign: 'center' }}
/>
<button onClick={verify} disabled={code.length !== 6 || busy} style={{ ...accentBtn, opacity: code.length === 6 && !busy ? 1 : 0.5 }}>{t('totpVerifyBtn')}</button>
<button onClick={reset} style={ghostBtn}>{t('cancel')}</button>
</div>
{err && <ErrorLine text={err} />}
</div>
)}
{mode === 'disable' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<div style={{ fontSize: 12.5, color: 'var(--fg2)' }}>{t('totpDisableHint')}</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<input
value={code}
onChange={(e) => { setCode(e.target.value.replace(/\D/g, '').slice(0, 6)); setErr(''); }}
onKeyDown={(e) => e.key === 'Enter' && disable()}
placeholder="······"
style={{ ...input, width: 160, fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: '0.4em', textAlign: 'center' }}
/>
<button onClick={disable} disabled={code.length !== 6 || busy} style={{ ...dangerBtn, opacity: code.length === 6 && !busy ? 1 : 0.5 }}>{t('totpDisableBtn')}</button>
<button onClick={reset} style={ghostBtn}>{t('cancel')}</button>
</div>
{err && <ErrorLine text={err} />}
</div>
)}
</div>
);
}
// 二维码占位:与订阅页一致的设计语言(dashed 框 + qr 图标)。mock 阶段不引入 QR 依赖。
function QrPlaceholder({ uri }: { uri: string }) {
return (
<div title={uri} style={{ width: 128, height: 128, borderRadius: 'var(--radius-md)', background: 'var(--bg-subtle)', border: '1px dashed var(--border-strong)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, flexShrink: 0 }}>
<Icon name="qr-code" size={40} color="var(--fg3)" stroke={1.5} />
<span style={{ fontSize: 10, color: 'var(--fg3)', fontWeight: 600 }}>otpauth://</span>
</div>
);
}
/* ───────── Devices ───────── */
function Devices({ t, lang }: { t: TFn; lang: Lang }) {
const api = getClient();
const [devices, setDevices] = useState<Device[] | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
async function load() {
try {
setDevices(await api.listDevices());
} catch (e) {
setErr(bilingual(e, lang));
}
}
// 仅挂载时拉取一次设备列表
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { load(); }, []);
async function remove(id: string) {
setBusy(true);
setErr('');
try {
await api.removeDevice(id);
setConfirmId(null);
await load();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
return (
<div style={{ ...card, overflow: 'hidden' }}>
<div style={{ padding: '16px 20px 12px' }}>
<div style={sectionTitle}>{t('devTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--fg3)', marginTop: 4, lineHeight: 1.5 }}>{t('devSub')}</div>
</div>
{err && <div style={{ padding: '0 20px 12px' }}><ErrorLine text={err} /></div>}
{devices === null ? (
<div style={{ padding: '8px 20px 18px', fontSize: 13, color: 'var(--fg3)' }}>{t('loading')}</div>
) : devices.length === 0 ? (
<div style={{ padding: '8px 20px 18px', fontSize: 13, color: 'var(--fg3)' }}>{t('devEmpty')}</div>
) : (
devices.map((d, i) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 20px', borderTop: '1px solid var(--border)' }}>
<div style={{ width: 34, height: 34, borderRadius: 'var(--radius-md)', background: 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon name={/i(Pad|Phone)|Android|iOS/i.test(d.platform) ? 'smartphone' : 'monitor'} size={17} color="var(--accent)" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)' }}>{d.name}</span>
{d.current && <span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: 'var(--accent-subtle)', color: 'var(--accent)' }}>{t('devCurrent')}</span>}
</div>
<div style={{ fontSize: 11.5, color: 'var(--fg3)', marginTop: 2 }}>{d.platform} · {t('devLastActive')} {d.lastActive}</div>
</div>
{!d.current && (
<button onClick={() => setConfirmId(d.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: '1.5px solid var(--border-strong)', background: 'transparent', color: 'var(--danger)', fontWeight: 600, fontSize: 12.5, padding: '7px 13px', borderRadius: 'var(--radius-full)', cursor: 'pointer' }}>
<Icon name="trash-2" size={14} color="var(--danger)" />{t('devRemove')}
</button>
)}
</div>
))
)}
{confirmId && (
<ConfirmModal
t={t}
busy={busy}
onCancel={() => setConfirmId(null)}
onConfirm={() => remove(confirmId)}
/>
)}
</div>
);
}
function ConfirmModal({ t, busy, onCancel, onConfirm }: { t: TFn; busy: boolean; onCancel: () => void; onConfirm: () => void }) {
return (
<div onClick={onCancel} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'var(--overlay)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
<div onClick={(e) => e.stopPropagation()} style={{ ...card, width: 380, maxWidth: '100%', padding: '24px 24px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: 'var(--danger-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name="alert-triangle" size={19} color="var(--danger)" />
</div>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)' }}>{t('devRemoveConfirmTitle')}</span>
</div>
<div style={{ fontSize: 13, color: 'var(--fg3)', lineHeight: 1.6, marginBottom: 18 }}>{t('devRemoveConfirmSub')}</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onCancel} style={ghostBtn}>{t('cancel')}</button>
<button onClick={onConfirm} disabled={busy} style={dangerBtn}>{busy ? t('loading') : t('confirm')}</button>
</div>
</div>
</div>
);
}
const accentBtn: React.CSSProperties = { border: 'none', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'var(--accent)', color: 'var(--fg-on-accent)', fontWeight: 700, fontSize: 13, cursor: 'pointer' };
const ghostBtn: React.CSSProperties = { border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'transparent', color: 'var(--fg2)', fontWeight: 600, fontSize: 13, cursor: 'pointer' };
const dangerBtn: React.CSSProperties = { border: 'none', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'var(--danger)', color: '#fff', fontWeight: 700, fontSize: 13, cursor: 'pointer' };