All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m54s
- 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>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
"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,
|
||
);
|
||
}
|