"use client"; import { useEffect, useState, useCallback } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "framer-motion"; import { signIn } from "next-auth/react"; import { X, Phone, KeyRound, Loader2 } from "lucide-react"; import Button from "@/components/ui/Button"; import Logo from "@/components/Logo"; import { cn } from "@/lib/cn"; interface LoginModalProps { open: boolean; onClose: () => void; /** 登录成功回调(默认刷新页面) */ onSuccess?: () => void; } /** * 登录 / 注册弹窗。 * 替代独立 /login 路由,所有"需要登录"的入口统一弹出此组件。 */ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps) { const [phone, setPhone] = useState(""); const [code, setCode] = useState(""); const [countdown, setCountdown] = useState(0); const [error, setError] = useState(null); const [sending, setSending] = useState(false); const [submitting, setSubmitting] = useState(false); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const phoneValid = /^1[3-9]\d{9}$/.test(phone); const codeValid = /^\d{6}$/.test(code); // 打开时重置 useEffect(() => { if (open) { setPhone(""); setCode(""); setError(null); setCountdown(0); setSubmitting(false); setSending(false); } }, [open]); // ESC + body scroll lock useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", handler); document.body.style.overflow = prev; }; }, [open, onClose]); const sendOtp = useCallback(async () => { if (!phoneValid) { setError("请输入有效的中国大陆手机号"); return; } setError(null); setSending(true); try { const res = await fetch("/api/auth/send-otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone }), }); const data = await res.json(); if (!data.ok) throw new Error(data.error?.message || "发送失败"); setCountdown(60); const timer = setInterval(() => { setCountdown((c) => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }); }, 1000); } catch (e) { setError(e instanceof Error ? e.message : "发送失败"); } finally { setSending(false); } }, [phone, phoneValid]); const handleLogin = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!phoneValid || !codeValid) { setError("请检查手机号和验证码"); return; } setError(null); setSubmitting(true); try { const result = await signIn("phone-otp", { phone, code, redirect: false, }); if (result?.error) { console.error("[login] signIn 返回错误:", result); // 把 NextAuth 真实错误透出来,避免被"验证码错误或已失效"一刀切掩盖 // (例如 server config 错误时,会显示 Configuration 而不是误导成验证码问题) setError( result.error === "CredentialsSignin" ? "验证码错误或已失效" : `登录失败:${result.error}`, ); } else { onClose(); if (onSuccess) onSuccess(); else if (typeof window !== "undefined") window.location.reload(); } } catch { setError("登录失败,请重试"); } finally { setSubmitting(false); } }, [phone, code, phoneValid, codeValid, onClose, onSuccess], ); if (!mounted) return null; return createPortal( {open && ( {/* 遮罩 */}

Sign in to Vote

setPhone( e.target.value.replace(/\D/g, "").slice(0, 11), ) } className="w-full h-11 pl-10 pr-3 rounded-lg bg-surface border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-purple-500 focus:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all" />
setCode( e.target.value.replace(/\D/g, "").slice(0, 6), ) } className="w-full h-11 pl-10 pr-3 rounded-lg bg-surface border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-purple-500 focus:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all tracking-widest" />
{error && (
{error}
)} {process.env.NODE_ENV !== "production" && (
开发环境:万能验证码 123456
)}

未注册手机号将自动创建账号

)}
, document.body, ); }