feat(web-admin): add phone+OTP login mode to login page

This commit is contained in:
hailin 2026-03-07 08:18:30 -08:00
parent 7dc5881496
commit 06c2d02c21
1 changed files with 106 additions and 50 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,39 +9,77 @@ import { apiClient } from '@/infrastructure/api/api-client';
interface LoginResponse { interface LoginResponse {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
user: { id: string; email: string; name: string; roles: string[] }; user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId?: string };
} }
type LoginMethod = 'email' | 'phone';
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const [loginMethod, setLoginMethod] = useState<LoginMethod>('email');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [smsCooldown, setSmsCooldown] = useState(0);
const [smsSending, setSmsSending] = useState(false);
const cooldownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleSendSms = async () => {
if (!phone.trim()) { setError('请先填写手机号'); return; }
setError(null);
setSmsSending(true);
try {
await apiClient('/api/v1/auth/sms/send', { method: 'POST', body: { phone: phone.trim(), purpose: 'login' } });
setSmsCooldown(60);
if (cooldownRef.current) clearInterval(cooldownRef.current);
cooldownRef.current = setInterval(() => {
setSmsCooldown((prev) => {
if (prev <= 1) { clearInterval(cooldownRef.current!); return 0; }
return prev - 1;
});
}, 1000);
} catch (err) {
setError(err instanceof Error ? err.message : '发送失败,请重试');
} finally {
setSmsSending(false);
}
};
const saveSession = (data: LoginResponse) => {
localStorage.setItem('access_token', data.accessToken);
localStorage.setItem('refresh_token', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
try {
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
if (payload.tenantId) {
localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId }));
}
} catch { /* ignore */ }
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const data = await apiClient<LoginResponse>('/api/v1/auth/login', { if (loginMethod === 'email') {
method: 'POST', const data = await apiClient<LoginResponse>('/api/v1/auth/login', {
body: { email, password }, method: 'POST',
}); body: { email, password },
});
localStorage.setItem('access_token', data.accessToken); saveSession(data);
localStorage.setItem('refresh_token', data.refreshToken); } else {
localStorage.setItem('user', JSON.stringify(data.user)); if (!smsCode.trim()) { setError('请输入短信验证码'); setIsLoading(false); return; }
const data = await apiClient<LoginResponse>('/api/v1/auth/login/otp', {
try { method: 'POST',
const payload = JSON.parse(atob(data.accessToken.split('.')[1])); body: { phone: phone.trim(), smsCode: smsCode.trim() },
if (payload.tenantId) { });
localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId })); saveSession(data);
} }
} catch { /* ignore decode errors */ }
router.push('/dashboard'); router.push('/dashboard');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t('loginFailed')); setError(err instanceof Error ? err.message : t('loginFailed'));
@ -57,45 +95,63 @@ export default function LoginPage() {
<h1 className="text-3xl font-bold">{t('appTitle')}</h1> <h1 className="text-3xl font-bold">{t('appTitle')}</h1>
<p className="text-muted-foreground mt-2">{t('adminConsole')}</p> <p className="text-muted-foreground mt-2">{t('adminConsole')}</p>
</div> </div>
{/* Login method toggle */}
<div className="flex rounded-md border overflow-hidden">
<button type="button" onClick={() => setLoginMethod('email')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${loginMethod === 'email' ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:text-foreground'}`}>
</button>
<button type="button" onClick={() => setLoginMethod('phone')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${loginMethod === 'phone' ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:text-foreground'}`}>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> {loginMethod === 'email' ? (
<label className="block text-sm font-medium mb-1">{t('email')}</label> <>
<input <div>
type="email" <label className="block text-sm font-medium mb-1">{t('email')}</label>
value={email} <input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 bg-input border rounded-md" placeholder={t('emailPlaceholder')} required />
className="w-full px-3 py-2 bg-input border rounded-md" </div>
placeholder={t('emailPlaceholder')} <div>
required <label className="block text-sm font-medium mb-1">{t('password')}</label>
/> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
</div> className="w-full px-3 py-2 bg-input border rounded-md" required />
<div> </div>
<label className="block text-sm font-medium mb-1">{t('password')}</label> </>
<input ) : (
type="password" <>
value={password} <div>
onChange={(e) => setPassword(e.target.value)} <label className="block text-sm font-medium mb-1"></label>
className="w-full px-3 py-2 bg-input border rounded-md" <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)}
required className="w-full px-3 py-2 bg-input border rounded-md" placeholder="+86 138 0000 0000" required />
/> </div>
</div> <div>
{error && ( <label className="block text-sm font-medium mb-1"></label>
<p className="text-sm text-red-500">{error}</p> <div className="flex gap-2">
<input type="text" value={smsCode} onChange={(e) => setSmsCode(e.target.value)}
className="flex-1 px-3 py-2 bg-input border rounded-md" placeholder="6 位验证码" maxLength={6} required />
<button type="button" onClick={handleSendSms} disabled={smsSending || smsCooldown > 0}
className="px-4 py-2 text-sm font-medium bg-secondary text-secondary-foreground rounded-md hover:opacity-90 disabled:opacity-50 whitespace-nowrap">
{smsSending ? '发送中...' : smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
</div>
</>
)} )}
<button {error && <p className="text-sm text-red-500">{error}</p>}
type="submit" <button type="submit" disabled={isLoading}
disabled={isLoading} className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50">
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
>
{isLoading ? t('signingIn') : t('signIn')} {isLoading ? t('signingIn') : t('signIn')}
</button> </button>
</form> </form>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
{t('noAccount')}{' '} {t('noAccount')}{' '}
<Link href="/register" className="text-primary hover:underline"> <Link href="/register" className="text-primary hover:underline">{t('createOne')}</Link>
{t('createOne')}
</Link>
</p> </p>
</div> </div>
); );