'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 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 (
{t('settingsTitle')}
); } const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' }; /* ───────── 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(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 (
{t('totpTitle')} {enabled ? t('totpOn') : t('totpOff')}
{t('totpDesc')}
{mode === 'idle' && ( enabled ? ( ) : ( ) )}
{flash && (
{flash}
)} {mode === 'setup' && setup && (
{t('totpStep1')}
{t('totpSecretLabel')}
{setup.secret}
{t('totpStep2')}
{ 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' }} />
{err && }
)} {mode === 'disable' && (
{t('totpDisableHint')}
{ 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' }} />
{err && }
)}
); } // 二维码占位:与订阅页一致的设计语言(dashed 框 + qr 图标)。mock 阶段不引入 QR 依赖。 function QrPlaceholder({ uri }: { uri: string }) { return (
otpauth://
); } /* ───────── Devices ───────── */ function Devices({ t, lang }: { t: TFn; lang: Lang }) { const api = getClient(); const [devices, setDevices] = useState(null); const [confirmId, setConfirmId] = useState(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 (
{t('devTitle')}
{t('devSub')}
{err &&
} {devices === null ? (
{t('loading')}
) : devices.length === 0 ? (
{t('devEmpty')}
) : ( devices.map((d, i) => (
{d.name} {d.current && {t('devCurrent')}}
{d.platform} · {t('devLastActive')} {d.lastActive}
{!d.current && ( )}
)) )} {confirmId && ( setConfirmId(null)} onConfirm={() => remove(confirmId)} /> )}
); } function ConfirmModal({ t, busy, onCancel, onConfirm }: { t: TFn; busy: boolean; onCancel: () => void; onConfirm: () => void }) { return (
e.stopPropagation()} style={{ ...card, width: 380, maxWidth: '100%', padding: '24px 24px 20px' }}>
{t('devRemoveConfirmTitle')}
{t('devRemoveConfirmSub')}
); } 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' };