e2646346a6
新建 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>
336 lines
16 KiB
TypeScript
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' };
|