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>
170 lines
7.1 KiB
TypeScript
170 lines
7.1 KiB
TypeScript
'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 (
|
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
|
|
<div style={{ ...card, width: 400, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 26 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<Mark size={32} />
|
|
<div>
|
|
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>穿山甲</div>
|
|
<div style={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
|
|
</div>
|
|
</div>
|
|
<LangSeg lang={lang} setLang={setLang} />
|
|
</div>
|
|
|
|
{step === 'cred' ? (
|
|
<>
|
|
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 22, color: 'var(--fg1)' }}>{t('loginTitle')}</div>
|
|
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '6px 0 24px' }}>{t('loginSub')}</div>
|
|
<label style={{ display: 'block', marginBottom: 14 }}>
|
|
<span style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginBottom: 6 }}>{t('emailLabel')}</span>
|
|
<input style={input} value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t('emailPh')} />
|
|
</label>
|
|
<label style={{ display: 'block' }}>
|
|
<span style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginBottom: 6 }}>{t('pwLabel')}</span>
|
|
<input
|
|
style={input}
|
|
type="password"
|
|
value={pw}
|
|
onChange={(e) => setPw(e.target.value)}
|
|
placeholder={t('pwPh')}
|
|
onKeyDown={(e) => e.key === 'Enter' && submitCred()}
|
|
/>
|
|
</label>
|
|
<div style={{ textAlign: 'right', margin: '10px 0 18px' }}>
|
|
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, cursor: 'pointer' }}>{t('forgotPw')}</span>
|
|
</div>
|
|
{err && <ErrorLine text={lockLeft > 0 ? `${err} · ${t('lockedCountdown', { s: lockLeft })}` : err} />}
|
|
<button
|
|
onClick={submitCred}
|
|
disabled={credDisabled}
|
|
style={primaryBtn(!credDisabled)}
|
|
>
|
|
{busy ? t('loading') : t('doLogin')}
|
|
</button>
|
|
<div style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', marginTop: 18 }}>{t('noAccount')}</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 6 }}>
|
|
<Icon name="shield-check" size={20} color="var(--accent)" />
|
|
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>{t('twoFATitle')}</span>
|
|
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-subtle)', border: '1px solid var(--accent-border)', padding: '2px 8px', borderRadius: 999 }}>PRO</span>
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--fg3)', margin: '4px 0 20px', lineHeight: 1.6 }}>{t('twoFAHint')}</div>
|
|
<input
|
|
value={otp}
|
|
onChange={(e) => 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 && <div style={{ marginTop: 14 }}><ErrorLine text={err} /></div>}
|
|
<button onClick={submitOtp} disabled={otp.length !== 6 || busy} style={{ ...primaryBtn(otp.length === 6 && !busy), marginTop: 16 }}>
|
|
{busy ? t('loading') : t('twoFAConfirm')}
|
|
</button>
|
|
<div onClick={() => { setStep('cred'); setErr(''); setOtp(''); }} style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', marginTop: 18, cursor: 'pointer' }}>
|
|
{t('twoFABack')}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--danger)', fontSize: 12.5, fontWeight: 600, marginBottom: 12 }}>
|
|
<Icon name="alert-triangle" size={14} color="var(--danger)" />
|
|
<span>{text}</span>
|
|
</div>
|
|
);
|
|
}
|