"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Button, Checkbox, Input, Modal, Segmented } from "antd"; import { useI18n } from "@/i18n"; import { useLoginModalStore } from "@/store/loginModalStore"; import { useUserStore } from "@/store/userStore"; type LoginMode = "code" | "wechat" | "password"; type CodePurpose = "login" | "wechat-bind"; interface ApiResponse { data?: unknown; message?: unknown; status?: unknown; } const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const getMessage = (payload: ApiResponse, fallback: string): string => typeof payload.message === "string" && payload.message.trim() ? payload.message : fallback; const getTokenFromResponse = (payload: ApiResponse): string | null => { if (isRecord(payload.data) && typeof payload.data.token === "string") { return payload.data.token.trim() || null; } return null; }; async function readJsonResponse(response: Response, fallbackMessage: string): Promise { const payload = (await response.json()) as ApiResponse; if (!response.ok || payload.status !== "0000") { throw new Error(getMessage(payload, fallbackMessage)); } return payload; } export function LoginModal() { const { t } = useI18n(); const { isOpen, reason, closeLoginModal } = useLoginModalStore(); const fetchUserInfo = useUserStore((state) => state.fetchUserInfo); const wxPollTimerRef = useRef(null); const [mode, setMode] = useState("code"); const [phone, setPhone] = useState(""); const [password, setPassword] = useState(""); const [code, setCode] = useState(""); const [inviteCode, setInviteCode] = useState(""); const [countdown, setCountdown] = useState(0); const [isLoading, setIsLoading] = useState(false); const [isSendingCode, setIsSendingCode] = useState(false); const [error, setError] = useState(""); const [agreementChecked, setAgreementChecked] = useState(false); const [captchaOpen, setCaptchaOpen] = useState(false); const [captchaId, setCaptchaId] = useState(""); const [captchaImage, setCaptchaImage] = useState(""); const [captchaValue, setCaptchaValue] = useState(""); const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaError, setCaptchaError] = useState(""); const [codeTargetPhone, setCodeTargetPhone] = useState(""); const [codePurpose, setCodePurpose] = useState("login"); const [wxSceneCode, setWxSceneCode] = useState(""); const [wxQrUrl, setWxQrUrl] = useState(""); const [wxQrError, setWxQrError] = useState(""); const [wxQrLoading, setWxQrLoading] = useState(false); const [wxNeedBindPhone, setWxNeedBindPhone] = useState(false); const [wxRegisterToken, setWxRegisterToken] = useState(""); const [wxBindPhone, setWxBindPhone] = useState(""); const [wxBindCode, setWxBindCode] = useState(""); const [wxBindInviteCode, setWxBindInviteCode] = useState(""); const stopWxPoll = useCallback((): void => { if (wxPollTimerRef.current !== null) { window.clearTimeout(wxPollTimerRef.current); wxPollTimerRef.current = null; } }, []); const clearWxState = useCallback((): void => { stopWxPoll(); setWxSceneCode(""); setWxQrUrl(""); setWxQrError(""); setWxQrLoading(false); setWxNeedBindPhone(false); setWxRegisterToken(""); setWxBindPhone(""); setWxBindCode(""); setWxBindInviteCode(""); }, [stopWxPoll]); const completeLoginWithToken = useCallback( async (token: string): Promise => { const userInfo = await fetchUserInfo(token); if (!userInfo) { throw new Error(t("auth.loginUserInfoFailed")); } stopWxPoll(); closeLoginModal(); window.location.reload(); }, [closeLoginModal, fetchUserInfo, stopWxPoll, t] ); useEffect(() => { if (!isOpen) { stopWxPoll(); setCaptchaOpen(false); return; } setError(""); }, [isOpen, stopWxPoll]); useEffect(() => { return () => stopWxPoll(); }, [stopWxPoll]); useEffect(() => { if (countdown <= 0) return; const timer = window.setTimeout(() => setCountdown((value) => value - 1), 1000); return () => window.clearTimeout(timer); }, [countdown]); const loadWxQrCode = useCallback(async (): Promise => { stopWxPoll(); setWxQrError(""); setWxQrLoading(true); setWxSceneCode(""); setWxQrUrl(""); setWxNeedBindPhone(false); setWxRegisterToken(""); try { const payload = await readJsonResponse( await fetch("/api/auth/wxgh-qrcode", { method: "GET", headers: { Accept: "application/json" }, }), t("auth.wechatQrLoadFailed") ); const data = payload.data; if (!isRecord(data)) { throw new Error(t("auth.wechatQrLoadFailed")); } const scene = data.sceneCode ?? data.scene; const qrUrl = data.qrCodeUrl ?? data.qrUrl ?? data.url ?? data.qr_code; if (typeof scene !== "string" || !scene.trim() || typeof qrUrl !== "string" || !qrUrl.trim()) { throw new Error(t("auth.wechatQrLoadFailed")); } setWxSceneCode(scene.trim()); setWxQrUrl(qrUrl.trim()); } catch (nextError) { setWxQrError(nextError instanceof Error ? nextError.message : t("auth.wechatQrLoadFailed")); } finally { setWxQrLoading(false); } }, [stopWxPoll, t]); useEffect(() => { if (!isOpen || mode !== "wechat") { clearWxState(); return; } void loadWxQrCode(); }, [clearWxState, isOpen, loadWxQrCode, mode]); useEffect(() => { if (!isOpen || mode !== "wechat" || !wxSceneCode || wxNeedBindPhone) { stopWxPoll(); return; } let cancelled = false; const tick = async (): Promise => { if (cancelled) return; try { const params = new URLSearchParams({ sceneCode: wxSceneCode }); const payload = await readJsonResponse( await fetch(`/api/auth/check-wxgh-login?${params.toString()}`, { method: "GET", headers: { Accept: "application/json" }, }), t("auth.wechatPollFailed") ); if (cancelled) return; const data = payload.data; if (!isRecord(data)) { wxPollTimerRef.current = window.setTimeout(() => void tick(), 2000); return; } if (data.needBindPhone === true) { const registerToken = typeof data.registerToken === "string" ? data.registerToken.trim() : ""; if (!registerToken) { throw new Error(t("auth.wechatMissingBindToken")); } stopWxPoll(); setWxRegisterToken(registerToken); setWxNeedBindPhone(true); return; } const token = typeof data.token === "string" ? data.token.trim() : ""; if (token) { stopWxPoll(); await completeLoginWithToken(token); return; } wxPollTimerRef.current = window.setTimeout(() => void tick(), 2000); } catch (nextError) { stopWxPoll(); if (!cancelled) { setWxQrError(nextError instanceof Error ? nextError.message : t("auth.wechatPollFailed")); } } }; stopWxPoll(); void tick(); return () => { cancelled = true; stopWxPoll(); }; }, [completeLoginWithToken, isOpen, mode, stopWxPoll, t, wxNeedBindPhone, wxSceneCode]); const loadCaptcha = async (): Promise => { setCaptchaError(""); const response = await fetch("/api/captcha/gen", { method: "GET", headers: { Accept: "application/json" }, }); const payload = await readJsonResponse(response, t("auth.captchaLoadFailed")); if (!isRecord(payload.data)) { throw new Error(t("auth.captchaLoadFailed")); } const nextCaptchaId = payload.data.id; const nextCaptchaImage = payload.data.data; if (typeof nextCaptchaId !== "string" || typeof nextCaptchaImage !== "string") { throw new Error(t("auth.captchaLoadFailed")); } setCaptchaId(nextCaptchaId); setCaptchaImage(nextCaptchaImage); setCaptchaValue(""); }; const handleSendCode = async (targetPhone: string, purpose: CodePurpose): Promise => { if (!targetPhone.trim() || countdown > 0 || captchaLoading || isSendingCode) return; setError(""); setCaptchaError(""); setCodeTargetPhone(targetPhone.trim()); setCodePurpose(purpose); setCaptchaOpen(true); try { await loadCaptcha(); } catch (nextError) { setCaptchaError(nextError instanceof Error ? nextError.message : t("auth.captchaLoadFailed")); } }; const handleCaptchaOk = async (): Promise => { const trimmedCaptchaValue = captchaValue.trim(); if (!captchaId || !trimmedCaptchaValue || captchaLoading || !codeTargetPhone) return; setCaptchaLoading(true); setCaptchaError(""); try { const verifyResponse = await fetch("/api/captcha/verify", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ id: captchaId, value: trimmedCaptchaValue }), }); const verifyPayload = await readJsonResponse(verifyResponse, t("auth.captchaInvalid")); const verifyData = verifyPayload.data; if (isRecord(verifyData) && verifyData.err !== 0) { throw new Error(getMessage(verifyPayload, t("auth.captchaInvalid"))); } setIsSendingCode(true); const params = new URLSearchParams({ phone: codeTargetPhone, usage: "LOGIN", captchaId, captchaValue: trimmedCaptchaValue, }); await readJsonResponse( await fetch(`/api/auth/code?${params.toString()}`, { method: "GET", headers: { Accept: "application/json" }, }), t("auth.captchaSendFailed") ); setCountdown(60); setCaptchaOpen(false); setCaptchaId(""); setCaptchaImage(""); setCaptchaValue(""); if (codePurpose === "wechat-bind") { setWxBindCode(""); } } catch (nextError) { setCaptchaError(nextError instanceof Error ? nextError.message : t("auth.captchaSendFailed")); void loadCaptcha().catch(() => undefined); } finally { setCaptchaLoading(false); setIsSendingCode(false); } }; const handleLogin = async (): Promise => { if (isLoading || mode === "wechat") return; setError(""); setIsLoading(true); try { const endpoint = mode === "password" ? "/api/auth/login" : "/api/auth/login-by-code"; const body = mode === "password" ? { username: phone.trim(), password } : { phone: phone.trim(), code: code.trim(), inviteCode: inviteCode.trim() || undefined }; const payload = await readJsonResponse( await fetch(endpoint, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(body), }), t("auth.loginFailed") ); const token = getTokenFromResponse(payload); if (!token) { throw new Error(t("auth.loginMissingToken")); } await completeLoginWithToken(token); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : t("auth.loginFailed")); } finally { setIsLoading(false); } }; const handleWxBindPhone = async (): Promise => { if (isLoading || !wxRegisterToken || !wxBindPhone.trim() || !wxBindCode.trim()) return; setError(""); setIsLoading(true); try { const payload = await readJsonResponse( await fetch("/api/auth/wxgh-register-by-phone", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ registerToken: wxRegisterToken, phone: wxBindPhone.trim(), code: wxBindCode.trim(), inviteCode: wxBindInviteCode.trim() || undefined, }), }), t("auth.bindPhoneFailed") ); const token = getTokenFromResponse(payload); if (!token) { throw new Error(t("auth.loginMissingToken")); } await completeLoginWithToken(token); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : t("auth.bindPhoneFailed")); } finally { setIsLoading(false); } }; const isSubmitDisabled = isLoading || mode === "wechat" || !phone.trim() || (mode === "password" ? !password : !code.trim()) || !agreementChecked; const isWxBindSubmitDisabled = isLoading || !wxRegisterToken || !wxBindPhone.trim() || !wxBindCode.trim(); return ( <> { stopWxPoll(); closeLoginModal(); }} footer={null} centered width={420} destroyOnHidden mask={{ closable: false }} >
{reason === "unauthorized" ? ( ) : null} {error ? : null} { setMode(value as LoginMode); setError(""); }} options={[ { label: t("auth.codeLogin"), value: "code" }, { label: t("auth.wechatLogin"), value: "wechat" }, { label: t("auth.passwordLogin"), value: "password" }, ]} /> {mode === "wechat" ? ( wxNeedBindPhone ? ( <> setWxBindPhone(event.target.value)} placeholder={t("auth.phonePlaceholder")} autoComplete="tel" />
setWxBindCode(event.target.value)} placeholder={t("auth.smsCodePlaceholder")} autoComplete="one-time-code" onPressEnter={() => { if (!isWxBindSubmitDisabled) void handleWxBindPhone(); }} />
setWxBindInviteCode(event.target.value)} placeholder={t("auth.inviteCodePlaceholder")} /> ) : (
{wxQrError ? : null} {wxQrUrl ? ( {t("auth.wechatScanAlt")} ) : (
{wxQrLoading ? t("auth.loading") : t("auth.wechatNoQr")}
)}
{t("auth.wechatScanTip")}

登录即代表同意 《用户协议》 《隐私政策》

) ) : ( <> setPhone(event.target.value)} placeholder={t("auth.phonePlaceholder")} autoComplete="tel" /> {mode === "password" ? ( setPassword(event.target.value)} placeholder={t("auth.passwordPlaceholder")} autoComplete="current-password" onPressEnter={() => { if (!isSubmitDisabled) void handleLogin(); }} /> ) : ( <>
setCode(event.target.value)} placeholder={t("auth.smsCodePlaceholder")} autoComplete="one-time-code" onPressEnter={() => { if (!isSubmitDisabled) void handleLogin(); }} />
setInviteCode(event.target.value)} placeholder={t("auth.inviteCodePlaceholder")} /> )} setAgreementChecked(event.target.checked)} className="text-xs leading-5 text-neutral-500" > 登录即代表同意 《用户协议》 《隐私政策》 ,未注册手机号将自动注册 )}
setCaptchaOpen(false)} footer={null} centered width={360} destroyOnHidden mask={{ closable: true }} >
{captchaError ? : null}
{captchaImage ? ( {t("auth.captchaAlt")} void loadCaptcha()} /> ) : (
{t("auth.loading")}
)}
setCaptchaValue(event.target.value)} placeholder={t("auth.captchaInputPlaceholder")} onPressEnter={() => void handleCaptchaOk()} />
); }