'use client'; // Login.tsx — 登录(凭证 → 可选 TOTP 二段式)。承袭 ucapp.jsx UCLogin 视觉。 import React, { useEffect, useState } from 'react'; import { Icon, Mark } from './icons'; import { card, input, LangSeg } from './shared'; import { useUI } from '../lib/theme'; import { makeT } from '../lib/i18n'; import { getClient } from '../lib/api/client'; import { ApiError } from '../lib/api/types'; import { bilingual } from '../lib/api/errors'; export default function Login({ onDone }: { onDone: () => void }) { const { lang, setLang } = useUI(); const t = makeT(lang); const api = getClient(); const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); const [step, setStep] = useState<'cred' | 'totp'>('cred'); const [otp, setOtp] = useState(''); const [pending, setPending] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [lockLeft, setLockLeft] = useState(0); const ok = /\S+@\S+\.\S+/.test(email) && pw.length > 0; // 锁定倒计时(限流在服务端,前端只做失败提示与倒计时) useEffect(() => { if (lockLeft <= 0) return; const id = setInterval(() => setLockLeft((s) => Math.max(0, s - 1)), 1000); return () => clearInterval(id); }, [lockLeft]); async function submitCred() { if (!ok || busy || lockLeft > 0) return; setBusy(true); setErr(''); try { const r = await api.login(email.trim(), pw); if (r.kind === 'totp_required') { setPending(r.pendingToken); setStep('totp'); } else { onDone(); } } catch (e) { setErr(bilingual(e, lang)); if (e instanceof ApiError && e.code === 'account_locked' && e.retryAfter) setLockLeft(e.retryAfter); } finally { setBusy(false); } } async function submitOtp() { if (otp.length !== 6 || busy) return; setBusy(true); setErr(''); try { await api.loginTotp(pending, otp); onDone(); } catch (e) { setErr(bilingual(e, lang)); } finally { setBusy(false); } } const credDisabled = !ok || busy || lockLeft > 0; return (
穿山甲
PANGOLIN
{step === 'cred' ? ( <>
{t('loginTitle')}
{t('loginSub')}
{t('forgotPw')}
{err && 0 ? `${err} · ${t('lockedCountdown', { s: lockLeft })}` : err} />}
{t('noAccount')}
) : ( <>
{t('twoFATitle')} PRO
{t('twoFAHint')}
setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))} onKeyDown={(e) => e.key === 'Enter' && submitOtp()} placeholder="······" style={{ ...input, fontFamily: 'var(--font-mono)', fontSize: 22, letterSpacing: '0.5em', textAlign: 'center', padding: '14px' }} /> {err &&
}
{ setStep('cred'); setErr(''); setOtp(''); }} style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', marginTop: 18, cursor: 'pointer' }}> {t('twoFABack')}
)}
); } function primaryBtn(enabled: boolean): React.CSSProperties { return { width: '100%', border: 'none', borderRadius: 'var(--radius-full)', padding: '13px', fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 15, cursor: enabled ? 'pointer' : 'not-allowed', background: 'var(--accent)', color: 'var(--fg-on-accent)', opacity: enabled ? 1 : 0.45, }; } export function ErrorLine({ text }: { text: string }) { return (
{text}
); }