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; 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 || / 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('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(EMPTY_LINKS); const [captchaRandomStr, setCaptchaRandomStr] = useState(null); const [captchaImage, setCaptchaImage] = useState(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(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( '/api/auth/public-config', { cache: 'no-store' }, ); if (active) setLinks(projectPublicLinks(response)); } catch { if (active) setLinks(EMPTY_LINKS); } try { const response = await hostApiFetch( '/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( `/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) => { 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('/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 (
Makelore
Makelore

登录 Makelore 账户

{displayError && (
{displayError}
)} {mode === 'password' ? (
setUsername(event.target.value)} />
{links.forgotPasswordUrl && ( 忘记密码? )}
setPassword(event.target.value)} />
) : (
setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} />
setImageCode(event.target.value)} />
setSmsCode(event.target.value)} />
)}
); } export default Login;