ALIEZ hai 1 mes
pai
achega
8b87cbaeb1

+ 1 - 1
messages/zh.json

@@ -169,7 +169,7 @@
     "registerBtn": "注册",
     "resetBtn": "发送重置邮件",
     "changeBtn": "保存新密码",
-    "toRegister": "没有账号?去注册",
+    "toRegister": "还没有账户?去注册",
     "toLogin": "已有账号?去登录",
     "profileHint": "注册后可完善姓名与密码等信息。",
     "errorInvalidEmail": "请输入有效邮箱",

+ 3 - 96
src/app/[locale]/auth/forgot-password/page.tsx

@@ -1,100 +1,7 @@
 "use client";
 
-import { useTranslations } from "next-intl";
-import { Link } from "@/i18n/navigation";
-import { useState } from "react";
-import { ApiError } from "@/lib/api";
-import { sendForgotPasswordEmail } from "@/lib/auth-api";
-import { KeyRound, CheckCircle2 } from "lucide-react";
-
-const InputCls = "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3.5 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458] transition-all";
+import { ForgotPasswordFormPanel } from "@/components/auth/forgot-password-form-panel";
 
 export default function ForgotPasswordPage() {
-  const t = useTranslations("auth");
-  const [email, setEmail] = useState("");
-  const [done, setDone] = useState(false);
-  const [submitting, setSubmitting] = useState(false);
-  const [err, setErr] = useState<string | null>(null);
-
-  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
-    e.preventDefault();
-    if (submitting) return;
-    setErr(null);
-    try {
-      setSubmitting(true);
-      await sendForgotPasswordEmail(email.trim());
-      setDone(true);
-    } catch (error) {
-      if (error instanceof ApiError && error.message.trim()) {
-        setErr(error.message);
-      } else {
-        setErr(t("errorApi"));
-      }
-    } finally {
-      setSubmitting(false);
-    }
-  }
-
-  return (
-    <div className="min-h-screen bg-[#050b14] flex flex-col items-center justify-center p-4 relative font-sans overflow-hidden">
-      {/* 环境光晕 */}
-      <div className="pointer-events-none fixed inset-0 z-0">
-        <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
-      </div>
-
-      <div className="relative z-10 w-full max-w-md">
-        <div className="mb-8 flex justify-center">
-          <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-lg shadow-[#b89458]/20">
-            <KeyRound size={28} />
-          </div>
-        </div>
-
-        <div className="rounded-[2.5rem] border border-white/10 bg-[#0a1120]/80 p-8 md:p-10 backdrop-blur-2xl shadow-2xl">
-          <div className="text-center mb-8">
-            <h1 className="font-serif text-2xl font-bold text-white">{t("forgotTitle")}</h1>
-            <p className="mt-2 text-sm text-slate-400">{t("forgotHint")}</p>
-          </div>
-
-          {!done ? (
-            <form onSubmit={onSubmit} className="space-y-5">
-              <div>
-                <label className="text-sm font-bold text-slate-300 ml-1">{t("email")}</label>
-                <input
-                  type="email"
-                  required
-                  value={email}
-                  onChange={(e) => setEmail(e.target.value)}
-                  className={InputCls}
-                  placeholder="admin@example.com"
-                />
-              </div>
-
-              {err && <div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-400">{err}</div>}
-
-              <button
-                type="submit"
-                disabled={submitting}
-                className="w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 text-sm font-bold text-[#5c461a] shadow-xl shadow-[#b89458]/10 transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100 mt-6"
-              >
-                {submitting ? "发送中..." : t("resetBtn")}
-              </button>
-            </form>
-          ) : (
-            <div className="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-6 text-center">
-              <CheckCircle2 size={40} className="mx-auto mb-4 text-emerald-400" />
-              <p className="text-base font-bold text-emerald-400">邮件已发送</p>
-              <p className="mt-2 text-sm text-emerald-500/80">请前往您的邮箱查收密码重置链接。</p>
-            </div>
-          )}
-
-          <p className="mt-8 text-center text-sm text-slate-400">
-            想起密码了?{" "}
-            <Link href="/auth/login" className="font-bold text-white hover:text-[#f3deae] transition-colors">
-              {t("toLogin")}
-            </Link>
-          </p>
-        </div>
-      </div>
-    </div>
-  );
-}
+  return <ForgotPasswordFormPanel layout="fullscreen" />;
+}

+ 12 - 134
src/app/[locale]/auth/login/page.tsx

@@ -1,38 +1,22 @@
 "use client";
 
-import { useTranslations } from "next-intl";
 import { useRouter } from "@/i18n/navigation";
-import { Link } from "@/i18n/navigation";
 import { useSearchParams } from "next/navigation";
 import { Suspense, useEffect, useState } from "react";
-import { useAuth } from "@/providers/auth-provider";
-import { Eye, EyeOff, LogIn, CheckCircle2 } from "lucide-react";
+import {
+  LoginFormPanel,
+  safePostLoginPath,
+} from "@/components/auth/login-form-panel";
 
 const REGISTER_BANNER_MS = 3000;
 
-function safePostLoginPath(raw: string | null): string | null {
-  if (!raw) return null;
-  if (!raw.startsWith("/") || raw.startsWith("//")) return null;
-  if (raw.includes("\\")) return null;
-  return raw;
-}
-
-const InputCls = "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3.5 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458] transition-all";
-
 function LoginForm() {
-  const t = useTranslations("auth");
   const router = useRouter();
   const searchParams = useSearchParams();
-  const { login } = useAuth();
   const fromRegister = searchParams.get("registered") === "1";
   const [registerBannerDismissed, setRegisterBannerDismissed] = useState(false);
   const showRegisterBanner = fromRegister && !registerBannerDismissed;
-  const [email, setEmail] = useState("");
-  const [password, setPassword] = useState("");
-  const [showPassword, setShowPassword] = useState(false);
-  const [err, setErr] = useState<string | null>(null);
-  const [submitting, setSubmitting] = useState(false);
-  const canSubmit = email.trim().length > 0 && password.length > 0 && !submitting;
+  const next = safePostLoginPath(searchParams.get("next"));
 
   useEffect(() => {
     if (!fromRegister) return;
@@ -43,118 +27,12 @@ function LoginForm() {
     return () => window.clearTimeout(timer);
   }, [fromRegister, router]);
 
-  const onSubmit = async (e: React.FormEvent) => {
-    e.preventDefault();
-    if (!canSubmit) return;
-    setErr(null);
-    setSubmitting(true);
-    const r = await login(email.trim(), password);
-    if (!r.ok) {
-      if (r.message) setErr(r.message);
-      else if (r.error === "invalid_email") setErr(t("errorInvalidEmail"));
-      else setErr(t("errorCredentials"));
-      setSubmitting(false);
-      return;
-    }
-    const next = safePostLoginPath(searchParams.get("next"));
-    router.push(next ?? "/account");
-  };
-
   return (
-    <div className="min-h-screen bg-[#050b14] flex flex-col items-center justify-center p-4 relative font-sans overflow-hidden">
-      {/* 沉浸式环境光晕 */}
-      <div className="pointer-events-none fixed inset-0 z-0">
-        <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
-      </div>
-
-      <div className="relative z-10 w-full max-w-md">
-        <div className="mb-8 flex justify-center">
-          <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-lg shadow-[#b89458]/20">
-            <LogIn size={28} className="translate-x-[2px]" />
-          </div>
-        </div>
-
-        <div className="rounded-[2.5rem] border border-white/10 bg-[#0a1120]/80 p-8 md:p-10 backdrop-blur-2xl shadow-2xl">
-          <div className="text-center mb-8">
-            <h1 className="font-serif text-2xl font-bold text-white">{t("loginTitle")}</h1>
-            <p className="mt-2 text-sm text-slate-400">登录您的交易账户以继续学习</p>
-          </div>
-
-          {showRegisterBanner && (
-            <div className="mb-6 flex items-center justify-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm font-medium text-emerald-400" role="status">
-              <CheckCircle2 size={16} /> {t("loginAfterRegister")}
-            </div>
-          )}
-
-          <form onSubmit={onSubmit} className="space-y-5">
-            <div>
-              <label className="text-sm font-bold text-slate-300 ml-1">{t("email")}</label>
-              <input
-                type="email"
-                required
-                value={email}
-                onChange={(e) => { setEmail(e.target.value); if (err) setErr(null); }}
-                onBlur={() => setEmail((v) => v.trim())}
-                autoComplete="email"
-                placeholder="admin@example.com"
-                className={InputCls}
-              />
-            </div>
-            <div>
-              <label className="text-sm font-bold text-slate-300 ml-1 flex justify-between">
-                <span>{t("password")}</span>
-                <Link href="/auth/forgot-password" className="text-[#b89458] hover:text-[#f3deae] transition-colors font-medium">
-                  {t("forgotTitle")}
-                </Link>
-              </label>
-              <div className="relative mt-2">
-                <input
-                  type={showPassword ? "text" : "password"}
-                  required
-                  value={password}
-                  onChange={(e) => {
-                    setPassword(e.target.value);
-                    if (err) setErr(null);
-                  }}
-                  autoComplete="current-password"
-                  placeholder="输入密码"
-                  className={`${InputCls} mt-0 pr-12`}
-                />
-                <button
-                  type="button"
-                  onClick={() => setShowPassword((v) => !v)}
-                  className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:text-white focus:outline-none focus:ring-1 focus:ring-[#b89458]"
-                  aria-label={showPassword ? "隐藏密码" : "显示密码"}
-                >
-                  {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
-                </button>
-              </div>
-            </div>
-
-            {err && (
-              <div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-400" role="alert">
-                {err}
-              </div>
-            )}
-
-            <button
-              type="submit"
-              disabled={!canSubmit}
-              className="w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 text-sm font-bold text-[#5c461a] shadow-xl shadow-[#b89458]/10 transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100 mt-6"
-            >
-              {submitting ? `${t("loginBtn")}中...` : t("loginBtn")}
-            </button>
-          </form>
-
-          <p className="mt-8 text-center text-sm text-slate-400">
-            还没有账户?{" "}
-            <Link href="/auth/register" className="font-bold text-white hover:text-[#f3deae] transition-colors">
-              {t("toRegister")}
-            </Link>
-          </p>
-        </div>
-      </div>
-    </div>
+    <LoginFormPanel
+      layout="fullscreen"
+      redirectAfterLogin={next}
+      showRegisterSuccessBanner={showRegisterBanner}
+    />
   );
 }
 
@@ -167,7 +45,7 @@ export default function LoginPage() {
   return (
     <Suspense
       fallback={
-        <div className="min-h-screen bg-[#050b14] flex items-center justify-center">
+        <div className="flex min-h-screen items-center justify-center bg-[#050b14]">
           <div className="h-8 w-40 animate-pulse rounded-full bg-white/10" />
         </div>
       }
@@ -175,4 +53,4 @@ export default function LoginPage() {
       <LoginFormWithSearchParamsKey />
     </Suspense>
   );
-}
+}

+ 3 - 174
src/app/[locale]/auth/register/page.tsx

@@ -1,178 +1,7 @@
 "use client";
 
-import { useTranslations } from "next-intl";
-import { useRouter } from "@/i18n/navigation";
-import { Link } from "@/i18n/navigation";
-import { useEffect, useState } from "react";
-import { useAuth } from "@/providers/auth-provider";
-import { ApiError } from "@/lib/api";
-import { isRegisterPasswordValid } from "@/lib/password-rules";
-import { sendRegisterVerificationCode } from "@/lib/register-api";
-import { Eye, EyeOff, UserPlus, CheckCircle2 } from "lucide-react";
-import { cn } from "@/lib/utils";
-
-const SEND_CODE_COOLDOWN_SEC = 60;
-const InputCls = "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3.5 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458] transition-all";
+import { RegisterFormPanel } from "@/components/auth/register-form-panel";
 
 export default function RegisterPage() {
-  const t = useTranslations("auth");
-  const router = useRouter();
-  const { register, logout } = useAuth();
-  const [email, setEmail] = useState("");
-  const [password, setPassword] = useState("");
-  const [showPassword, setShowPassword] = useState(false);
-  const [code, setCode] = useState("");
-  const [err, setErr] = useState<string | null>(null);
-  const [sendCooldown, setSendCooldown] = useState(0);
-  const [sendingCode, setSendingCode] = useState(false);
-  const [submitting, setSubmitting] = useState(false);
-  const [registerSuccess, setRegisterSuccess] = useState(false);
-  const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
-  const canSendCode = emailValid && !sendingCode && sendCooldown <= 0;
-  const canSubmit = email.trim().length > 0 && code.trim().length > 0 && password.length > 0 && !submitting;
-
-  useEffect(() => {
-    if (sendCooldown <= 0) return;
-    const id = window.setInterval(() => setSendCooldown((s) => (s <= 1 ? 0 : s - 1)), 1000);
-    return () => window.clearInterval(id);
-  }, [sendCooldown]);
-
-  useEffect(() => {
-    if (!registerSuccess) return;
-    const tm = window.setTimeout(() => router.push("/auth/login?registered=1"), 1600);
-    return () => window.clearTimeout(tm);
-  }, [registerSuccess, router]);
-
-  const onSendCode = async () => {
-    if (!canSendCode) return;
-    setErr(null);
-    if (!emailValid) { setErr(t("errorInvalidEmail")); return; }
-    setSendingCode(true);
-    try {
-      await sendRegisterVerificationCode(email.trim());
-      setSendCooldown(SEND_CODE_COOLDOWN_SEC);
-    } catch (e) {
-      setErr(e instanceof ApiError ? e.message : t("errorApi"));
-    } finally {
-      setSendingCode(false);
-    }
-  };
-
-  const onSubmit = async (e: React.FormEvent) => {
-    e.preventDefault();
-    if (!canSubmit) return;
-    setErr(null);
-    if (!isRegisterPasswordValid(password)) { setErr(t("errorWeakPassword")); return; }
-    setSubmitting(true);
-    const r = await register({ country: "CN", email: email.trim(), password, name: "学员", code: code.trim() });
-    if (!r.ok) {
-      if (r.message) setErr(r.message);
-      else if (r.error === "invalid_email") setErr(t("errorInvalidEmail"));
-      else if (r.error === "weak_password") setErr(t("errorWeakPassword"));
-      else if (r.error === "invalid_code") setErr(t("errorCode"));
-      else if (r.error === "country_required") setErr(t("countryRequired"));
-      else setErr(t("errorApi"));
-      setSubmitting(false);
-      return;
-    }
-    logout();
-    setRegisterSuccess(true);
-  };
-
-  if (registerSuccess) {
-    return (
-      <div className="min-h-screen bg-[#050b14] flex flex-col items-center justify-center p-4 relative font-sans overflow-hidden">
-        <div className="pointer-events-none fixed inset-0 z-0"><div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" /></div>
-        <div className="relative z-10 w-full max-w-md rounded-[2.5rem] border border-white/10 bg-[#0a1120]/80 p-10 text-center backdrop-blur-2xl shadow-2xl">
-          <div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-400"><CheckCircle2 size={40} /></div>
-          <h1 className="font-serif text-2xl font-bold text-white mb-2">{t("registerSuccessTitle")}</h1>
-          <p className="text-sm text-slate-400">{t("registerSuccessHint")}</p>
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <div className="min-h-screen bg-[#050b14] flex flex-col items-center justify-center p-4 relative font-sans overflow-hidden py-16">
-      <div className="pointer-events-none fixed inset-0 z-0">
-        <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
-      </div>
-
-      <div className="relative z-10 w-full max-w-md">
-        <div className="mb-8 flex justify-center">
-          <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-lg shadow-[#b89458]/20">
-            <UserPlus size={28} className="translate-x-[2px]" />
-          </div>
-        </div>
-
-        <div className="rounded-[2.5rem] border border-white/10 bg-[#0a1120]/80 p-8 md:p-10 backdrop-blur-2xl shadow-2xl">
-          <div className="text-center mb-8">
-            <h1 className="font-serif text-2xl font-bold text-white">{t("registerTitle")}</h1>
-            <p className="mt-2 text-sm text-slate-400">{t("registerEmailOnlyHint")}</p>
-          </div>
-
-          <form onSubmit={onSubmit} className="space-y-5">
-            <div>
-              <label className="text-sm font-bold text-slate-300 ml-1">{t("email")}</label>
-              <input type="email" required value={email} onChange={(e) => { setEmail(e.target.value); if (err) setErr(null); }} onBlur={() => setEmail((v) => v.trim())} autoComplete="email" className={InputCls} placeholder="admin@example.com" />
-            </div>
-
-            <div>
-              <label className="text-sm font-bold text-slate-300 ml-1">{t("code")}</label>
-              <div className="mt-2 flex gap-3">
-                <input required value={code} onChange={(e) => { setCode(e.target.value.replace(/\D/g, "").slice(0, 6)); if (err) setErr(null); }} className={cn(InputCls, "mt-0 flex-1")} placeholder="000000" inputMode="numeric" autoComplete="one-time-code" />
-                <button type="button" disabled={!canSendCode} onClick={onSendCode} className="shrink-0 rounded-xl border border-white/10 bg-white/5 px-4 text-xs font-bold text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50">
-                  {sendingCode ? `${t("sendCode")}...` : sendCooldown > 0 ? t("sendCodeCooldown", { sec: sendCooldown }) : t("sendCode")}
-                </button>
-              </div>
-            </div>
-
-            <div>
-              <label className="text-sm font-bold text-slate-300 ml-1">{t("password")}</label>
-              <div className="relative mt-2">
-                <input
-                  type={showPassword ? "text" : "password"}
-                  required
-                  autoComplete="new-password"
-                  value={password}
-                  onChange={(e) => {
-                    setPassword(e.target.value);
-                    if (err) setErr(null);
-                  }}
-                  className={`${InputCls} mt-0 pr-12`}
-                  placeholder="设置密码"
-                />
-                <button
-                  type="button"
-                  onClick={() => setShowPassword((v) => !v)}
-                  className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:text-white focus:outline-none focus:ring-1 focus:ring-[#b89458]"
-                  aria-label={showPassword ? "隐藏密码" : "显示密码"}
-                >
-                  {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
-                </button>
-              </div>
-              <ul className="mt-3 list-disc space-y-1.5 pl-5 text-xs font-medium text-slate-500">
-                <li>{t("passwordRuleLen")}</li>
-                <li>{t("passwordRuleCase")}</li>
-                <li>{t("passwordRuleMix")}</li>
-              </ul>
-            </div>
-
-            {err && <div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-400" role="alert">{err}</div>}
-
-            <button type="submit" disabled={!canSubmit} className="w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 text-sm font-bold text-[#5c461a] shadow-xl shadow-[#b89458]/10 transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100 mt-6">
-              {submitting ? `${t("registerBtn")}中...` : t("registerBtn")}
-            </button>
-          </form>
-
-          <p className="mt-8 text-center text-sm text-slate-400">
-            已有账户?{" "}
-            <Link href="/auth/login" className="font-bold text-white hover:text-[#f3deae] transition-colors">
-              {t("toLogin")}
-            </Link>
-          </p>
-        </div>
-      </div>
-    </div>
-  );
-}
+  return <RegisterFormPanel layout="fullscreen" />;
+}

+ 14 - 0
src/app/globals.css

@@ -254,3 +254,17 @@ body {
   background-size: 400% 100%;
   animation: ui-skeleton-shimmer 1.4s ease infinite;
 }
+
+/* 登录/注册/忘记密码弹窗:内容过高时可滚动,但不显示侧边滚动条 */
+.auth-modal-body {
+  max-height: min(90vh, 720px);
+  overflow-y: auto;
+  scrollbar-width: none;
+  -ms-overflow-style: none;
+}
+
+.auth-modal-body::-webkit-scrollbar {
+  display: none;
+  width: 0;
+  height: 0;
+}

+ 74 - 0
src/components/auth/auth-flow-modal.tsx

@@ -0,0 +1,74 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { ModalShell } from "@/components/ui/modal-shell";
+import { LoginFormPanel } from "@/components/auth/login-form-panel";
+import { RegisterFormPanel } from "@/components/auth/register-form-panel";
+import { ForgotPasswordFormPanel } from "@/components/auth/forgot-password-form-panel";
+
+type Step = "login" | "register" | "forgot";
+
+type Props = {
+  open: boolean;
+  onClose: () => void;
+  onAuthenticated: () => void;
+};
+
+export function AuthFlowModal({ open, onClose, onAuthenticated }: Props) {
+  const [step, setStep] = useState<Step>("login");
+  const [showPostRegisterBanner, setShowPostRegisterBanner] = useState(false);
+
+  useEffect(() => {
+    if (!open) {
+      /* eslint-disable-next-line react-hooks/set-state-in-effect -- 关闭蒙层时复位步骤,下次打开从登录开始 */
+      setStep("login");
+      setShowPostRegisterBanner(false);
+    }
+  }, [open]);
+
+  useEffect(() => {
+    if (!showPostRegisterBanner) return;
+    const id = window.setTimeout(() => setShowPostRegisterBanner(false), 3000);
+    return () => window.clearTimeout(id);
+  }, [showPostRegisterBanner]);
+
+  return (
+    <ModalShell
+      open={open}
+      className="max-w-md"
+      zIndexClassName="z-[100]"
+      onBackdropClick={onClose}
+    >
+      {step === "login" ? (
+        <LoginFormPanel
+          layout="embedded"
+          onAuthenticated={onAuthenticated}
+          onDismiss={onClose}
+          showRegisterSuccessBanner={showPostRegisterBanner}
+          embeddedAuthSwitch={{
+            onForgot: () => setStep("forgot"),
+            onRegister: () => setStep("register"),
+          }}
+        />
+      ) : null}
+      {step === "register" ? (
+        <RegisterFormPanel
+          layout="embedded"
+          onDismiss={onClose}
+          onRegisterComplete={() => {
+            setShowPostRegisterBanner(true);
+            setStep("login");
+          }}
+          onBackToLogin={() => setStep("login")}
+        />
+      ) : null}
+      {step === "forgot" ? (
+        <ForgotPasswordFormPanel
+          layout="embedded"
+          onDismiss={onClose}
+          onBackToLogin={() => setStep("login")}
+        />
+      ) : null}
+    </ModalShell>
+  );
+}

+ 166 - 0
src/components/auth/forgot-password-form-panel.tsx

@@ -0,0 +1,166 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { useState } from "react";
+import { ApiError } from "@/lib/api";
+import { sendForgotPasswordEmail } from "@/lib/auth-api";
+import { KeyRound, CheckCircle2, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const inputCls =
+  "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3.5 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458] transition-all";
+
+type Props = {
+  layout: "fullscreen" | "embedded";
+  onDismiss?: () => void;
+  /** 弹窗内:返回登录步骤 */
+  onBackToLogin?: () => void;
+};
+
+export function ForgotPasswordFormPanel({ layout, onDismiss, onBackToLogin }: Props) {
+  const t = useTranslations("auth");
+  const [email, setEmail] = useState("");
+  const [done, setDone] = useState(false);
+  const [submitting, setSubmitting] = useState(false);
+  const [err, setErr] = useState<string | null>(null);
+
+  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
+    e.preventDefault();
+    if (submitting) return;
+    setErr(null);
+    try {
+      setSubmitting(true);
+      await sendForgotPasswordEmail(email.trim());
+      setDone(true);
+    } catch (error) {
+      if (error instanceof ApiError && error.message.trim()) {
+        setErr(error.message);
+      } else {
+        setErr(t("errorApi"));
+      }
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  const embedded = layout === "embedded";
+
+  const card = (
+    <div
+      className={cn(
+        "auth-modal-body relative border border-white/10 bg-[#0a1120]/80 backdrop-blur-2xl shadow-2xl",
+        layout === "fullscreen" ? "rounded-[2.5rem] p-8 md:p-10" : "rounded-2xl p-6 sm:p-8",
+      )}
+    >
+      {embedded && onDismiss ? (
+        <button
+          type="button"
+          onClick={onDismiss}
+          className="absolute right-3 top-3 rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
+          aria-label="关闭"
+        >
+          <X size={18} />
+        </button>
+      ) : null}
+
+      {layout === "fullscreen" ? (
+        <div className="mb-8 flex justify-center">
+          <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-lg shadow-[#b89458]/20">
+            <KeyRound size={28} />
+          </div>
+        </div>
+      ) : (
+        <div className="mb-6 flex justify-center">
+          <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-md shadow-[#b89458]/20">
+            <KeyRound size={22} />
+          </div>
+        </div>
+      )}
+
+      <div className={layout === "fullscreen" ? "mb-8 text-center" : "mb-6 text-center"}>
+        <h1
+          className={cn(
+            "font-serif font-bold text-white",
+            layout === "fullscreen" ? "text-2xl" : "text-xl",
+          )}
+        >
+          {t("forgotTitle")}
+        </h1>
+        <p className="mt-2 text-sm text-slate-400">{t("forgotHint")}</p>
+      </div>
+
+      {!done ? (
+        <form onSubmit={onSubmit} className="space-y-5">
+          <div>
+            <label className="ml-1 text-sm font-bold text-slate-300">{t("email")}</label>
+            <input
+              type="email"
+              required
+              value={email}
+              onChange={(e) => setEmail(e.target.value)}
+              className={inputCls}
+              placeholder="admin@example.com"
+            />
+          </div>
+
+          {err ? (
+            <div
+              className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-400"
+              role="alert"
+            >
+              {err}
+            </div>
+          ) : null}
+
+          <button
+            type="submit"
+            disabled={submitting}
+            className="mt-6 w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 text-sm font-bold text-[#5c461a] shadow-xl shadow-[#b89458]/10 transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100"
+          >
+            {submitting ? "发送中..." : t("resetBtn")}
+          </button>
+        </form>
+      ) : (
+        <div className="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-6 text-center">
+          <CheckCircle2 size={40} className="mx-auto mb-4 text-emerald-400" />
+          <p className="text-base font-bold text-emerald-400">邮件已发送</p>
+          <p className="mt-2 text-sm text-emerald-500/80">请前往您的邮箱查收密码重置链接。</p>
+        </div>
+      )}
+
+      <p className="mt-8 text-center text-sm text-slate-400">
+        想起密码了?{" "}
+        {embedded && onBackToLogin ? (
+          <button
+            type="button"
+            onClick={onBackToLogin}
+            className="font-bold text-white transition-colors hover:text-[#f3deae]"
+          >
+            {t("toLogin")}
+          </button>
+        ) : (
+          <Link
+            href="/auth/login"
+            className="font-bold text-white transition-colors hover:text-[#f3deae]"
+          >
+            {t("toLogin")}
+          </Link>
+        )}
+      </p>
+    </div>
+  );
+
+  if (layout === "fullscreen") {
+    return (
+      <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-[#050b14] p-4 font-sans">
+        <div className="pointer-events-none fixed inset-0 z-0">
+          <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
+        </div>
+        <div className="relative z-10 w-full max-w-md">{card}</div>
+      </div>
+    );
+  }
+
+  return <div className="w-full max-w-md">{card}</div>;
+}

+ 242 - 0
src/components/auth/login-form-panel.tsx

@@ -0,0 +1,242 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useRouter } from "@/i18n/navigation";
+import { Link } from "@/i18n/navigation";
+import { useState } from "react";
+import { useAuth } from "@/providers/auth-provider";
+import { Eye, EyeOff, LogIn, CheckCircle2, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export function safePostLoginPath(raw: string | null): string | null {
+  if (!raw) return null;
+  if (!raw.startsWith("/") || raw.startsWith("//")) return null;
+  if (raw.includes("\\")) return null;
+  return raw;
+}
+
+const inputCls =
+  "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3.5 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458] transition-all";
+
+type Props = {
+  layout: "fullscreen" | "embedded";
+  /** 全屏登录页:登录成功后的跳转路径,缺省为 /account */
+  redirectAfterLogin?: string | null;
+  /** 弹窗等场景:登录成功后回调(例如关闭弹窗并继续购买),优先级高于 redirectAfterLogin */
+  onAuthenticated?: () => void;
+  /** embedded:关闭弹窗 */
+  onDismiss?: () => void;
+  showRegisterSuccessBanner?: boolean;
+  /** 弹窗内:忘记密码 / 注册在同一蒙层内切换,不跳转路由 */
+  embeddedAuthSwitch?: {
+    onForgot: () => void;
+    onRegister: () => void;
+  };
+};
+
+export function LoginFormPanel({
+  layout,
+  redirectAfterLogin,
+  onAuthenticated,
+  onDismiss,
+  showRegisterSuccessBanner,
+  embeddedAuthSwitch,
+}: Props) {
+  const t = useTranslations("auth");
+  const router = useRouter();
+  const { login } = useAuth();
+  const [email, setEmail] = useState("");
+  const [password, setPassword] = useState("");
+  const [showPassword, setShowPassword] = useState(false);
+  const [err, setErr] = useState<string | null>(null);
+  const [submitting, setSubmitting] = useState(false);
+  const canSubmit = email.trim().length > 0 && password.length > 0 && !submitting;
+
+  const onSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!canSubmit) return;
+    setErr(null);
+    setSubmitting(true);
+    const r = await login(email.trim(), password);
+    if (!r.ok) {
+      if (r.message) setErr(r.message);
+      else if (r.error === "invalid_email") setErr(t("errorInvalidEmail"));
+      else setErr(t("errorCredentials"));
+      setSubmitting(false);
+      return;
+    }
+    if (onAuthenticated) {
+      onAuthenticated();
+      setSubmitting(false);
+      return;
+    }
+    const next = redirectAfterLogin ?? "/account";
+    router.push(next);
+  };
+
+  const card = (
+    <div
+      className={cn(
+        "auth-modal-body relative border border-white/10 bg-[#0a1120]/80 backdrop-blur-2xl shadow-2xl",
+        layout === "fullscreen" ? "rounded-[2.5rem] p-8 md:p-10" : "rounded-2xl p-6 sm:p-8",
+      )}
+    >
+      {layout === "embedded" && onDismiss ? (
+        <button
+          type="button"
+          onClick={onDismiss}
+          className="absolute right-3 top-3 rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
+          aria-label="关闭"
+        >
+          <X size={18} />
+        </button>
+      ) : null}
+
+      {layout === "fullscreen" ? (
+        <div className="mb-8 flex justify-center">
+          <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-lg shadow-[#b89458]/20">
+            <LogIn size={28} className="translate-x-[2px]" />
+          </div>
+        </div>
+      ) : (
+        <div className="mb-6 flex justify-center">
+          <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-md shadow-[#b89458]/20">
+            <LogIn size={22} className="translate-x-[2px]" />
+          </div>
+        </div>
+      )}
+
+      <div className={layout === "fullscreen" ? "mb-8 text-center" : "mb-6 text-center"}>
+        <h1
+          className={cn(
+            "font-serif font-bold text-white",
+            layout === "fullscreen" ? "text-2xl" : "text-xl",
+          )}
+        >
+          {t("loginTitle")}
+        </h1>
+        <p className="mt-2 text-sm text-slate-400">登录您的交易账户以继续学习</p>
+      </div>
+
+      {showRegisterSuccessBanner ? (
+        <div
+          className="mb-6 flex items-center justify-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm font-medium text-emerald-400"
+          role="status"
+        >
+          <CheckCircle2 size={16} /> {t("loginAfterRegister")}
+        </div>
+      ) : null}
+
+      <form onSubmit={onSubmit} className="space-y-5">
+        <div>
+          <label className="ml-1 text-sm font-bold text-slate-300">{t("email")}</label>
+          <input
+            type="email"
+            required
+            value={email}
+            onChange={(e) => {
+              setEmail(e.target.value);
+              if (err) setErr(null);
+            }}
+            onBlur={() => setEmail((v) => v.trim())}
+            autoComplete="email"
+            placeholder="admin@example.com"
+            className={inputCls}
+          />
+        </div>
+        <div>
+          <label className="ml-1 flex justify-between text-sm font-bold text-slate-300">
+            <span>{t("password")}</span>
+            {embeddedAuthSwitch ? (
+              <button
+                type="button"
+                onClick={embeddedAuthSwitch.onForgot}
+                className="font-medium text-[#b89458] transition-colors hover:text-[#f3deae]"
+              >
+                {t("forgotTitle")}
+              </button>
+            ) : (
+              <Link
+                href="/auth/forgot-password"
+                className="font-medium text-[#b89458] transition-colors hover:text-[#f3deae]"
+              >
+                {t("forgotTitle")}
+              </Link>
+            )}
+          </label>
+          <div className="relative mt-2">
+            <input
+              type={showPassword ? "text" : "password"}
+              required
+              value={password}
+              onChange={(e) => {
+                setPassword(e.target.value);
+                if (err) setErr(null);
+              }}
+              autoComplete="current-password"
+              placeholder="输入密码"
+              className={`${inputCls} mt-0 pr-12`}
+            />
+            <button
+              type="button"
+              onClick={() => setShowPassword((v) => !v)}
+              className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:text-white focus:outline-none focus:ring-1 focus:ring-[#b89458]"
+              aria-label={showPassword ? "隐藏密码" : "显示密码"}
+            >
+              {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
+            </button>
+          </div>
+        </div>
+
+        {err ? (
+          <div
+            className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-400"
+            role="alert"
+          >
+            {err}
+          </div>
+        ) : null}
+
+        <button
+          type="submit"
+          disabled={!canSubmit}
+          className="mt-6 w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 text-sm font-bold text-[#5c461a] shadow-xl shadow-[#b89458]/10 transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100"
+        >
+          {submitting ? `${t("loginBtn")}中...` : t("loginBtn")}
+        </button>
+      </form>
+
+      <p className="mt-8 text-center text-sm text-slate-400">
+        {embeddedAuthSwitch ? (
+          <button
+            type="button"
+            onClick={embeddedAuthSwitch.onRegister}
+            className="font-bold text-white transition-colors hover:text-[#f3deae]"
+          >
+            {t("toRegister")}
+          </button>
+        ) : (
+          <Link
+            href="/auth/register"
+            className="font-bold text-white transition-colors hover:text-[#f3deae]"
+          >
+            {t("toRegister")}
+          </Link>
+        )}
+      </p>
+    </div>
+  );
+
+  if (layout === "fullscreen") {
+    return (
+      <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-[#050b14] p-4 font-sans">
+        <div className="pointer-events-none fixed inset-0 z-0">
+          <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
+        </div>
+        <div className="relative z-10 w-full max-w-md">{card}</div>
+      </div>
+    );
+  }
+
+  return <div className="w-full max-w-md">{card}</div>;
+}

+ 331 - 0
src/components/auth/register-form-panel.tsx

@@ -0,0 +1,331 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useRouter } from "@/i18n/navigation";
+import { Link } from "@/i18n/navigation";
+import { useEffect, useState } from "react";
+import { useAuth } from "@/providers/auth-provider";
+import { ApiError } from "@/lib/api";
+import { isRegisterPasswordValid } from "@/lib/password-rules";
+import { sendRegisterVerificationCode } from "@/lib/register-api";
+import { Eye, EyeOff, UserPlus, CheckCircle2, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const SEND_CODE_COOLDOWN_SEC = 60;
+const inputCls =
+  "mt-2 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3.5 text-sm text-white placeholder-slate-500 focus:border-[#b89458] focus:outline-none focus:ring-1 focus:ring-[#b89458] transition-all";
+
+type Props = {
+  layout: "fullscreen" | "embedded";
+  onDismiss?: () => void;
+  /** 弹窗内:注册成功后回到登录 */
+  onRegisterComplete?: () => void;
+  /** 弹窗内:已有账号,返回登录 */
+  onBackToLogin?: () => void;
+};
+
+export function RegisterFormPanel({
+  layout,
+  onDismiss,
+  onRegisterComplete,
+  onBackToLogin,
+}: Props) {
+  const t = useTranslations("auth");
+  const router = useRouter();
+  const { register, logout } = useAuth();
+  const [email, setEmail] = useState("");
+  const [password, setPassword] = useState("");
+  const [showPassword, setShowPassword] = useState(false);
+  const [code, setCode] = useState("");
+  const [err, setErr] = useState<string | null>(null);
+  const [sendCooldown, setSendCooldown] = useState(0);
+  const [sendingCode, setSendingCode] = useState(false);
+  const [submitting, setSubmitting] = useState(false);
+  const [registerSuccess, setRegisterSuccess] = useState(false);
+  const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
+  const canSendCode = emailValid && !sendingCode && sendCooldown <= 0;
+  const canSubmit = email.trim().length > 0 && code.trim().length > 0 && password.length > 0 && !submitting;
+  const embedded = layout === "embedded";
+
+  useEffect(() => {
+    if (sendCooldown <= 0) return;
+    const id = window.setInterval(() => setSendCooldown((s) => (s <= 1 ? 0 : s - 1)), 1000);
+    return () => window.clearInterval(id);
+  }, [sendCooldown]);
+
+  useEffect(() => {
+    if (!registerSuccess || embedded) return;
+    const tm = window.setTimeout(() => router.push("/auth/login?registered=1"), 1600);
+    return () => window.clearTimeout(tm);
+  }, [registerSuccess, router, embedded]);
+
+  const onSendCode = async () => {
+    if (!canSendCode) return;
+    setErr(null);
+    if (!emailValid) {
+      setErr(t("errorInvalidEmail"));
+      return;
+    }
+    setSendingCode(true);
+    try {
+      await sendRegisterVerificationCode(email.trim());
+      setSendCooldown(SEND_CODE_COOLDOWN_SEC);
+    } catch (e) {
+      setErr(e instanceof ApiError ? e.message : t("errorApi"));
+    } finally {
+      setSendingCode(false);
+    }
+  };
+
+  const onSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!canSubmit) return;
+    setErr(null);
+    if (!isRegisterPasswordValid(password)) {
+      setErr(t("errorWeakPassword"));
+      return;
+    }
+    setSubmitting(true);
+    const r = await register({
+      country: "CN",
+      email: email.trim(),
+      password,
+      name: "学员",
+      code: code.trim(),
+    });
+    if (!r.ok) {
+      if (r.message) setErr(r.message);
+      else if (r.error === "invalid_email") setErr(t("errorInvalidEmail"));
+      else if (r.error === "weak_password") setErr(t("errorWeakPassword"));
+      else if (r.error === "invalid_code") setErr(t("errorCode"));
+      else if (r.error === "country_required") setErr(t("countryRequired"));
+      else setErr(t("errorApi"));
+      setSubmitting(false);
+      return;
+    }
+    logout();
+    setRegisterSuccess(true);
+  };
+
+  if (registerSuccess) {
+    const successCard = (
+      <div className="text-center">
+        <div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-400">
+          <CheckCircle2 size={40} />
+        </div>
+        <h1 className="mb-2 font-serif text-2xl font-bold text-white">{t("registerSuccessTitle")}</h1>
+        <p className="text-sm text-slate-400">
+          {embedded ? "请使用邮箱与密码登录以继续。" : t("registerSuccessHint")}
+        </p>
+        {embedded && onRegisterComplete ? (
+          <button
+            type="button"
+            onClick={onRegisterComplete}
+            className="mt-6 w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-3.5 text-sm font-bold text-[#5c461a] shadow-xl"
+          >
+            {t("loginBtn")}
+          </button>
+        ) : null}
+      </div>
+    );
+
+    if (layout === "fullscreen") {
+      return (
+        <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-[#050b14] p-4 font-sans">
+          <div className="pointer-events-none fixed inset-0 z-0">
+            <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
+          </div>
+          <div className="relative z-10 w-full max-w-md rounded-[2.5rem] border border-white/10 bg-[#0a1120]/80 p-10 text-center backdrop-blur-2xl shadow-2xl">
+            {successCard}
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div className="relative w-full max-w-md">
+        {onDismiss ? (
+          <button
+            type="button"
+            onClick={onDismiss}
+            className="absolute right-3 top-3 z-10 rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
+            aria-label="关闭"
+          >
+            <X size={18} />
+          </button>
+        ) : null}
+        <div className="auth-modal-body rounded-2xl border border-white/10 bg-[#0a1120]/80 p-6 text-center backdrop-blur-2xl shadow-2xl sm:p-8">
+          {successCard}
+        </div>
+      </div>
+    );
+  }
+
+  const card = (
+    <div
+      className={cn(
+        "auth-modal-body relative border border-white/10 bg-[#0a1120]/80 backdrop-blur-2xl shadow-2xl",
+        layout === "fullscreen" ? "rounded-[2.5rem] p-8 md:p-10" : "rounded-2xl p-6 sm:p-8",
+      )}
+    >
+      {embedded && onDismiss ? (
+        <button
+          type="button"
+          onClick={onDismiss}
+          className="absolute right-3 top-3 rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
+          aria-label="关闭"
+        >
+          <X size={18} />
+        </button>
+      ) : null}
+
+      {layout === "fullscreen" ? (
+        <div className="mb-8 flex justify-center">
+          <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-lg shadow-[#b89458]/20">
+            <UserPlus size={28} className="translate-x-[2px]" />
+          </div>
+        </div>
+      ) : (
+        <div className="mb-6 flex justify-center">
+          <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] text-[#5c461a] shadow-md shadow-[#b89458]/20">
+            <UserPlus size={22} className="translate-x-[2px]" />
+          </div>
+        </div>
+      )}
+
+      <div className={layout === "fullscreen" ? "mb-8 text-center" : "mb-6 text-center"}>
+        <h1
+          className={cn(
+            "font-serif font-bold text-white",
+            layout === "fullscreen" ? "text-2xl" : "text-xl",
+          )}
+        >
+          {t("registerTitle")}
+        </h1>
+        <p className="mt-2 text-sm text-slate-400">{t("registerEmailOnlyHint")}</p>
+      </div>
+
+      <form onSubmit={onSubmit} className="space-y-5">
+        <div>
+          <label className="ml-1 text-sm font-bold text-slate-300">{t("email")}</label>
+          <input
+            type="email"
+            required
+            value={email}
+            onChange={(e) => {
+              setEmail(e.target.value);
+              if (err) setErr(null);
+            }}
+            onBlur={() => setEmail((v) => v.trim())}
+            autoComplete="email"
+            className={inputCls}
+            placeholder="admin@example.com"
+          />
+        </div>
+
+        <div>
+          <label className="ml-1 text-sm font-bold text-slate-300">{t("code")}</label>
+          <div className="mt-2 flex gap-3">
+            <input
+              required
+              value={code}
+              onChange={(e) => {
+                setCode(e.target.value.replace(/\D/g, "").slice(0, 6));
+                if (err) setErr(null);
+              }}
+              className={cn(inputCls, "mt-0 flex-1")}
+              placeholder="000000"
+              inputMode="numeric"
+              autoComplete="one-time-code"
+            />
+            <button
+              type="button"
+              disabled={!canSendCode}
+              onClick={onSendCode}
+              className="shrink-0 rounded-xl border border-white/10 bg-white/5 px-4 text-xs font-bold text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
+            >
+              {sendingCode ? `${t("sendCode")}...` : sendCooldown > 0 ? t("sendCodeCooldown", { sec: sendCooldown }) : t("sendCode")}
+            </button>
+          </div>
+        </div>
+
+        <div>
+          <label className="ml-1 text-sm font-bold text-slate-300">{t("password")}</label>
+          <div className="relative mt-2">
+            <input
+              type={showPassword ? "text" : "password"}
+              required
+              autoComplete="new-password"
+              value={password}
+              onChange={(e) => {
+                setPassword(e.target.value);
+                if (err) setErr(null);
+              }}
+              className={`${inputCls} mt-0 pr-12`}
+              placeholder="设置密码"
+            />
+            <button
+              type="button"
+              onClick={() => setShowPassword((v) => !v)}
+              className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:text-white focus:outline-none focus:ring-1 focus:ring-[#b89458]"
+              aria-label={showPassword ? "隐藏密码" : "显示密码"}
+            >
+              {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
+            </button>
+          </div>
+          <ul className="mt-3 list-disc space-y-1.5 pl-5 text-xs font-medium text-slate-500">
+            <li>{t("passwordRuleLen")}</li>
+            <li>{t("passwordRuleCase")}</li>
+            <li>{t("passwordRuleMix")}</li>
+          </ul>
+        </div>
+
+        {err ? (
+          <div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-400" role="alert">
+            {err}
+          </div>
+        ) : null}
+
+        <button
+          type="submit"
+          disabled={!canSubmit}
+          className="mt-6 w-full rounded-2xl bg-gradient-to-br from-[#f3deae] to-[#d9be88] py-4 text-sm font-bold text-[#5c461a] shadow-xl shadow-[#b89458]/10 transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100"
+        >
+          {submitting ? `${t("registerBtn")}中...` : t("registerBtn")}
+        </button>
+      </form>
+
+      <p className="mt-8 text-center text-sm text-slate-400">
+        {embedded && onBackToLogin ? (
+          <>
+            已有账户?{" "}
+            <button
+              type="button"
+              onClick={onBackToLogin}
+              className="font-bold text-white transition-colors hover:text-[#f3deae]"
+            >
+              {t("loginBtn")}
+            </button>
+          </>
+        ) : (
+          <Link href="/auth/login" className="font-bold text-white transition-colors hover:text-[#f3deae]">
+            {t("toLogin")}
+          </Link>
+        )}
+      </p>
+    </div>
+  );
+
+  if (layout === "fullscreen") {
+    return (
+      <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-[#050b14] p-4 py-16 font-sans">
+        <div className="pointer-events-none fixed inset-0 z-0">
+          <div className="absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#b89458]/10 blur-[150px]" />
+        </div>
+        <div className="relative z-10 w-full max-w-md">{card}</div>
+      </div>
+    );
+  }
+
+  return <div className="w-full max-w-md">{card}</div>;
+}

+ 36 - 17
src/components/course-buy-button.tsx

@@ -1,12 +1,13 @@
 "use client";
 
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { useTranslations } from "next-intl";
 import { useRouter } from "@/i18n/navigation";
 import { cn } from "@/lib/utils";
 import { useAuth } from "@/providers/auth-provider";
 import { generateOrderByGoodId } from "@/lib/order-api";
 import { ModalShell } from "@/components/ui/modal-shell";
+import { AuthFlowModal } from "@/components/auth/auth-flow-modal";
 import { ApiError } from "@/lib/api";
 
 type Props = {
@@ -29,6 +30,7 @@ export function CourseBuyButton({
   const router = useRouter();
   const refreshTimerRef = useRef<number | null>(null);
   const closeDialogTimerRef = useRef<number | null>(null);
+  const [loginModalOpen, setLoginModalOpen] = useState(false);
   const [freeOrderDialog, setFreeOrderDialog] = useState<{
     open: boolean;
     status: "loading" | "success" | "error";
@@ -57,18 +59,7 @@ export function CourseBuyButton({
     };
   }, []);
 
-  const handleBuy = async () => {
-    if (!isReady) return;
-    if (!user) {
-      const next = encodeURIComponent(checkoutPath);
-      router.push(`/auth/login?next=${next}`);
-      return;
-    }
-    if (coursePrice > 0) {
-      router.push(checkoutPath);
-      return;
-    }
-
+  const runFreeOrderFlow = useCallback(async () => {
     try {
       setIsAuthExpiredError(false);
       setIsFreeOrderSubmitting(true);
@@ -88,7 +79,6 @@ export function CourseBuyButton({
       }, 900);
       refreshTimerRef.current = window.setTimeout(() => {
         router.refresh();
-        // 某些场景下仅 refresh 不会触发列表客户端状态更新,补一次整页刷新确保权益即时生效。
         window.setTimeout(() => {
           window.location.reload();
         }, 120);
@@ -98,7 +88,8 @@ export function CourseBuyButton({
         (error instanceof ApiError && error.status === 600) ||
         (error instanceof Error && error.message.includes("登录状态失效"));
       setIsAuthExpiredError(isAuthExpired);
-      const message = error instanceof Error && error.message.trim() ? error.message : "购买失败,请稍后重试。";
+      const message =
+        error instanceof Error && error.message.trim() ? error.message : "购买失败,请稍后重试。";
       setFreeOrderDialog({
         open: true,
         status: "error",
@@ -107,6 +98,28 @@ export function CourseBuyButton({
     } finally {
       setIsFreeOrderSubmitting(false);
     }
+  }, [courseId, router]);
+
+  const proceedAfterLogin = useCallback(() => {
+    setLoginModalOpen(false);
+    if (coursePrice > 0) {
+      router.push(checkoutPath);
+      return;
+    }
+    void runFreeOrderFlow();
+  }, [checkoutPath, coursePrice, router, runFreeOrderFlow]);
+
+  const handleBuy = async () => {
+    if (!isReady) return;
+    if (!user) {
+      setLoginModalOpen(true);
+      return;
+    }
+    if (coursePrice > 0) {
+      router.push(checkoutPath);
+      return;
+    }
+    await runFreeOrderFlow();
   };
 
   return (
@@ -125,6 +138,13 @@ export function CourseBuyButton({
       >
         {isFreeOrderSubmitting ? "处理中..." : t("buy")}
       </button>
+
+      <AuthFlowModal
+        open={loginModalOpen}
+        onClose={() => setLoginModalOpen(false)}
+        onAuthenticated={proceedAfterLogin}
+      />
+
       {freeOrderDialog.open ? (
         <ModalShell open={freeOrderDialog.open} className="max-w-sm">
           <div className="w-full rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-xl">
@@ -157,8 +177,7 @@ export function CourseBuyButton({
                 onClick={() => {
                   setFreeOrderDialog((prev) => ({ ...prev, open: false }));
                   if (isAuthExpiredError) {
-                    const next = encodeURIComponent(checkoutPath);
-                    router.push(`/auth/login?next=${next}`);
+                    setLoginModalOpen(true);
                   }
                 }}
                 className="mt-5 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm hover:bg-[var(--background)]"

+ 32 - 4
src/components/ui/modal-shell.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
 import { cn } from "@/lib/utils";
 
 export function ModalShell({
@@ -7,29 +9,55 @@ export function ModalShell({
   children,
   className,
   zIndexClassName = "z-[60]",
+  onBackdropClick,
 }: {
   open: boolean;
   children: React.ReactNode;
   className?: string;
   zIndexClassName?: string;
+  /** 点击遮罩时触发(子内容已阻止冒泡) */
+  onBackdropClick?: () => void;
 }) {
-  return (
+  const [mounted, setMounted] = useState(false);
+
+  useEffect(() => {
+    /* eslint-disable-next-line react-hooks/set-state-in-effect -- document.body portal only after mount; avoids SSR mismatch */
+    setMounted(true);
+  }, []);
+
+  if (!mounted) {
+    return null;
+  }
+
+  return createPortal(
     <div
+      role="presentation"
       className={cn(
         "fixed inset-0 flex items-center justify-center px-4 transition-all duration-300",
         zIndexClassName,
-        open ? "pointer-events-auto bg-black/45 opacity-100" : "pointer-events-none bg-black/0 opacity-0",
+        open
+          ? "pointer-events-auto bg-slate-900/55 opacity-100 backdrop-blur-[2px]"
+          : "pointer-events-none bg-transparent opacity-0",
       )}
+      onClick={
+        onBackdropClick && open
+          ? () => {
+              onBackdropClick();
+            }
+          : undefined
+      }
     >
       <div
         className={cn(
-          "w-full transition-all duration-300",
+          "mx-auto w-full transition-all duration-300",
           open ? "translate-y-0 scale-100 opacity-100" : "translate-y-3 scale-[0.98] opacity-0",
           className,
         )}
+        onClick={(e) => e.stopPropagation()}
       >
         {children}
       </div>
-    </div>
+    </div>,
+    document.body,
   );
 }