UI-UX/src/components/auth/LoginModal.tsx
zyc 7168e50a6e
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m54s
fix: prod login + env-file driven config + scroll-snap bounce
- env: 解封 .env / .env.production 提交, 仅忽略 .env.local 系列;
  .env.production 承载 DATABASE_URL / AUTH_SECRET / AUTH_URL /
  SMS_* / NEXT_PUBLIC_TOS_DOMAIN, Dockerfile runner 阶段 COPY 进
  运行时镜像, Next.js standalone 启动自动加载
- ci: 移除 kubectl 注入 secret 步骤(env 已烧入镜像), 保留占位避免
  envFrom optional 引用告警, 修复 /api/auth/providers 500 (缺 AUTH_SECRET)
- auth: signIn 失败透传 NextAuth 真实错误码, 不再被"验证码错误"一刀切掩盖
- home: 首页 scroll-snap-type 由 mandatory 改 proximity, 修复滚动到
  底部被强制吸回候选区顶部的回弹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:31:00 +08:00

295 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string | null>(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(
<AnimatePresence>
{open && (
<motion.div
key="login-modal-root"
className="fixed inset-0 z-[100] flex items-center justify-center px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* 遮罩 */}
<button
type="button"
aria-label="关闭弹窗"
onClick={onClose}
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
/>
{/* 弹窗主体 */}
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
className="relative w-full max-w-md bg-elevated border border-white/14 rounded-2xl p-8 shadow-[0_24px_80px_rgba(0,0,0,0.7),0_0_40px_rgba(139,92,246,0.12)]"
initial={{ opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
<button
type="button"
onClick={onClose}
className="absolute top-3.5 right-4 w-7 h-7 flex items-center justify-center text-white/55 hover:text-white transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
<div className="text-center mb-6">
<div className="inline-block">
<Logo size="md" href={null} />
</div>
<p
id="login-modal-title"
className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3"
>
Sign in to Vote
</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block font-label text-[10px] tracking-widest uppercase text-white/55 mb-2">
</label>
<div className="relative">
<Phone
size={14}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-white/35"
/>
<input
type="tel"
inputMode="numeric"
autoComplete="tel"
placeholder="138 0000 0000"
value={phone}
maxLength={11}
onChange={(e) =>
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"
/>
</div>
</div>
<div>
<label className="block font-label text-[10px] tracking-widest uppercase text-white/55 mb-2">
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<KeyRound
size={14}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-white/35"
/>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位验证码"
value={code}
maxLength={6}
onChange={(e) =>
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"
/>
</div>
<button
type="button"
disabled={!phoneValid || countdown > 0 || sending}
onClick={sendOtp}
className={cn(
"h-11 px-4 rounded-lg font-display text-xs tracking-widest uppercase border transition-all whitespace-nowrap",
countdown > 0
? "bg-white/5 border-white/10 text-white/30"
: "bg-purple-500/10 border-purple-500/40 text-purple-300 hover:bg-purple-500/15 disabled:opacity-50",
)}
>
{sending ? (
<Loader2 size={14} className="animate-spin" />
) : countdown > 0 ? (
`${countdown}s`
) : (
"发送"
)}
</button>
</div>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-pink-500/10 border border-pink-500/30 text-pink-300 text-xs">
{error}
</div>
)}
{process.env.NODE_ENV !== "production" && (
<div className="px-3 py-2 rounded-lg bg-purple-500/[0.08] border border-purple-500/25 text-[11px] text-purple-300/80">
<b className="text-white">123456</b>
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={!phoneValid || !codeValid}
loading={submitting}
>
/
</Button>
<p className="text-[11px] text-white/40 text-center leading-relaxed">
</p>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}