"use client"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, type AnimationControls, motion, useAnimationControls, useReducedMotion, } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { EASE_OUT, SPRING_PRESS } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface LoginCardProps { className?: string; /** 头部两行标题,默认 ["登录后", "有问题,免费聊"] */ titleLines?: string[]; /** 区号,默认 "+86" */ countryCode?: string; /** 区号可选项,默认 ["+86","+852","+886","+1","+44","+81"] */ countryOptions?: string[]; /** 覆盖二维码槽位;不传则渲染内置占位二维码 */ qr?: React.ReactNode; /** 内置占位二维码的种子,默认 20260719 */ qrSeed?: number; /** 卡片下方的备案小字;传 null 隐藏;不传用默认 ICP 文案 */ footer?: React.ReactNode; /** 点「登录」回调,可 async;期间按钮显示加载态 */ onSubmit?: (data: { phone: string; code: string; countryCode: string; }) => void | Promise; /** 点「发送」回调,可 async */ onSendCode?: (phone: string) => void | Promise; } const DEFAULT_TITLE_LINES = ["登录后", "有问题,免费聊"]; const DEFAULT_COUNTRY_OPTIONS = ["+86", "+852", "+886", "+1", "+44", "+81"]; function shake(controls: AnimationControls, reduce: boolean) { if (reduce) return; controls.start({ x: [0, -6, 6, -4, 4, 0], transition: { duration: 0.4 }, }); } function PlaceholderQr({ seed = 20260719, size = 25, }: { seed?: number; size?: number; }) { const matrix = useMemo(() => { let a = seed >>> 0; const rand = () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const m: boolean[][] = Array.from({ length: size }, () => Array.from({ length: size }, () => rand() > 0.52), ); const finder = (r0: number, c0: number) => { // 先清出 9x9 的静默区 for (let r = r0 - 1; r <= r0 + 7; r++) for (let c = c0 - 1; c <= c0 + 7; c++) if (r >= 0 && c >= 0 && r < size && c < size) m[r][c] = false; // 画 7x7 回字定位块 for (let r = 0; r < 7; r++) for (let c = 0; c < 7; c++) { const ring = r === 0 || r === 6 || c === 0 || c === 6; const core = r >= 2 && r <= 4 && c >= 2 && c <= 4; m[r0 + r][c0 + c] = ring || core; } }; finder(0, 0); finder(0, size - 7); finder(size - 7, 0); return m; }, [seed, size]); return ( {matrix.map((row, r) => row.map((on, c) => on ? ( ) : null, ), )} ); } export function LoginCard({ className, titleLines = DEFAULT_TITLE_LINES, countryCode = "+86", countryOptions = DEFAULT_COUNTRY_OPTIONS, qr, qrSeed = 20260719, footer, onSubmit, onSendCode, }: LoginCardProps) { const reduce = useReducedMotion(); const rootRef = useRef(null); const countdownTimerRef = useRef | null>( null, ); const [phone, setPhone] = useState(""); const [code, setCode] = useState(""); const [agreed, setAgreed] = useState(false); const [countdown, setCountdown] = useState(0); const [submitting, setSubmitting] = useState(false); const [cc, setCc] = useState(countryCode); const [ccOpen, setCcOpen] = useState(false); const phoneShake = useAnimationControls(); const agreeShake = useAnimationControls(); useEffect( () => () => { if (countdownTimerRef.current !== null) { clearInterval(countdownTimerRef.current); } }, [], ); useEffect(() => { if (!ccOpen) return; const onPointerDown = (e: PointerEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { setCcOpen(false); } }; window.addEventListener("pointerdown", onPointerDown); return () => window.removeEventListener("pointerdown", onPointerDown); }, [ccOpen]); const handleSend = async () => { if (countdown > 0) return; if (phone.trim().length === 0) { shake(phoneShake, !!reduce); return; } await onSendCode?.(phone); setCountdown(60); countdownTimerRef.current = setInterval(() => { setCountdown((c) => { if (c <= 1) { if (countdownTimerRef.current !== null) { clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; } return 0; } return c - 1; }); }, 1000); }; const handleSubmit = async () => { if (!agreed) { shake(agreeShake, !!reduce); return; } setSubmitting(true); try { await onSubmit?.({ phone, code, countryCode: cc }); } finally { setSubmitting(false); } }; const dropdownInitial = reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(2px)" }; const dropdownAnimate = reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }; const dropdownExit = dropdownInitial; return (
{/* (a) 头部渐变条 */}
{/* 右上角大面积柔光,营造高光区 */}
{/* 柔和玻璃光球:左上更亮、向外渐淡,缓慢漂浮 */}
{/* 极淡弧线 */} {/* 点缀亮点 */}

{titleLines.map((line) => ( {line} ))}

{/* (b) 主体两栏 */}
微信扫码登录
{qr ?? }
手机号快捷登录
{ccOpen ? ( {countryOptions.map((o) => (
  • ))}
    ) : null}
    setPhone(e.target.value)} inputMode="tel" placeholder="手机号" className="min-w-0 flex-1 bg-transparent text-[15px] text-[#374151] outline-none placeholder:text-[#9ca3af]" />
    setCode(e.target.value)} inputMode="numeric" placeholder="验证码" className="min-w-0 flex-1 bg-transparent text-[15px] text-[#374151] outline-none placeholder:text-[#9ca3af]" />
    {submitting ? "登录中…" : "登录"}
    {/* (c) 协议 + 反馈行 */}
    已阅读同意{" "} {/* biome-ignore lint/a11y/useValidAnchor: placeholder legal links, real product wires the actual URLs */} 《模型服务协议》 {" "} 和{" "} {/* biome-ignore lint/a11y/useValidAnchor: placeholder legal links, real product wires the actual URLs */} 《用户隐私协议》
    遇到问题?{" "} 去反馈
    {/* (d) footer(卡片外) */} {footer !== null ? (
    {footer ?? (
    © 2026 你的公司名称 ICP 备案号 000000 公安备案号 000000
    )}
    ) : null}
    ); }