465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Loader2, RefreshCw } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { hostApiFetch } from '@/lib/host-api';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import { useProviderStore } from '@/stores/providers';
|
||
import logoSvg from '@/assets/logo.svg';
|
||
|
||
const LOGIN_SERVICE_ERROR_MESSAGE = '登录服务暂时不可用,请稍后重试。';
|
||
const LOGIN_ERROR_MESSAGE = '登录失败,请稍后重试。';
|
||
const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。';
|
||
const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。';
|
||
const PHONE_PATTERN = /^1\d{10}$/;
|
||
|
||
type LoginMode = 'password' | 'mobile';
|
||
|
||
type PublicLinks = {
|
||
termsUrl: string | null;
|
||
privacyUrl: string | null;
|
||
forgotPasswordUrl: string | null;
|
||
};
|
||
|
||
type PublicConfigResponse = {
|
||
success?: unknown;
|
||
links?: unknown;
|
||
};
|
||
|
||
type CaptchaResponse = {
|
||
success?: unknown;
|
||
image?: {
|
||
mimeType?: unknown;
|
||
dataBase64?: unknown;
|
||
};
|
||
};
|
||
|
||
type SmsResponse = {
|
||
success?: unknown;
|
||
error?: unknown;
|
||
};
|
||
|
||
type RememberedPasswordResponse = {
|
||
success?: unknown;
|
||
available?: unknown;
|
||
credentials?: {
|
||
username?: unknown;
|
||
password?: unknown;
|
||
} | null;
|
||
};
|
||
|
||
const EMPTY_LINKS: PublicLinks = {
|
||
termsUrl: null,
|
||
privacyUrl: null,
|
||
forgotPasswordUrl: null,
|
||
};
|
||
|
||
function getSafeExternalUrl(value: unknown): string | null {
|
||
if (typeof value !== 'string' || !value.trim()) return null;
|
||
try {
|
||
const url = new URL(value);
|
||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function projectPublicLinks(response: PublicConfigResponse): PublicLinks {
|
||
if (response.success !== true || !response.links || typeof response.links !== 'object') {
|
||
return EMPTY_LINKS;
|
||
}
|
||
const links = response.links as Record<string, unknown>;
|
||
return {
|
||
termsUrl: getSafeExternalUrl(links.termsUrl),
|
||
privacyUrl: getSafeExternalUrl(links.privacyUrl),
|
||
forgotPasswordUrl: getSafeExternalUrl(links.forgotPasswordUrl),
|
||
};
|
||
}
|
||
|
||
function getLoginErrorMessage(error: string | null): string | null {
|
||
const message = error?.trim();
|
||
if (!message) return null;
|
||
|
||
const normalized = message.toLowerCase();
|
||
if (
|
||
message.length > 180
|
||
|| /<!doctype\b|<html\b|<head\b|<body\b/i.test(message)
|
||
|| /\b50[234]\b/.test(normalized)
|
||
|| normalized.includes('bad gateway')
|
||
|| normalized.includes('service unavailable')
|
||
) {
|
||
return LOGIN_SERVICE_ERROR_MESSAGE;
|
||
}
|
||
|
||
if (normalized.includes('timeout') || normalized.includes('timed out') || message.includes('超时')) {
|
||
return '登录超时,请稍后重试。';
|
||
}
|
||
|
||
return /[\u3400-\u9fff]/.test(message) ? message : LOGIN_ERROR_MESSAGE;
|
||
}
|
||
|
||
export function Login() {
|
||
const navigate = useNavigate();
|
||
const loginWithPassword = useAuthStore((state) => state.loginWithPassword);
|
||
const loginWithMobile = useAuthStore((state) => state.loginWithMobile);
|
||
const loading = useAuthStore((state) => state.loading);
|
||
const error = useAuthStore((state) => state.error);
|
||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||
const logout = useAuthStore((state) => state.logout);
|
||
const importUserModelConfig = useProviderStore((state) => state.importUserModelConfig);
|
||
const [mode, setMode] = useState<LoginMode>('password');
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [rememberPassword, setRememberPassword] = useState(false);
|
||
const [rememberPasswordAvailable, setRememberPasswordAvailable] = useState(false);
|
||
const [phone, setPhone] = useState('');
|
||
const [imageCode, setImageCode] = useState('');
|
||
const [smsCode, setSmsCode] = useState('');
|
||
const [agreed, setAgreed] = useState(false);
|
||
const [links, setLinks] = useState<PublicLinks>(EMPTY_LINKS);
|
||
const [captchaRandomStr, setCaptchaRandomStr] = useState<string | null>(null);
|
||
const [captchaImage, setCaptchaImage] = useState<string | null>(null);
|
||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||
const [sendingCode, setSendingCode] = useState(false);
|
||
const [cooldown, setCooldown] = useState(0);
|
||
const [cooldownActive, setCooldownActive] = useState(false);
|
||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const captchaRequestRef = useRef(0);
|
||
const captchaStartedRef = useRef(false);
|
||
const displayError = getLoginErrorMessage(submitError || error);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
void (async () => {
|
||
try {
|
||
const response = await hostApiFetch<PublicConfigResponse>(
|
||
'/api/auth/public-config',
|
||
{ cache: 'no-store' },
|
||
);
|
||
if (active) setLinks(projectPublicLinks(response));
|
||
} catch {
|
||
if (active) setLinks(EMPTY_LINKS);
|
||
}
|
||
|
||
try {
|
||
const response = await hostApiFetch<RememberedPasswordResponse>(
|
||
'/api/auth/remembered-password',
|
||
{ cache: 'no-store' },
|
||
);
|
||
if (!active) return;
|
||
const available = response.success === true && response.available === true;
|
||
setRememberPasswordAvailable(available);
|
||
if (
|
||
available
|
||
&& response.credentials
|
||
&& typeof response.credentials.username === 'string'
|
||
&& response.credentials.username.trim()
|
||
&& typeof response.credentials.password === 'string'
|
||
&& response.credentials.password
|
||
) {
|
||
setUsername(response.credentials.username.trim());
|
||
setPassword(response.credentials.password);
|
||
setRememberPassword(true);
|
||
}
|
||
} catch {
|
||
if (active) setRememberPasswordAvailable(false);
|
||
}
|
||
})();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, []);
|
||
|
||
const loadCaptcha = useCallback(async () => {
|
||
captchaStartedRef.current = true;
|
||
const requestId = ++captchaRequestRef.current;
|
||
const randomStr = crypto.randomUUID();
|
||
setCaptchaLoading(true);
|
||
setCaptchaRandomStr(null);
|
||
setCaptchaImage(null);
|
||
setImageCode('');
|
||
setSubmitError(null);
|
||
try {
|
||
const response = await hostApiFetch<CaptchaResponse>(
|
||
`/api/auth/mobile-image-code?randomStr=${encodeURIComponent(randomStr)}`,
|
||
{ cache: 'no-store' },
|
||
);
|
||
if (requestId !== captchaRequestRef.current) return;
|
||
const mimeType = response.image?.mimeType;
|
||
const dataBase64 = response.image?.dataBase64;
|
||
if (
|
||
response.success !== true
|
||
|| typeof mimeType !== 'string'
|
||
|| !/^image\/[a-z0-9.+-]+$/i.test(mimeType)
|
||
|| typeof dataBase64 !== 'string'
|
||
|| !dataBase64
|
||
) {
|
||
throw new Error(CAPTCHA_ERROR_MESSAGE);
|
||
}
|
||
setCaptchaRandomStr(randomStr);
|
||
setCaptchaImage(`data:${mimeType};base64,${dataBase64}`);
|
||
} catch {
|
||
if (requestId === captchaRequestRef.current) {
|
||
setSubmitError(CAPTCHA_ERROR_MESSAGE);
|
||
}
|
||
} finally {
|
||
if (requestId === captchaRequestRef.current) setCaptchaLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (mode !== 'mobile' || cooldownActive || captchaImage || captchaLoading || captchaStartedRef.current) return;
|
||
void loadCaptcha();
|
||
}, [captchaImage, captchaLoading, cooldownActive, loadCaptcha, mode]);
|
||
|
||
useEffect(() => {
|
||
if (!cooldownActive) return;
|
||
const startedAt = Date.now();
|
||
const ticker = window.setInterval(() => {
|
||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
|
||
setCooldown(Math.max(0, 60 - elapsedSeconds));
|
||
}, 1_000);
|
||
const expiry = window.setTimeout(() => {
|
||
setCooldownActive(false);
|
||
setCooldown(0);
|
||
void loadCaptcha();
|
||
}, 60_000);
|
||
return () => {
|
||
window.clearInterval(ticker);
|
||
window.clearTimeout(expiry);
|
||
};
|
||
}, [cooldownActive, loadCaptcha]);
|
||
|
||
useEffect(() => () => {
|
||
captchaRequestRef.current += 1;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isAuthenticated() && !submitting) {
|
||
navigate('/module-select', { replace: true });
|
||
}
|
||
}, [isAuthenticated, navigate, submitting]);
|
||
|
||
const finishLogin = async (login: () => Promise<void>) => {
|
||
setSubmitError(null);
|
||
setSubmitting(true);
|
||
try {
|
||
await login();
|
||
const accessToken = useAuthStore.getState().accessToken;
|
||
if (!accessToken) throw new Error('Login did not return an access token');
|
||
try {
|
||
await importUserModelConfig(accessToken);
|
||
} catch (syncError) {
|
||
await logout();
|
||
throw syncError;
|
||
}
|
||
navigate('/module-select', { replace: true });
|
||
} catch (loginError) {
|
||
setSubmitError(loginError instanceof Error ? loginError.message : String(loginError));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handlePasswordSubmit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!agreed || !username.trim() || !password) return;
|
||
void finishLogin(() => loginWithPassword({
|
||
username: username.trim(),
|
||
password,
|
||
rememberPassword,
|
||
}));
|
||
};
|
||
|
||
const handleMobileSubmit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!agreed || !PHONE_PATTERN.test(phone) || !smsCode.trim()) return;
|
||
void finishLogin(() => loginWithMobile({ phone, code: smsCode.trim() }));
|
||
};
|
||
|
||
const handleModeChange = (nextMode: LoginMode) => {
|
||
if (nextMode === mode) return;
|
||
setMode(nextMode);
|
||
if (nextMode === 'mobile' && !cooldownActive) {
|
||
void loadCaptcha();
|
||
}
|
||
};
|
||
|
||
const handleSendCode = async () => {
|
||
if (
|
||
sendingCode
|
||
|| cooldownActive
|
||
|| !PHONE_PATTERN.test(phone)
|
||
|| !captchaRandomStr
|
||
|| !captchaImage
|
||
|| !imageCode.trim()
|
||
) return;
|
||
|
||
const challenge = captchaRandomStr;
|
||
setSendingCode(true);
|
||
setSubmitError(null);
|
||
try {
|
||
const response = await hostApiFetch<SmsResponse>('/api/auth/mobile-code', {
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
body: JSON.stringify({
|
||
phone,
|
||
imageRandomStr: challenge,
|
||
imageCode: imageCode.trim(),
|
||
}),
|
||
});
|
||
if (response.success !== true) {
|
||
throw new Error(SMS_ERROR_MESSAGE);
|
||
}
|
||
captchaRequestRef.current += 1;
|
||
setCaptchaRandomStr(null);
|
||
setCaptchaImage(null);
|
||
setImageCode('');
|
||
setCooldown(60);
|
||
setCooldownActive(true);
|
||
} catch (sendError) {
|
||
const message = sendError instanceof Error ? sendError.message : SMS_ERROR_MESSAGE;
|
||
await loadCaptcha();
|
||
setSubmitError(message);
|
||
} finally {
|
||
setSendingCode(false);
|
||
}
|
||
};
|
||
|
||
const busy = loading || submitting;
|
||
const passwordReady = agreed && Boolean(username.trim()) && Boolean(password) && !busy;
|
||
const mobileReady = agreed && PHONE_PATTERN.test(phone) && Boolean(smsCode.trim()) && !busy;
|
||
const sendReady = PHONE_PATTERN.test(phone)
|
||
&& Boolean(captchaRandomStr && captchaImage && imageCode.trim())
|
||
&& !sendingCode
|
||
&& !cooldownActive;
|
||
|
||
return (
|
||
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
|
||
<Card className="w-full max-w-[420px]">
|
||
<CardHeader className="space-y-5 pb-4">
|
||
<div className="flex items-center gap-3">
|
||
<img src={logoSvg} alt="Makelore" className="h-9 w-12 shrink-0 object-contain" />
|
||
<div className="min-w-0">
|
||
<CardTitle className="text-xl tracking-normal">Makelore</CardTitle>
|
||
<p className="text-sm text-muted-foreground">登录 Makelore 账户</p>
|
||
</div>
|
||
</div>
|
||
<div role="tablist" aria-label="登录方式" className="grid grid-cols-2 rounded-md bg-muted p-1">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mode === 'password'}
|
||
className="rounded px-3 py-2 text-sm aria-selected:bg-background aria-selected:font-medium aria-selected:shadow-sm"
|
||
onClick={() => handleModeChange('password')}
|
||
>
|
||
密码登录
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mode === 'mobile'}
|
||
className="rounded px-3 py-2 text-sm aria-selected:bg-background aria-selected:font-medium aria-selected:shadow-sm"
|
||
onClick={() => handleModeChange('mobile')}
|
||
>
|
||
验证码登录
|
||
</button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{displayError && (
|
||
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||
{displayError}
|
||
</div>
|
||
)}
|
||
|
||
{mode === 'password' ? (
|
||
<form className="space-y-4" onSubmit={handlePasswordSubmit}>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="login-username">用户名</Label>
|
||
<Input id="login-username" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="login-password">密码</Label>
|
||
{links.forgotPasswordUrl && (
|
||
<a className="text-xs text-primary hover:underline" href={links.forgotPasswordUrl} target="_blank" rel="noopener noreferrer">忘记密码?</a>
|
||
)}
|
||
</div>
|
||
<Input id="login-password" type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||
</div>
|
||
<label
|
||
className="flex w-fit items-center gap-2 text-sm text-muted-foreground"
|
||
title={rememberPasswordAvailable ? undefined : '当前环境无法使用系统安全存储'}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={rememberPassword}
|
||
disabled={!rememberPasswordAvailable}
|
||
onChange={(event) => setRememberPassword(event.target.checked)}
|
||
/>
|
||
<span>记住密码</span>
|
||
</label>
|
||
<Button type="submit" className="w-full" disabled={!passwordReady}>
|
||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
登录
|
||
</Button>
|
||
</form>
|
||
) : (
|
||
<form className="space-y-4" onSubmit={handleMobileSubmit}>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="login-phone">手机号</Label>
|
||
<Input id="login-phone" type="tel" inputMode="numeric" autoComplete="tel" maxLength={11} value={phone} onChange={(event) => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="login-image-code">图形验证码</Label>
|
||
<div className="flex gap-2">
|
||
<Input id="login-image-code" autoComplete="off" value={imageCode} onChange={(event) => setImageCode(event.target.value)} />
|
||
<button
|
||
type="button"
|
||
aria-label="刷新图形验证码"
|
||
className="flex h-10 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted"
|
||
disabled={cooldownActive}
|
||
onClick={() => void loadCaptcha()}
|
||
>
|
||
{captchaLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : captchaImage ? <img src={captchaImage} alt="图形验证码" className="h-full w-full object-contain" /> : <RefreshCw className="h-4 w-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="login-sms-code">短信验证码</Label>
|
||
<div className="flex gap-2">
|
||
<Input id="login-sms-code" inputMode="numeric" autoComplete="one-time-code" value={smsCode} onChange={(event) => setSmsCode(event.target.value)} />
|
||
<Button type="button" variant="outline" className="w-28 shrink-0" disabled={!sendReady} onClick={() => void handleSendCode()}>
|
||
{sendingCode ? '发送中…' : cooldownActive ? `${cooldown} 秒` : '获取验证码'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<Button type="submit" className="w-full" disabled={!mobileReady}>
|
||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
登录
|
||
</Button>
|
||
</form>
|
||
)}
|
||
|
||
<label className="flex items-start gap-2 text-xs leading-5 text-muted-foreground">
|
||
<input type="checkbox" className="mt-1" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
|
||
<span>
|
||
我已阅读并同意
|
||
{links.termsUrl ? <a className="mx-1 text-primary hover:underline" href={links.termsUrl} target="_blank" rel="noopener noreferrer">用户协议</a> : '用户协议'}
|
||
和
|
||
{links.privacyUrl ? <a className="ml-1 text-primary hover:underline" href={links.privacyUrl} target="_blank" rel="noopener noreferrer">隐私政策</a> : '隐私政策'}
|
||
</span>
|
||
</label>
|
||
</CardContent>
|
||
</Card>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default Login;
|