ALIEZ пре 1 недеља
родитељ
комит
dec559c72a

+ 4 - 4
.env.development

@@ -5,11 +5,11 @@ NEXT_PUBLIC_APP_ENV=local
 
 # 主业务 :8005 | 汇款/出款/存款支付 :8504(与 src/lib/remittance-client、withdrawal-api、checkout-api 一致)
 NEXT_PUBLIC_API_BASE_URL_LOCAL=/api-backend
-# API_PROXY_TARGET=http://192.168.0.33:8005
-API_PROXY_TARGET=http://103.158.191.66:8005
+API_PROXY_TARGET=http://192.168.0.27:8005
+# API_PROXY_TARGET=http://103.158.191.66:8005
 NEXT_PUBLIC_REMITTANCE_API_BASE_URL=/api-backend-remittance
-# API_PROXY_TARGET_REMITTANCE=http://192.168.0.33:8504
-API_PROXY_TARGET_REMITTANCE=http://103.158.191.66:8504
+API_PROXY_TARGET_REMITTANCE=http://192.168.0.27:8504
+# API_PROXY_TARGET_REMITTANCE=http://103.158.191.66:8504
 
 # __ORIGIN__:浏览器 → 当前域 + /api-backend;SSR → NEXT_PUBLIC_SITE_URL + /api-backend。端口仍由上面 API_PROXY_* 决定。
 NEXT_PUBLIC_API_BASE_URL_TEST=__ORIGIN__

+ 9 - 1
messages/en.json

@@ -139,6 +139,9 @@
   },
   "auth": {
     "loginTitle": "Log in",
+    "loginSubtitle": "Sign in to your trading account to continue learning",
+    "passwordPlaceholder": "Enter password",
+    "setPasswordPlaceholder": "Set password",
     "registerTitle": "Sign up",
     "forgotTitle": "Forgot password",
     "changePwdTitle": "Change password",
@@ -149,6 +152,9 @@
     "passwordRuleMix": "Use a combination of numbers and English letters",
     "name": "Name",
     "code": "Email code",
+    "referralCode": "Referral code",
+    "referralCodePlaceholder": "Enter if you have one",
+    "optional": "optional",
     "sendCode": "Send code",
     "sendCodeCooldown": "Resend in {sec}s",
     "country": "Country / region",
@@ -203,7 +209,9 @@
     "demoTitle": "Local demo",
     "demoHint": "Use the shortcut below to test unlock without waiting.",
     "demoUnlock": "Demo: satisfy rules and skip countdown",
-    "loginRequired": "Log in to view the member center."
+    "loginRequired": "Log in to view the member center.",
+    "notLoggedInTitle": "You are not signed in",
+    "loginNow": "Sign in now"
   },
   "quiz": {
     "title": "Prize quiz",

+ 9 - 1
messages/zh.json

@@ -139,6 +139,9 @@
   },
   "auth": {
     "loginTitle": "登录",
+    "loginSubtitle": "登录您的交易账户以继续学习",
+    "passwordPlaceholder": "输入密码",
+    "setPasswordPlaceholder": "设置密码",
     "registerTitle": "注册",
     "forgotTitle": "忘记密码",
     "changePwdTitle": "修改密码",
@@ -149,6 +152,9 @@
     "passwordRuleMix": "使用数字和英文字母的组合",
     "name": "姓名",
     "code": "邮箱验证码",
+    "referralCode": "推荐码",
+    "referralCodePlaceholder": "如有推荐码请填写",
+    "optional": "选填",
     "sendCode": "发送验证码",
     "sendCodeCooldown": "{sec} 秒后可重发",
     "country": "国家/地区",
@@ -206,7 +212,9 @@
     "demoTitle": "本地演示",
     "demoHint": "后台未就绪时,可用下方按钮快速体验「解锁问答」流程。",
     "demoUnlock": "一键演示:满足条件并跳过倒计时",
-    "loginRequired": "请先登录以查看会员中心。"
+    "loginRequired": "请先登录以查看会员中心。",
+    "notLoggedInTitle": "您尚未登录",
+    "loginNow": "立即登录"
   },
   "quiz": {
     "title": "有奖知识问答",

+ 5 - 0
next.config.ts

@@ -8,6 +8,11 @@ const remittanceProxyTarget =
   process.env.API_PROXY_TARGET_REMITTANCE?.replace(/\/$/, "") ?? "";
 
 const nextConfig: NextConfig = {
+  /**
+   * 开发模式:用局域网 IP(如 http://192.168.0.28:3000)访问时,Next 16 默认只允许 localhost
+   * 加载 /_next/*,否则 JS/CSS/RSC 会 403,页面只剩 HTML 壳子。局域网段可按需增减。
+   */
+  allowedDevOrigins: ["192.168.*.*", "10.*.*.*", "172.*.*.*"],
   /**
    * 放在 Nginx/Caddy 等反代后面时开启:用客户端的 Host / 转发头构造 URL,
    * 避免 Next 把内部监听端口(如 `next start` 默认的 3000)写进重定向,

+ 2 - 8
src/app/[locale]/account/orders/page.tsx

@@ -12,6 +12,7 @@ import {
   type OrderRecord,
 } from "@/lib/order-api";
 import { cn } from "@/lib/utils";
+import { AccountLoginRequired } from "@/components/auth/account-login-required";
 
 const PAGE_SIZE = 10;
 
@@ -53,14 +54,7 @@ export default function AccountOrdersPage() {
   if (!isReady) return <div className="min-h-screen bg-[#050b14] flex items-center justify-center text-slate-500"><Loader2 className="animate-spin" /></div>;
 
   if (!user) {
-    return (
-      <div className="min-h-screen bg-[#050b14] flex items-center justify-center px-4 font-sans">
-        <div className="w-full max-w-md rounded-[2.5rem] border border-white/10 bg-white/5 p-10 text-center backdrop-blur-2xl">
-          <h2 className="text-2xl font-bold text-white mb-6">您尚未登录</h2>
-          <Link href="/auth/login" className="inline-block rounded-full bg-[#f3deae] px-10 py-4 text-sm font-bold text-[#5c461a] shadow-lg">立即登录</Link>
-        </div>
-      </div>
-    );
+    return <AccountLoginRequired />;
   }
 
   const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));

+ 54 - 27
src/app/[locale]/account/page.tsx

@@ -10,7 +10,7 @@ import {
   fetchBalanceRecordList,
   type BalanceRecord,
 } from "@/lib/balance-record-api";
-import { fetchCustomUserInfo, updateUserInfo } from "@/lib/user-info-api";
+import { fetchCustomUserInfo, tryFetchCustomUserInfo, updateUserInfo } from "@/lib/user-info-api";
 import {
   cancelOrder,
   fetchOrderList,
@@ -27,6 +27,8 @@ import {
   type WithdrawalRecord,
 } from "@/lib/withdrawal-api";
 import { cn } from "@/lib/utils";
+import { ReferralCodeBadge } from "@/components/referral-code-badge";
+import { AccountLoginRequired } from "@/components/auth/account-login-required";
 
 export default function AccountPage() {
   const t = useTranslations("account");
@@ -57,6 +59,7 @@ export default function AccountPage() {
   const [editSubmitting, setEditSubmitting] = useState(false);
   const [editError, setEditError] = useState<string | null>(null);
   const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false);
+  const [referralCode, setReferralCode] = useState<string | null>(null);
 
   useEffect(() => {
     if (!user) return;
@@ -66,17 +69,28 @@ export default function AccountPage() {
       setOrdersLoading(true);
       setWithdrawalsLoading(true);
       try {
-        const [balance, orderRes, courseRes, withdrawRes] = await Promise.all([
-          fetchWalletBalance(),
-          fetchOrderList({ current: 1, row: 10 }),
-          fetchPurchasedCourses(),
-          fetchWithdrawalList()
-        ]);
+        const [balanceResult, orderResult, courseResult, withdrawResult] =
+          await Promise.allSettled([
+            fetchWalletBalance(),
+            fetchOrderList({ current: 1, row: 10 }),
+            fetchPurchasedCourses(),
+            fetchWithdrawalList(),
+          ]);
         if (cancelled) return;
-        setWalletBalance(balance);
-        setOrders(orderRes.list);
-        setPurchasedCourses(courseRes);
-        setWithdrawals(withdrawRes.list);
+        if (balanceResult.status === "fulfilled") {
+          setWalletBalance(balanceResult.value);
+        }
+        if (orderResult.status === "fulfilled") {
+          setOrders(orderResult.value.list);
+        }
+        if (courseResult.status === "fulfilled") {
+          setPurchasedCourses(courseResult.value);
+        } else {
+          console.error(courseResult.reason);
+        }
+        if (withdrawResult.status === "fulfilled") {
+          setWithdrawals(withdrawResult.value.list);
+        }
       } catch (e) {
         console.error(e);
       } finally {
@@ -91,6 +105,21 @@ export default function AccountPage() {
     return () => { cancelled = true; };
   }, [user]);
 
+  useEffect(() => {
+    if (!user?.email) return;
+    let cancelled = false;
+    void tryFetchCustomUserInfo().then((info) => {
+      if (cancelled || !info) return;
+      if (info.referralCode) setReferralCode(info.referralCode);
+      if (info.levelLabel && !user.levelLabel) {
+        updateProfile({ levelLabel: info.levelLabel });
+      }
+    });
+    return () => { cancelled = true; };
+    // 仅登录用户变化时拉取,避免 updateProfile 触发重复请求
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [user?.email, user?.levelLabel]);
+
   async function openBalanceDetail() {
     setBalanceDetailOpen(true);
     setBalanceDetailLoading(true);
@@ -193,14 +222,7 @@ export default function AccountPage() {
   if (!isReady) return <div className="min-h-screen bg-[#050b14] flex items-center justify-center text-slate-500">加载中...</div>;
 
   if (!user) {
-    return (
-      <div className="min-h-screen bg-[#050b14] flex items-center justify-center px-4">
-        <div className="w-full max-w-md rounded-[2.5rem] border border-white/10 bg-white/5 p-10 text-center backdrop-blur-2xl">
-          <h2 className="text-2xl font-bold text-white mb-6">您尚未登录</h2>
-          <Link href="/auth/login" className="inline-block rounded-full bg-[#f3deae] px-10 py-4 text-sm font-bold text-[#5c461a] shadow-lg">立即登录</Link>
-        </div>
-      </div>
-    );
+    return <AccountLoginRequired />;
   }
 
   return (
@@ -217,9 +239,14 @@ export default function AccountPage() {
           <div className="flex flex-col gap-8 md:flex-row md:items-center md:justify-between">
             <div>
               <p className="text-xs font-bold tracking-[0.3em] text-[#b89458] mb-2 uppercase">User Control Center</p>
-              <h1 className="font-serif text-3xl font-bold text-white md:text-5xl tracking-wide">
-                {t("welcome", { name: user.name })}
-              </h1>
+              <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
+                <h1 className="font-serif text-3xl font-bold text-white md:text-5xl tracking-wide">
+                  {t("welcome", { name: user.name })}
+                </h1>
+                {referralCode ? (
+                  <ReferralCodeBadge code={referralCode} />
+                ) : null}
+              </div>
               <p className="mt-3 text-sm text-slate-400">欢迎回来,在这里掌控您的个人资产与学习进程。</p>
             </div>
             <Link
@@ -381,10 +408,10 @@ export default function AccountPage() {
                             <p className="text-xs font-medium text-slate-500 mt-1">NO: {o.serial}</p>
                           </div>
                         </div>
-                        <div className="flex items-center justify-between sm:justify-end gap-6">
-                          <div className="text-right">
+                        <div className="flex shrink-0 items-center justify-end gap-4 sm:gap-6">
+                          <div className="flex flex-col items-end gap-1.5">
                             <p className="text-xl font-bold text-white tracking-tight">${o.amount}</p>
-                            <span className={cn("inline-block mt-1 rounded-full border px-3 py-0.5 text-[10px] font-bold uppercase tracking-widest", getStatusStyle(o.status))}>
+                            <span className={cn("rounded-full border px-3 py-1 text-[10px] font-bold", getStatusStyle(o.status))}>
                               {getOrderStatusLabel(o.status)}
                             </span>
                           </div>
@@ -426,9 +453,9 @@ export default function AccountPage() {
                             <p className="text-xs font-medium text-slate-500 mt-1">流水号: {w.serial}</p>
                           </div>
                         </div>
-                        <div className="text-right">
+                        <div className="flex shrink-0 flex-col items-end gap-1.5">
                           <p className="text-xl font-bold text-white tracking-tight">${w.amount}</p>
-                          <span className={cn("inline-block mt-1 rounded-full border px-3 py-0.5 text-[10px] font-bold uppercase tracking-widest", getStatusStyle(w.status))}>
+                          <span className={cn("rounded-full border px-3 py-1 text-[10px] font-bold", getStatusStyle(w.status))}>
                             {getWithdrawalStatusLabel(w.status)}
                           </span>
                         </div>

+ 2 - 8
src/app/[locale]/account/purchased-courses/page.tsx

@@ -8,6 +8,7 @@ import {
   fetchPurchasedCourses,
   type PurchasedCourse,
 } from "@/lib/goods-order-api";
+import { AccountLoginRequired } from "@/components/auth/account-login-required";
 import {
   fetchRewardQuestion,
   submitRewardQuestionAnswer,
@@ -94,14 +95,7 @@ export default function PurchasedCoursesPage() {
   if (!isReady) return <div className="min-h-screen bg-[#050b14] flex items-center justify-center text-slate-500"><Loader2 className="animate-spin" /></div>;
 
   if (!user) {
-    return (
-      <div className="min-h-screen bg-[#050b14] flex items-center justify-center px-4 font-sans">
-        <div className="w-full max-w-md rounded-[2.5rem] border border-white/10 bg-white/5 p-10 text-center backdrop-blur-2xl">
-          <h2 className="text-2xl font-bold text-white mb-6">您尚未登录</h2>
-          <Link href="/auth/login" className="inline-block rounded-full bg-[#f3deae] px-10 py-4 text-sm font-bold text-[#5c461a] shadow-lg">立即登录</Link>
-        </div>
-      </div>
-    );
+    return <AccountLoginRequired />;
   }
 
   return (

+ 2 - 13
src/app/[locale]/account/withdrawals/page.tsx

@@ -10,6 +10,7 @@ import {
   type WithdrawalRecord,
 } from "@/lib/withdrawal-api";
 import { cn } from "@/lib/utils";
+import { AccountLoginRequired } from "@/components/auth/account-login-required";
 
 const PAGE_SIZE = 10;
 
@@ -58,19 +59,7 @@ export default function AccountWithdrawalsPage() {
     );
 
   if (!user) {
-    return (
-      <div className="min-h-screen bg-[#050b14] flex items-center justify-center px-4 font-sans">
-        <div className="w-full max-w-md rounded-[2.5rem] border border-white/10 bg-white/5 p-10 text-center backdrop-blur-2xl">
-          <h2 className="text-2xl font-bold text-white mb-6">您尚未登录</h2>
-          <Link
-            href="/auth/login"
-            className="inline-block rounded-full bg-[#f3deae] px-10 py-4 text-sm font-bold text-[#5c461a] shadow-lg"
-          >
-            立即登录
-          </Link>
-        </div>
-      </div>
-    );
+    return <AccountLoginRequired />;
   }
 
   const hasPrev = page > 1;

+ 36 - 5
src/app/[locale]/checkout/checkout-form.tsx

@@ -49,6 +49,7 @@ export function CheckoutForm() {
   const [bankOptionsLoading, setBankOptionsLoading] = useState(false);
   const [bankOptionsError, setBankOptionsError] = useState<string | null>(null);
   const [selectedBankCode, setSelectedBankCode] = useState("");
+  const [goodsNum, setGoodsNum] = useState(1);
   const [depositAmount, setDepositAmount] = useState("");
   const [, setMsg] = useState<string | null>(null);
   const [submitting, setSubmitting] = useState(false);
@@ -126,8 +127,11 @@ export function CheckoutForm() {
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
     if (!user) return;
-    const pricedByRate = payAmountWithChannelRate(displayCoursePrice, payRateMultiplier);
-    // 与「支付金额(USDT)」展示同源:始终提交标价×通道汇率后的金额,不依赖输入框字符串解析。
+    const pricedByRate = payAmountWithChannelRate(
+      displayCoursePrice * goodsNum,
+      payRateMultiplier,
+    );
+    // 与「支付金额(USDT)」展示同源:始终提交标价×数量×通道汇率后的金额,不依赖输入框字符串解析。
     const finalAmount = pricedByRate;
     if (!(finalAmount > 0)) {
       setMsg("支付金额无效,请重新选择支付通道或付款方式。");
@@ -151,7 +155,7 @@ export function CheckoutForm() {
         amount: finalAmount,
         bankCode: selectedBankCode || undefined,
         code: selectedChannel?.code || undefined,
-        goodIds: [goodsId],
+        goodsDetails: [{ goodsId, goodsNum }],
         payName,
         payPhone,
       });
@@ -190,6 +194,7 @@ export function CheckoutForm() {
     setIsPaymentModalOpen(false);
     setSelectedChannelId("");
     setSelectedBankCode("");
+    setGoodsNum(1);
     setDepositAmount("");
   };
 
@@ -233,10 +238,10 @@ export function CheckoutForm() {
   useEffect(() => {
     if (!isPaymentModalOpen || !selectedChannelId) return;
     if (!(displayCoursePrice > 0)) return;
-    const amount = payAmountWithChannelRate(displayCoursePrice, payRateMultiplier);
+    const amount = payAmountWithChannelRate(displayCoursePrice * goodsNum, payRateMultiplier);
     if (!(amount > 0)) return;
     setDepositAmount(String(amount));
-  }, [isPaymentModalOpen, selectedChannelId, displayCoursePrice, payRateMultiplier]);
+  }, [isPaymentModalOpen, selectedChannelId, displayCoursePrice, goodsNum, payRateMultiplier]);
 
   return (
     <form id={checkoutFormId} onSubmit={handleSubmit} className="space-y-8">
@@ -451,6 +456,32 @@ export function CheckoutForm() {
               </div>
             ) : null}
 
+            <div className="mt-4">
+              <label className="text-sm font-medium">商品数量</label>
+              <div className="mt-1 flex h-11 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-slate-100">
+                <button
+                  type="button"
+                  onClick={() => setGoodsNum((prev) => Math.max(1, prev - 1))}
+                  disabled={goodsNum <= 1}
+                  className="ui-interactive-btn flex w-11 shrink-0 items-center justify-center border-r border-[var(--border)] text-base text-slate-500 transition hover:bg-slate-200/50 disabled:cursor-not-allowed disabled:opacity-40"
+                  aria-label="减少数量"
+                >
+                  −
+                </button>
+                <div className="flex flex-1 items-center justify-center text-sm font-semibold tabular-nums text-slate-700">
+                  {goodsNum}
+                </div>
+                <button
+                  type="button"
+                  onClick={() => setGoodsNum((prev) => prev + 1)}
+                  className="ui-interactive-btn flex w-11 shrink-0 items-center justify-center border-l border-[var(--border)] text-base text-slate-500 transition hover:bg-slate-200/50"
+                  aria-label="增加数量"
+                >
+                  +
+                </button>
+              </div>
+            </div>
+
             <div className="mt-4">
               <label className="text-sm font-medium">
                 支付金额({selectedBank?.currency ?? "USDT"})

+ 26 - 7
src/app/api/xfgpay/pay/[...segments]/route.ts

@@ -6,8 +6,13 @@ function getRemittanceTarget(): string {
   return "http://192.168.0.33:8504";
 }
 
+type PayGoodsDetail = {
+  goodsId?: string | number;
+  goodsNum?: number | string;
+};
+
 type PayRequestBody = {
-  goodIds?: string[] | string;
+  goodsDetails?: PayGoodsDetail[];
   payName?: string;
   payPhone?: string;
   amount?: number | string;
@@ -29,11 +34,25 @@ export async function POST(
   }
 
   const body = (await request.json()) as PayRequestBody;
-  const goodIds = Array.isArray(body.goodIds)
-    ? body.goodIds.map((v) => String(v)).filter(Boolean)
-    : typeof body.goodIds === "string" && body.goodIds.trim()
-      ? [body.goodIds.trim()]
-      : [];
+  const goodsDetails = Array.isArray(body.goodsDetails)
+    ? body.goodsDetails
+        .map((item) => {
+          const goodsId =
+            item?.goodsId != null && String(item.goodsId).trim()
+              ? String(item.goodsId).trim()
+              : "";
+          const goodsNumRaw = item?.goodsNum;
+          const goodsNum =
+            typeof goodsNumRaw === "number" && Number.isFinite(goodsNumRaw)
+              ? goodsNumRaw
+              : typeof goodsNumRaw === "string" && goodsNumRaw.trim()
+                ? Number(goodsNumRaw)
+                : NaN;
+          if (!goodsId || !Number.isFinite(goodsNum) || goodsNum <= 0) return null;
+          return { goodsId, goodsNum };
+        })
+        .filter((item): item is { goodsId: string; goodsNum: number } => item !== null)
+    : [];
 
   const amountRaw = body.amount;
   const amount =
@@ -44,7 +63,7 @@ export async function POST(
         : undefined;
 
   const upstreamBody: Record<string, unknown> = {
-    goodIds,
+    goodsDetails,
     payName: typeof body.payName === "string" ? body.payName : "",
     payPhone: typeof body.payPhone === "string" ? body.payPhone : "",
   };

+ 272 - 9
src/app/globals.css

@@ -255,16 +255,279 @@ body {
   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;
+  overflow: visible;
 }
 
-.auth-modal-body::-webkit-scrollbar {
-  display: none;
-  width: 0;
-  height: 0;
+/* 认证页场景:深蓝金调背景 + 玻璃卡片 */
+.auth-page-scene {
+  background-color: #0b1528;
+}
+
+.auth-page-base {
+  background:
+    radial-gradient(ellipse 120% 80% at 50% 110%, rgb(20 52 86 / 0.55), transparent 58%),
+    radial-gradient(ellipse 70% 45% at 12% 8%, rgb(36 72 118 / 0.35), transparent 52%),
+    linear-gradient(175deg, #162a45 0%, #101e34 38%, #0c1628 68%, #08111f 100%);
+}
+
+.auth-page-mesh {
+  background:
+    conic-gradient(from 210deg at 18% 22%, rgb(56 99 150 / 0.14), transparent 28%),
+    conic-gradient(from 30deg at 88% 12%, rgb(201 169 110 / 0.1), transparent 32%),
+    linear-gradient(125deg, transparent 42%, rgb(90 130 180 / 0.06) 50%, transparent 58%);
+  opacity: 0.9;
+}
+
+.auth-page-horizon {
+  height: 46%;
+  background: linear-gradient(
+    180deg,
+    transparent 0%,
+    rgb(24 58 92 / 0.22) 55%,
+    rgb(12 32 56 / 0.45) 100%
+  );
+  mask-image: linear-gradient(180deg, transparent, black 35%);
+}
+
+.auth-page-arcs {
+  display: block;
+}
+
+.auth-arc {
+  position: absolute;
+  left: 50%;
+  border-radius: 50%;
+  border: 1px solid rgb(201 169 110 / 0.1);
+  transform: translateX(-50%);
+  box-shadow: 0 0 60px rgb(184 148 88 / 0.04);
+}
+
+.auth-arc--1 {
+  bottom: -38%;
+  width: min(140vw, 920px);
+  height: min(140vw, 920px);
+  border-color: rgb(201 169 110 / 0.14);
+}
+
+.auth-arc--2 {
+  bottom: -46%;
+  width: min(168vw, 1100px);
+  height: min(168vw, 1100px);
+  border-color: rgb(90 130 180 / 0.12);
+}
+
+.auth-arc--3 {
+  bottom: -54%;
+  width: min(196vw, 1280px);
+  height: min(196vw, 1280px);
+  border-color: rgb(201 169 110 / 0.06);
+}
+
+.auth-page-beams {
+  background:
+    linear-gradient(
+      118deg,
+      transparent 30%,
+      rgb(243 222 174 / 0.045) 46%,
+      transparent 62%
+    ),
+    linear-gradient(
+      72deg,
+      transparent 58%,
+      rgb(120 170 220 / 0.05) 72%,
+      transparent 86%
+    );
+  opacity: 0.85;
+}
+
+.auth-page-grid {
+  background-image:
+    linear-gradient(rgb(243 222 174 / 0.035) 1px, transparent 1px),
+    linear-gradient(90deg, rgb(243 222 174 / 0.035) 1px, transparent 1px);
+  background-size: 48px 48px;
+  mask-image: radial-gradient(ellipse 78% 68% at 50% 40%, black 12%, transparent 74%);
+  opacity: 0.55;
+}
+
+.auth-orb {
+  position: absolute;
+  border-radius: 50%;
+  filter: blur(90px);
+  will-change: transform;
+}
+
+.auth-orb--blue {
+  top: 6%;
+  left: -8%;
+  width: min(480px, 52vw);
+  height: min(480px, 52vw);
+  background: rgb(42 88 140 / 0.38);
+  animation: auth-orb-drift-a 22s ease-in-out infinite alternate;
+}
+
+.auth-orb--gold {
+  top: 14%;
+  right: -6%;
+  width: min(440px, 48vw);
+  height: min(440px, 48vw);
+  background: rgb(184 148 88 / 0.22);
+  animation: auth-orb-drift-b 26s ease-in-out infinite alternate;
+}
+
+.auth-orb--teal {
+  bottom: 8%;
+  left: 50%;
+  width: min(560px, 62vw);
+  height: min(320px, 36vw);
+  transform: translateX(-50%);
+  background: rgb(18 72 96 / 0.35);
+  animation: auth-orb-drift-c 20s ease-in-out infinite alternate;
+}
+
+@keyframes auth-orb-drift-a {
+  from {
+    transform: translate(0, 0) scale(1);
+  }
+  to {
+    transform: translate(6%, 8%) scale(1.06);
+  }
+}
+
+@keyframes auth-orb-drift-b {
+  from {
+    transform: translate(0, 0) scale(1);
+  }
+  to {
+    transform: translate(-5%, 6%) scale(1.05);
+  }
+}
+
+@keyframes auth-orb-drift-c {
+  from {
+    transform: translate(-50%, 0) scale(1);
+  }
+  to {
+    transform: translate(-48%, -4%) scale(1.04);
+  }
+}
+
+.auth-page-noise {
+  opacity: 0.035;
+  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+  background-size: 180px 180px;
+}
+
+.auth-page-watermark {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  margin: 0;
+  transform: translate(-50%, -52%);
+  font-family: var(--font-serif), "Noto Serif SC", serif;
+  font-size: clamp(3.5rem, 14vw, 8rem);
+  font-weight: 700;
+  letter-spacing: 0.35em;
+  white-space: nowrap;
+  color: rgb(243 222 174 / 0.04);
+  user-select: none;
+  pointer-events: none;
+}
+
+.auth-page-vignette {
+  background:
+    radial-gradient(ellipse 90% 70% at 50% 45%, transparent 30%, rgb(6 12 22 / 0.5) 100%),
+    linear-gradient(180deg, rgb(8 14 24 / 0.35) 0%, transparent 22%, transparent 78%, rgb(5 10 18 / 0.55) 100%);
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .auth-orb {
+    animation: none;
+  }
+}
+
+.auth-page-embedded-base {
+  background: linear-gradient(155deg, rgb(22 38 62 / 0.95) 0%, rgb(10 18 32 / 0.98) 100%);
+}
+
+.auth-page-embedded-glow {
+  background:
+    radial-gradient(ellipse 90% 55% at 50% -10%, rgb(184 148 88 / 0.16), transparent 58%),
+    radial-gradient(ellipse 70% 50% at 100% 100%, rgb(42 88 140 / 0.2), transparent 55%);
+}
+
+.auth-card {
+  border: 1px solid rgb(184 148 88 / 0.22);
+  background: linear-gradient(
+    155deg,
+    rgb(22 32 52 / 0.94) 0%,
+    rgb(12 20 36 / 0.9) 48%,
+    rgb(8 14 26 / 0.92) 100%
+  );
+  box-shadow:
+    0 0 0 1px rgb(255 255 255 / 0.05) inset,
+    0 28px 60px -16px rgb(0 0 0 / 0.55),
+    0 0 100px rgb(184 148 88 / 0.07);
+  backdrop-filter: blur(20px);
+}
+
+.auth-card::before {
+  content: "";
+  position: absolute;
+  inset: 0 0 auto;
+  height: 1px;
+  border-radius: inherit;
+  background: linear-gradient(
+    90deg,
+    transparent 0%,
+    rgb(243 222 174 / 0.55) 50%,
+    transparent 100%
+  );
+  pointer-events: none;
+}
+
+.auth-card-glow {
+  background: radial-gradient(ellipse 80% 45% at 50% -5%, rgb(243 222 174 / 0.12), transparent 60%);
+}
+
+.auth-icon-badge::after {
+  content: "";
+  position: absolute;
+  inset: -6px;
+  border-radius: inherit;
+  background: radial-gradient(circle at 50% 0%, rgb(243 222 174 / 0.35), transparent 65%);
+  opacity: 0.6;
+  pointer-events: none;
+}
+
+.auth-header-rule {
+  width: 3rem;
+  height: 2px;
+  border-radius: 999px;
+  background: linear-gradient(90deg, transparent, rgb(201 169 110 / 0.85), transparent);
+}
+
+.auth-title {
+  background: linear-gradient(180deg, #faf6eb 0%, #e8d4a8 55%, #c9a96e 100%);
+  -webkit-background-clip: text;
+  background-clip: text;
+  color: transparent;
+}
+
+.auth-hints li {
+  position: relative;
+  padding-left: 0.75rem;
+  list-style: none;
+}
+
+.auth-hints li::before {
+  content: "";
+  position: absolute;
+  left: 0;
+  top: 0.55em;
+  height: 4px;
+  width: 4px;
+  border-radius: 999px;
+  background: rgb(201 169 110 / 0.65);
 }

+ 33 - 0
src/components/auth/account-login-required.tsx

@@ -0,0 +1,33 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { LogIn } from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+  AuthCard,
+  AuthHeader,
+  AuthIconBadge,
+  AuthPageScene,
+  authPrimaryBtnCls,
+} from "@/components/auth/auth-ui";
+
+export function AccountLoginRequired() {
+  const t = useTranslations("account");
+
+  return (
+    <AuthPageScene layout="fullscreen">
+      <AuthCard layout="fullscreen" className="text-center">
+        <AuthIconBadge icon={LogIn} layout="fullscreen" />
+        <AuthHeader
+          title={t("notLoggedInTitle")}
+          subtitle={t("loginRequired")}
+          layout="fullscreen"
+        />
+        <Link href="/auth/login" className={cn(authPrimaryBtnCls, "inline-flex items-center justify-center no-underline")}>
+          {t("loginNow")}
+        </Link>
+      </AuthCard>
+    </AuthPageScene>
+  );
+}

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

@@ -37,6 +37,7 @@ export function AuthFlowModal({ open, onClose, onAuthenticated }: Props) {
       open={open}
       className="max-w-md"
       zIndexClassName="z-[100]"
+      alignTop
       onBackdropClick={onClose}
     >
       {step === "login" ? (

+ 169 - 0
src/components/auth/auth-ui.tsx

@@ -0,0 +1,169 @@
+"use client";
+
+import { X, type LucideIcon } from "lucide-react";
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+export const authInputCls =
+  "mt-2 w-full rounded-xl border border-white/[0.12] bg-white/[0.04] px-4 py-3.5 text-sm text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] placeholder:text-slate-500 transition-all focus:border-[#c9a96e]/55 focus:bg-white/[0.06] focus:outline-none focus:ring-2 focus:ring-[#b89458]/25";
+
+export const authSecondaryBtnCls =
+  "shrink-0 rounded-xl border border-[#b89458]/25 bg-[#b89458]/10 px-4 text-xs font-bold text-[#f3deae] shadow-[inset_0_1px_0_rgba(243,222,174,0.12)] transition-colors hover:border-[#b89458]/40 hover:bg-[#b89458]/18 disabled:cursor-not-allowed disabled:opacity-50";
+
+export const authPrimaryBtnCls =
+  "mt-6 w-full rounded-2xl border border-[#f3deae]/30 bg-gradient-to-br from-[#f5e6c8] via-[#e8cf9a] to-[#c9a96e] py-4 text-sm font-bold text-[#4a3818] shadow-[0_12px_32px_rgba(184,148,88,0.28),inset_0_1px_0_rgba(255,255,255,0.45)] transition-all hover:brightness-105 hover:shadow-[0_16px_40px_rgba(184,148,88,0.35)] active:scale-[0.99] disabled:opacity-50 disabled:hover:brightness-100 disabled:active:scale-100";
+
+export const authLabelCls = "ml-1 text-sm font-semibold tracking-wide text-slate-200/90";
+
+export const authErrorCls =
+  "rounded-xl border border-rose-500/35 bg-rose-500/10 p-3 text-center text-sm font-medium text-rose-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]";
+
+export const authSuccessBannerCls =
+  "mb-6 flex items-center justify-center gap-2 rounded-xl border border-emerald-400/30 bg-emerald-500/10 p-3 text-sm font-medium text-emerald-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]";
+
+export const authFooterLinkCls =
+  "font-bold text-[#f3deae] underline decoration-[#b89458]/40 decoration-1 underline-offset-4 transition-colors hover:text-white hover:decoration-[#f3deae]/70";
+
+export const authHintsCls =
+  "auth-hints mt-3 space-y-1.5 rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 text-xs font-medium text-slate-400/95 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]";
+
+export function AuthBackdrop({ compact = false }: { compact?: boolean }) {
+  if (compact) {
+    return (
+      <div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]" aria-hidden>
+        <div className="auth-page-embedded-base absolute inset-0 rounded-[inherit]" />
+        <div className="auth-page-embedded-glow absolute inset-0 rounded-[inherit]" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden>
+      <div className="auth-page-base absolute inset-0" />
+      <div className="auth-page-mesh absolute inset-0" />
+      <div className="auth-page-horizon absolute inset-x-0 bottom-0" />
+      <div className="auth-page-arcs absolute inset-0">
+        <span className="auth-arc auth-arc--1" />
+        <span className="auth-arc auth-arc--2" />
+        <span className="auth-arc auth-arc--3" />
+      </div>
+      <div className="auth-page-beams absolute inset-0" />
+      <div className="auth-page-grid absolute inset-0" />
+      <div className="auth-orb auth-orb--blue" />
+      <div className="auth-orb auth-orb--gold" />
+      <div className="auth-orb auth-orb--teal" />
+      <div className="auth-page-noise absolute inset-0" />
+      <p className="auth-page-watermark">金策弘论社</p>
+      <div className="auth-page-vignette absolute inset-0" />
+    </div>
+  );
+}
+
+export function AuthPageScene({
+  layout,
+  children,
+}: {
+  layout: "fullscreen" | "embedded";
+  children: ReactNode;
+}) {
+  if (layout === "embedded") {
+    return (
+      <div className="relative w-full max-w-md">
+        <AuthBackdrop compact />
+        <div className="relative z-10">{children}</div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="auth-page-scene relative flex min-h-screen flex-col items-center justify-center overflow-x-hidden p-4 py-16 font-sans">
+      <AuthBackdrop />
+      <div className="relative z-10 w-full max-w-md">{children}</div>
+    </div>
+  );
+}
+
+export function AuthCard({
+  layout,
+  children,
+  className,
+}: {
+  layout: "fullscreen" | "embedded";
+  children: ReactNode;
+  className?: string;
+}) {
+  return (
+    <div
+      className={cn(
+        "auth-card auth-modal-body group relative overflow-hidden",
+        layout === "fullscreen" ? "rounded-[2.5rem] p-8 md:p-10" : "rounded-2xl p-6 sm:p-8",
+        className,
+      )}
+    >
+      <div className="auth-card-glow pointer-events-none absolute inset-0 rounded-[inherit]" aria-hidden />
+      <div className="relative z-[1]">{children}</div>
+    </div>
+  );
+}
+
+export function AuthIconBadge({
+  icon: Icon,
+  layout,
+}: {
+  icon: LucideIcon;
+  layout: "fullscreen" | "embedded";
+}) {
+  const large = layout === "fullscreen";
+  return (
+    <div className={cn("flex justify-center", large ? "mb-8" : "mb-6")}>
+      <div
+        className={cn(
+          "auth-icon-badge relative flex items-center justify-center rounded-2xl bg-gradient-to-br from-[#f8edd4] via-[#e8cf9a] to-[#c9a96e] text-[#4a3818] ring-1 ring-[#f3deae]/50",
+          large ? "h-16 w-16 shadow-[0_12px_40px_rgba(184,148,88,0.35)]" : "h-12 w-12 rounded-xl shadow-[0_8px_28px_rgba(184,148,88,0.28)]",
+        )}
+      >
+        <Icon size={large ? 28 : 22} className="translate-x-[2px]" strokeWidth={2.25} />
+      </div>
+    </div>
+  );
+}
+
+export function AuthHeader({
+  title,
+  subtitle,
+  layout,
+}: {
+  title: string;
+  subtitle?: string;
+  layout: "fullscreen" | "embedded";
+}) {
+  return (
+    <div className={cn("text-center", layout === "fullscreen" ? "mb-8" : "mb-6")}>
+      <div className="auth-header-rule mx-auto mb-5" aria-hidden />
+      <h1
+        className={cn(
+          "auth-title font-serif font-bold tracking-wide",
+          layout === "fullscreen" ? "text-2xl md:text-[1.65rem]" : "text-xl",
+        )}
+      >
+        {title}
+      </h1>
+      {subtitle ? (
+        <p className="mt-2.5 text-sm leading-relaxed text-slate-400/95">{subtitle}</p>
+      ) : null}
+    </div>
+  );
+}
+
+export function AuthCloseButton({ onClick }: { onClick: () => void }) {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className="absolute right-3 top-3 z-10 rounded-lg border border-white/10 bg-white/5 p-2 text-slate-400 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/10 hover:text-white"
+      aria-label="关闭"
+    >
+      <X size={18} />
+    </button>
+  );
+}

+ 61 - 117
src/components/auth/forgot-password-form-panel.tsx

@@ -5,16 +5,23 @@ 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";
+import { KeyRound, CheckCircle2 } from "lucide-react";
+import {
+  AuthCard,
+  AuthCloseButton,
+  AuthHeader,
+  AuthIconBadge,
+  AuthPageScene,
+  authErrorCls,
+  authFooterLinkCls,
+  authInputCls,
+  authLabelCls,
+  authPrimaryBtnCls,
+} from "@/components/auth/auth-ui";
 
 type Props = {
   layout: "fullscreen" | "embedded";
   onDismiss?: () => void;
-  /** 弹窗内:返回登录步骤 */
   onBackToLogin?: () => void;
 };
 
@@ -24,6 +31,7 @@ export function ForgotPasswordFormPanel({ layout, onDismiss, onBackToLogin }: Pr
   const [done, setDone] = useState(false);
   const [submitting, setSubmitting] = useState(false);
   const [err, setErr] = useState<string | null>(null);
+  const embedded = layout === "embedded";
 
   async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
     e.preventDefault();
@@ -44,123 +52,59 @@ export function ForgotPasswordFormPanel({ layout, onDismiss, onBackToLogin }: Pr
     }
   }
 
-  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>
+  return (
+    <AuthPageScene layout={layout}>
+      <AuthCard layout={layout}>
+        {embedded && onDismiss ? <AuthCloseButton onClick={onDismiss} /> : null}
 
-      {!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>
+        <AuthIconBadge icon={KeyRound} layout={layout} />
+        <AuthHeader title={t("forgotTitle")} subtitle={t("forgotHint")} layout={layout} />
 
-          {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}
+        {!done ? (
+          <form onSubmit={onSubmit} className="space-y-5">
+            <div>
+              <label className={authLabelCls}>{t("email")}</label>
+              <input
+                type="email"
+                required
+                value={email}
+                onChange={(e) => setEmail(e.target.value)}
+                className={authInputCls}
+                placeholder="admin@example.com"
+              />
             </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>
-      )}
+            {err ? (
+              <div className={authErrorCls} role="alert">
+                {err}
+              </div>
+            ) : null}
 
-      <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>
+            <button type="submit" disabled={submitting} className={authPrimaryBtnCls}>
+              {submitting ? "发送中..." : t("resetBtn")}
+            </button>
+          </form>
         ) : (
-          <Link
-            href="/auth/login"
-            className="font-bold text-white transition-colors hover:text-[#f3deae]"
-          >
-            {t("toLogin")}
-          </Link>
+          <div className="rounded-2xl border border-emerald-400/30 bg-emerald-500/10 p-6 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
+            <CheckCircle2 size={40} className="mx-auto mb-4 text-emerald-400" />
+            <p className="text-base font-bold text-emerald-300">邮件已发送</p>
+            <p className="mt-2 text-sm text-emerald-400/80">请前往您的邮箱查收密码重置链接。</p>
+          </div>
         )}
-      </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>;
+        <p className="mt-8 text-center text-sm text-slate-400/90">
+          想起密码了?{" "}
+          {embedded && onBackToLogin ? (
+            <button type="button" onClick={onBackToLogin} className={authFooterLinkCls}>
+              {t("toLogin")}
+            </button>
+          ) : (
+            <Link href="/auth/login" className={authFooterLinkCls}>
+              {t("toLogin")}
+            </Link>
+          )}
+        </p>
+      </AuthCard>
+    </AuthPageScene>
+  );
 }

+ 99 - 155
src/components/auth/login-form-panel.tsx

@@ -5,8 +5,20 @@ 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";
+import { Eye, EyeOff, LogIn, CheckCircle2 } from "lucide-react";
+import {
+  AuthCard,
+  AuthCloseButton,
+  authFooterLinkCls,
+  AuthHeader,
+  AuthIconBadge,
+  AuthPageScene,
+  authErrorCls,
+  authInputCls,
+  authLabelCls,
+  authPrimaryBtnCls,
+  authSuccessBannerCls,
+} from "@/components/auth/auth-ui";
 
 export function safePostLoginPath(raw: string | null): string | null {
   if (!raw) return null;
@@ -15,9 +27,6 @@ export function safePostLoginPath(raw: string | null): string | 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 */
@@ -74,169 +83,104 @@ export function LoginFormPanel({
     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>
-      )}
+  return (
+    <AuthPageScene layout={layout}>
+      <AuthCard layout={layout}>
+        {layout === "embedded" && onDismiss ? <AuthCloseButton onClick={onDismiss} /> : null}
 
-      <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>
+        <AuthIconBadge icon={LogIn} layout={layout} />
+        <AuthHeader title={t("loginTitle")} subtitle={t("loginSubtitle")} layout={layout} />
 
-      {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}
+        {showRegisterSuccessBanner ? (
+          <div className={authSuccessBannerCls} 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">
+        <form onSubmit={onSubmit} className="space-y-5">
+          <div>
+            <label className={authLabelCls}>{t("email")}</label>
             <input
-              type={showPassword ? "text" : "password"}
+              type="email"
               required
-              value={password}
+              value={email}
               onChange={(e) => {
-                setPassword(e.target.value);
+                setEmail(e.target.value);
                 if (err) setErr(null);
               }}
-              autoComplete="current-password"
-              placeholder="输入密码"
-              className={`${inputCls} mt-0 pr-12`}
+              onBlur={() => setEmail((v) => v.trim())}
+              autoComplete="email"
+              placeholder="admin@example.com"
+              className={authInputCls}
             />
-            <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>
+            <label className={`${authLabelCls} flex justify-between`}>
+              <span>{t("password")}</span>
+              {embeddedAuthSwitch ? (
+                <button
+                  type="button"
+                  onClick={embeddedAuthSwitch.onForgot}
+                  className="font-medium text-[#c9a96e] transition-colors hover:text-[#f3deae]"
+                >
+                  {t("forgotTitle")}
+                </button>
+              ) : (
+                <Link
+                  href="/auth/forgot-password"
+                  className="font-medium text-[#c9a96e] 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={t("passwordPlaceholder")}
+                className={`${authInputCls} 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-[#f3deae] focus:outline-none focus:ring-1 focus:ring-[#b89458]"
+                aria-label={showPassword ? "隐藏密码" : "显示密码"}
+              >
+                {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
+              </button>
+            </div>
           </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>
+          {err ? (
+            <div className={authErrorCls} role="alert">
+              {err}
+            </div>
+          ) : null}
 
-      <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 type="submit" disabled={!canSubmit} className={authPrimaryBtnCls}>
+            {submitting ? `${t("loginBtn")}中...` : t("loginBtn")}
           </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>
-    );
-  }
+        </form>
 
-  return <div className="w-full max-w-md">{card}</div>;
+        <p className="mt-8 text-center text-sm text-slate-400/90">
+          {embeddedAuthSwitch ? (
+            <button type="button" onClick={embeddedAuthSwitch.onRegister} className={authFooterLinkCls}>
+              {t("toRegister")}
+            </button>
+          ) : (
+            <Link href="/auth/register" className={authFooterLinkCls}>
+              {t("toRegister")}
+            </Link>
+          )}
+        </p>
+      </AuthCard>
+    </AuthPageScene>
+  );
 }

+ 136 - 182
src/components/auth/register-form-panel.tsx

@@ -8,19 +8,29 @@ 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 { Eye, EyeOff, UserPlus, CheckCircle2 } from "lucide-react";
 import { cn } from "@/lib/utils";
+import {
+  AuthCard,
+  AuthCloseButton,
+  AuthHeader,
+  AuthIconBadge,
+  AuthPageScene,
+  authErrorCls,
+  authFooterLinkCls,
+  authHintsCls,
+  authInputCls,
+  authLabelCls,
+  authPrimaryBtnCls,
+  authSecondaryBtnCls,
+} from "@/components/auth/auth-ui";
 
 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;
 };
 
@@ -37,6 +47,7 @@ export function RegisterFormPanel({
   const [password, setPassword] = useState("");
   const [showPassword, setShowPassword] = useState(false);
   const [code, setCode] = useState("");
+  const [referralCode, setReferralCode] = useState("");
   const [err, setErr] = useState<string | null>(null);
   const [sendCooldown, setSendCooldown] = useState(0);
   const [sendingCode, setSendingCode] = useState(false);
@@ -92,6 +103,7 @@ export function RegisterFormPanel({
       password,
       name: "学员",
       code: code.trim(),
+      referralCode: referralCode.trim() || undefined,
     });
     if (!r.ok) {
       if (r.message) setErr(r.message);
@@ -108,21 +120,17 @@ export function RegisterFormPanel({
   };
 
   if (registerSuccess) {
-    const successCard = (
+    const successBody = (
       <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">
+        <div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full border border-emerald-400/25 bg-emerald-500/10 text-emerald-400 shadow-[0_0_40px_rgba(16,185,129,0.15)]">
           <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">
+        <h1 className="auth-title mb-2 font-serif text-2xl font-bold">{t("registerSuccessTitle")}</h1>
+        <p className="text-sm leading-relaxed text-slate-400/95">
           {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"
-          >
+          <button type="button" onClick={onRegisterComplete} className={cn(authPrimaryBtnCls, "mt-6")}>
             {t("loginBtn")}
           </button>
         ) : null}
@@ -131,201 +139,147 @@ export function RegisterFormPanel({
 
     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>
+        <AuthPageScene layout="fullscreen">
+          <AuthCard layout="fullscreen" className="text-center">
+            {successBody}
+          </AuthCard>
+        </AuthPageScene>
       );
     }
 
     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>
+        {onDismiss ? <AuthCloseButton onClick={onDismiss} /> : null}
+        <AuthCard layout="embedded" className="text-center">
+          {successBody}
+        </AuthCard>
       </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}
+  return (
+    <AuthPageScene layout={layout}>
+      <AuthCard layout={layout}>
+        {embedded && onDismiss ? <AuthCloseButton onClick={onDismiss} /> : 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>
+        <AuthIconBadge icon={UserPlus} layout={layout} />
+        <AuthHeader title={t("registerTitle")} subtitle={t("registerEmailOnlyHint")} layout={layout} />
 
-      <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">
+        <form onSubmit={onSubmit} className="space-y-5">
+          <div>
+            <label className={authLabelCls}>{t("email")}</label>
             <input
+              type="email"
               required
-              value={code}
+              value={email}
               onChange={(e) => {
-                setCode(e.target.value.replace(/\D/g, "").slice(0, 6));
+                setEmail(e.target.value);
                 if (err) setErr(null);
               }}
-              className={cn(inputCls, "mt-0 flex-1")}
-              placeholder="000000"
-              inputMode="numeric"
-              autoComplete="one-time-code"
+              onBlur={() => setEmail((v) => v.trim())}
+              autoComplete="email"
+              className={authInputCls}
+              placeholder="admin@example.com"
             />
-            <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">
+          <div>
+            <label className={authLabelCls}>{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(authInputCls, "mt-0 flex-1")}
+                placeholder="000000"
+                inputMode="numeric"
+                autoComplete="one-time-code"
+              />
+              <button type="button" disabled={!canSendCode} onClick={onSendCode} className={authSecondaryBtnCls}>
+                {sendingCode ? `${t("sendCode")}...` : sendCooldown > 0 ? t("sendCodeCooldown", { sec: sendCooldown }) : t("sendCode")}
+              </button>
+            </div>
+          </div>
+
+          <div>
+            <label className={authLabelCls}>{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={cn(authInputCls, "mt-0 pr-12")}
+                placeholder={t("setPasswordPlaceholder")}
+              />
+              <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-[#f3deae] focus:outline-none focus:ring-1 focus:ring-[#b89458]"
+                aria-label={showPassword ? "隐藏密码" : "显示密码"}
+              >
+                {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
+              </button>
+            </div>
+            <ul className={authHintsCls}>
+              <li>{t("passwordRuleLen")}</li>
+              <li>{t("passwordRuleCase")}</li>
+              <li>{t("passwordRuleMix")}</li>
+            </ul>
+          </div>
+
+          <div>
+            <label className={authLabelCls}>
+              {t("referralCode")}
+              <span className="ml-1.5 font-normal text-slate-500">({t("optional")})</span>
+            </label>
             <input
-              type={showPassword ? "text" : "password"}
-              required
-              autoComplete="new-password"
-              value={password}
+              type="text"
+              value={referralCode}
               onChange={(e) => {
-                setPassword(e.target.value);
+                setReferralCode(e.target.value);
                 if (err) setErr(null);
               }}
-              className={`${inputCls} mt-0 pr-12`}
-              placeholder="设置密码"
+              onBlur={() => setReferralCode((v) => v.trim())}
+              autoComplete="off"
+              className={authInputCls}
+              placeholder={t("referralCodePlaceholder")}
             />
-            <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}
+          {err ? (
+            <div className={authErrorCls} 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>
+          <button type="submit" disabled={!canSubmit} className={authPrimaryBtnCls}>
+            {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>
+        <p className="mt-8 text-center text-sm text-slate-400/90">
+          {embedded && onBackToLogin ? (
+            <>
+              已有账户?{" "}
+              <button type="button" onClick={onBackToLogin} className={authFooterLinkCls}>
+                {t("loginBtn")}
+              </button>
+            </>
+          ) : (
+            <Link href="/auth/login" className={authFooterLinkCls}>
+              {t("toLogin")}
+            </Link>
+          )}
+        </p>
+      </AuthCard>
+    </AuthPageScene>
   );
-
-  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>;
 }

+ 296 - 0
src/components/level-label-badge.tsx

@@ -0,0 +1,296 @@
+import { IconSpark } from "@/components/icons";
+import { cn } from "@/lib/utils";
+import {
+  getLevelLabelText,
+  LEVEL_ROMAN,
+  LEVEL_THEME,
+  type UserLevel,
+} from "@/lib/user-level";
+
+type BadgeVariant = "dark" | "light" | "header";
+
+function avatarInitial(name: string): string {
+  const trimmed = name.trim();
+  if (!trimmed) return "?";
+  const first = Array.from(trimmed)[0] ?? "?";
+  return /[a-z]/i.test(first) ? first.toUpperCase() : first;
+}
+
+const OUTER_SIZE = {
+  xs: "h-6 w-6",
+  sm: "h-7 w-7",
+  md: "h-8 w-8",
+  header: "h-9 w-9",
+  lg: "h-11 w-11",
+} as const;
+
+const AVATAR_TEXT = {
+  xs: "text-[9px]",
+  sm: "text-[10px]",
+  md: "text-[11px]",
+  header: "text-xs",
+  lg: "text-sm",
+} as const;
+
+function Medallion({
+  level,
+  size,
+  variant,
+  avatarName,
+}: {
+  level: UserLevel;
+  size: keyof typeof OUTER_SIZE;
+  variant: BadgeVariant;
+  avatarName?: string;
+}) {
+  const theme = LEVEL_THEME[level];
+  const isHeader = variant === "header";
+  const isDark = variant === "dark";
+  const innerBg = isDark ? "bg-[#0c1018]" : "bg-white";
+  const showRoman = size === "lg" || size === "header";
+
+  return (
+    <span
+      className={cn(
+        "relative flex shrink-0 items-center justify-center rounded-full bg-gradient-to-br p-[1.5px]",
+        OUTER_SIZE[size],
+        theme.ring,
+        isHeader
+          ? cn("shadow-md", theme.glowLight)
+          : cn("shadow-lg", theme.glow),
+      )}
+    >
+      <span
+        className={cn(
+          "flex h-full w-full items-center justify-center overflow-hidden rounded-full ring-1 ring-inset",
+          avatarName
+            ? "bg-gradient-to-br from-violet-600 to-purple-700 text-white font-semibold shadow-inner"
+            : innerBg,
+          isDark ? "ring-[#f3deae]/15" : "ring-amber-200/60",
+        )}
+      >
+        {avatarName ? (
+          <span className={AVATAR_TEXT[size]}>{avatarInitial(avatarName)}</span>
+        ) : (
+          <IconSpark
+            className={cn(
+              {
+                xs: "h-2.5 w-2.5",
+                sm: "h-3 w-3",
+                md: "h-3.5 w-3.5",
+                header: "h-3.5 w-3.5",
+                lg: "h-4 w-4",
+              }[size],
+              theme.icon,
+            )}
+            aria-hidden
+          />
+        )}
+      </span>
+      {showRoman ? (
+        <span
+          className={cn(
+            "absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-bold leading-none ring-1",
+            isHeader
+              ? cn(
+                  "bg-gradient-to-br from-[#faf6ee] to-[#f0e0b8] text-[#5c461a] ring-amber-300/70 shadow-sm font-bold",
+                  theme.ring,
+                )
+              : cn("bg-gradient-to-br text-[#5c461a] ring-[#f3deae]/40", theme.ring),
+          )}
+        >
+          {LEVEL_ROMAN[level]}
+        </span>
+      ) : null}
+    </span>
+  );
+}
+
+function FullBadge({
+  level,
+  variant,
+  size,
+  avatarName,
+  className,
+}: {
+  level: UserLevel;
+  variant: BadgeVariant;
+  size: "lg" | "header";
+  avatarName?: string;
+  className?: string;
+}) {
+  const isHeader = variant === "header";
+  const isDark = variant === "dark";
+  const theme = LEVEL_THEME[level];
+  const label = getLevelLabelText(level);
+  const compact = size === "header";
+
+  return (
+    <span
+      className={cn(
+        "relative inline-flex shrink-0 items-center overflow-hidden rounded-2xl border",
+        compact ? "gap-2.5 px-2.5 py-1.5" : "gap-3.5 px-4 py-2.5",
+        isHeader
+          ? cn(
+              "border-slate-200/90 bg-gradient-to-br from-white via-[#fefcf8] to-[#faf6ee]",
+              "shadow-[0_1px_0_rgba(15,23,42,0.04),0_4px_16px_rgba(15,23,42,0.05)]",
+              "ring-1 ring-amber-100/70",
+              theme.glowLight,
+            )
+          : isDark
+            ? cn(
+                "border-[#d4bc82]/25 bg-gradient-to-br from-[#141820]/90 via-[#0e1218]/95 to-[#0a0d12]/90",
+                "shadow-xl backdrop-blur-md",
+                theme.glow,
+              )
+            : cn(
+                "border-[#d4bc82]/35 bg-gradient-to-br from-white via-[#faf6ee] to-[#f5efe3]",
+                "shadow-xl backdrop-blur-md",
+                theme.glowLight,
+              ),
+        className,
+      )}
+    >
+      <span
+        className={cn(
+          "pointer-events-none absolute inset-0 bg-gradient-to-r",
+          isHeader || !isDark ? theme.sheenLight : theme.sheen,
+          isHeader ? "opacity-50" : "opacity-90",
+        )}
+      />
+      <span
+        className={cn(
+          "pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent to-transparent",
+          isHeader ? "via-amber-300/35" : "via-[#f3deae]/40",
+        )}
+      />
+
+      <Medallion
+        level={level}
+        size={size}
+        variant={variant}
+        avatarName={avatarName}
+      />
+
+      <span className="relative flex flex-col leading-none">
+        <span
+          className={cn(
+            "font-semibold uppercase tracking-[0.24em]",
+            compact ? "mb-0.5 text-[8px]" : "mb-1.5 text-[9px]",
+            isHeader
+              ? "text-[#8a6d3b]"
+              : isDark
+                ? "text-[#d4bc82]/55"
+                : "text-[#a8864a]/70",
+          )}
+        >
+          会员等级
+        </span>
+        <span
+          className={cn(
+            "font-serif tracking-[0.1em]",
+            compact ? "text-[15px] font-bold" : "text-lg font-semibold",
+            isHeader
+              ? cn("bg-gradient-to-r bg-clip-text text-transparent", theme.textOnLight)
+              : cn("bg-gradient-to-r bg-clip-text text-transparent", isDark ? theme.text : theme.textOnLight),
+          )}
+        >
+          {label}
+        </span>
+      </span>
+
+      <span
+        className={cn(
+          "relative ml-0.5 hidden w-px sm:block",
+          compact ? "h-8" : "h-9",
+          isHeader
+            ? "bg-gradient-to-b from-transparent via-amber-400/45 to-transparent"
+            : isDark
+              ? "bg-gradient-to-b from-transparent via-[#d4bc82]/30 to-transparent"
+              : "bg-gradient-to-b from-transparent via-[#d4bc82]/40 to-transparent",
+        )}
+        aria-hidden
+      />
+      <span
+        className={cn(
+          "relative hidden font-serif tracking-wider sm:block",
+          compact ? "text-lg font-medium" : "text-2xl font-light",
+          isHeader
+            ? "text-[#b89458]/65"
+            : isDark
+              ? "text-[#d4bc82]/25"
+              : "text-[#d4bc82]/35",
+        )}
+        aria-hidden
+      >
+        {LEVEL_ROMAN[level]}
+      </span>
+    </span>
+  );
+}
+
+export function LevelLabelBadge({
+  level,
+  variant = "dark",
+  size = "sm",
+  avatarName,
+  className,
+}: {
+  level: UserLevel;
+  variant?: BadgeVariant;
+  size?: "xs" | "sm" | "md" | "lg" | "header";
+  avatarName?: string;
+  className?: string;
+}) {
+  const resolvedVariant: BadgeVariant =
+    size === "header" && variant === "dark" ? "header" : variant;
+  const isDark = resolvedVariant === "dark";
+  const theme = LEVEL_THEME[level];
+  const label = getLevelLabelText(level);
+
+  if (size === "lg" || size === "header") {
+    return (
+      <FullBadge
+        level={level}
+        variant={resolvedVariant}
+        size={size}
+        avatarName={avatarName}
+        className={className}
+      />
+    );
+  }
+
+  return (
+    <span
+      className={cn(
+        "relative inline-flex shrink-0 items-center overflow-hidden border font-bold backdrop-blur-sm",
+        size === "xs" && "gap-1.5 rounded-full py-0.5 pl-0.5 pr-2",
+        size === "sm" && "gap-2 rounded-full py-0.5 pl-0.5 pr-2.5",
+        size === "md" && "gap-2 rounded-full py-1 pl-1 pr-3",
+        isDark
+          ? "border-[#d4bc82]/20 bg-gradient-to-r from-[#141820]/80 to-[#0e1218]/80"
+          : "border-slate-200/90 bg-gradient-to-r from-white to-[#faf6ee]",
+        isDark ? theme.glow : theme.glowLight,
+        className,
+      )}
+    >
+      <Medallion
+        level={level}
+        size={size}
+        variant={resolvedVariant}
+        avatarName={avatarName}
+      />
+      <span
+        className={cn(
+          "font-serif bg-gradient-to-r bg-clip-text text-transparent",
+          size === "xs" && "text-[10px] tracking-[0.15em]",
+          size === "sm" && "text-[10px] tracking-[0.18em]",
+          size === "md" && "text-[11px] tracking-[0.2em]",
+          isDark ? theme.text : theme.textOnLight,
+        )}
+      >
+        {label}
+      </span>
+    </span>
+  );
+}

+ 69 - 0
src/components/referral-code-badge.tsx

@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { Copy, Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export function ReferralCodeBadge({
+  code,
+  className,
+}: {
+  code: string;
+  className?: string;
+}) {
+  const [copied, setCopied] = useState(false);
+
+  async function handleCopy() {
+    const ok = await copyTextToClipboard(code);
+    if (!ok) return;
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  }
+
+  return (
+    <button
+      type="button"
+      onClick={handleCopy}
+      title="点击复制推荐码"
+      className={cn(
+        "inline-flex shrink-0 items-center gap-2 rounded-full border border-[#f3deae]/40 bg-[#f3deae]/10 px-3.5 py-1 text-xs font-bold tracking-wide text-[#f3deae] transition hover:bg-[#f3deae]/20 focus:outline-none focus:ring-2 focus:ring-[#f3deae]/40",
+        className,
+      )}
+    >
+      <span className="text-[#f3deae]/70">推荐码</span>
+      <span className="font-mono">{code}</span>
+      {copied ? (
+        <Check size={14} className="text-emerald-400" aria-hidden />
+      ) : (
+        <Copy size={14} className="opacity-60" aria-hidden />
+      )}
+    </button>
+  );
+}
+
+/** Clipboard API 仅在安全上下文可用;局域网 IP + HTTP 需 execCommand 回退 */
+async function copyTextToClipboard(text: string): Promise<boolean> {
+  if (navigator.clipboard?.writeText) {
+    try {
+      await navigator.clipboard.writeText(text);
+      return true;
+    } catch {
+      /* 非安全上下文(如 http://192.168.x.x)会在此失败 */
+    }
+  }
+
+  try {
+    const textarea = document.createElement("textarea");
+    textarea.value = text;
+    textarea.setAttribute("readonly", "");
+    textarea.style.position = "fixed";
+    textarea.style.left = "-9999px";
+    document.body.appendChild(textarea);
+    textarea.select();
+    const ok = document.execCommand("copy");
+    document.body.removeChild(textarea);
+    return ok;
+  } catch {
+    return false;
+  }
+}

+ 20 - 5
src/components/site-header.tsx

@@ -9,6 +9,7 @@ import type { UserSession } from "@/lib/auth-types";
 import { useAuth } from "@/providers/auth-provider";
 import { IconSpark } from "@/components/icons";
 import { fetchCustomUserInfo, updateUserInfo } from "@/lib/user-info-api";
+import { LevelLabelBadge } from "@/components/level-label-badge";
 
 function avatarInitial(name: string): string {
   const trimmed = name.trim();
@@ -56,10 +57,21 @@ function UserAvatarMenu({
         aria-expanded={menuOpen}
         aria-haspopup="menu"
         aria-label={t("userMenu")}
-        className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-violet-600 to-purple-700 text-sm font-semibold text-white shadow-md shadow-purple-600/30 ring-2 ring-white outline-none transition hover:from-violet-500 hover:to-purple-600 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
+        className="shrink-0 outline-none transition hover:brightness-[0.98] focus-visible:ring-2 focus-visible:ring-amber-300/60 focus-visible:ring-offset-2 rounded-2xl"
         onClick={() => setMenuOpen((o) => !o)}
       >
-        {avatarInitial(user.name)}
+        {user.levelLabel ? (
+          <LevelLabelBadge
+            level={user.levelLabel}
+            size="header"
+            variant="header"
+            avatarName={user.name}
+          />
+        ) : (
+          <span className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-violet-600 to-purple-700 text-sm font-semibold text-white shadow-md shadow-purple-600/30 ring-2 ring-white">
+            {avatarInitial(user.name)}
+          </span>
+        )}
       </button>
       {menuOpen && (
         <div
@@ -125,6 +137,7 @@ export function SiteHeader() {
   const [editSubmitting, setEditSubmitting] = useState(false);
   const [editError, setEditError] = useState<string | null>(null);
   const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false);
+
   const handleLogout = () => {
     logout();
     if (typeof window !== "undefined") {
@@ -472,9 +485,11 @@ export function SiteHeader() {
                     <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-violet-600 to-purple-700 text-sm font-semibold text-white shadow-md">
                       {avatarInitial(user.name)}
                     </div>
-                    <span className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">
-                      {user.name}
-                    </span>
+                    <div className="min-w-0 flex-1">
+                      <span className="truncate text-sm font-semibold text-slate-800">
+                        {user.name}
+                      </span>
+                    </div>
                   </div>
                   <Link
                     href="/account/change-password"

+ 5 - 1
src/components/ui/modal-shell.tsx

@@ -10,6 +10,8 @@ export function ModalShell({
   className,
   zIndexClassName = "z-[60]",
   onBackdropClick,
+  /** 内容偏高时从顶部留白展示,避免卡片内滚动 */
+  alignTop = false,
 }: {
   open: boolean;
   children: React.ReactNode;
@@ -17,6 +19,7 @@ export function ModalShell({
   zIndexClassName?: string;
   /** 点击遮罩时触发(子内容已阻止冒泡) */
   onBackdropClick?: () => void;
+  alignTop?: boolean;
 }) {
   const [mounted, setMounted] = useState(false);
 
@@ -33,7 +36,8 @@ export function ModalShell({
     <div
       role="presentation"
       className={cn(
-        "fixed inset-0 flex items-center justify-center px-4 transition-all duration-300",
+        "fixed inset-0 flex justify-center px-4 transition-all duration-300",
+        alignTop ? "items-start overflow-y-auto py-6 sm:py-10" : "items-center",
         zIndexClassName,
         open
           ? "pointer-events-auto bg-slate-900/55 opacity-100 backdrop-blur-[2px]"

+ 2 - 0
src/lib/auth-types.ts

@@ -13,12 +13,14 @@ export type ScholarshipPayout = {
 };
 
 import { SPEND_THRESHOLD_USD } from "./quiz-rules";
+import type { UserLevel } from "./user-level";
 
 export type UserSession = {
   email: string;
   name: string;
   phone?: string;
   identity?: string;
+  levelLabel?: UserLevel;
   /** ISO:首次累计消费达到门槛的时间(仅本地 Mock) */
   thresholdReachedAt: string | null;
   orders: MockOrder[];

+ 8 - 3
src/lib/checkout-api.ts

@@ -246,12 +246,17 @@ export function isTelegraphicStylePayRequestUrl(requestUrl: string): boolean {
   return n.includes("telegraphic");
 }
 
+export type PayGoodsDetail = {
+  goodsId: string;
+  goodsNum: number;
+};
+
 export async function submitXfgPayOrder(input: {
   requestUrl: string;
   amount: number;
   bankCode?: string;
   code?: string;
-  goodIds: string[];
+  goodsDetails: PayGoodsDetail[];
   payName: string;
   payPhone: string;
 }): Promise<{ raw: unknown; resultUrl: string | null }> {
@@ -269,7 +274,7 @@ export async function submitXfgPayOrder(input: {
       : `${payRequestPath}/1/${encodeURIComponent(amount)}/0`;
   const body = useBodyForPayParams
     ? {
-        goodIds: input.goodIds,
+        goodsDetails: input.goodsDetails,
         payName: input.payName,
         payPhone: input.payPhone,
         amount: input.amount,
@@ -277,7 +282,7 @@ export async function submitXfgPayOrder(input: {
         ...(input.bankCode ? { bankCode: input.bankCode } : {}),
       }
     : {
-        goodIds: input.goodIds,
+        goodsDetails: input.goodsDetails,
         payName: input.payName,
         payPhone: input.payPhone,
         /** 与路径中的金额一致:标价×通道 rate 后的实付额;避免后端只读 body 时落到商品原价。 */

+ 7 - 2
src/lib/register-api.ts

@@ -59,13 +59,18 @@ export async function registerWithEmail(input: {
   email: string;
   emailCode: string;
   password: string;
+  referralCode?: string;
 }): Promise<{ token: string | null }> {
-  const data = await apiPost<unknown>("/custom/register", {
+  const body: Record<string, string> = {
     country: input.country,
     name: input.name,
     email: input.email,
     emailCode: input.emailCode,
     password: input.password,
-  });
+  };
+  const referralCode = input.referralCode?.trim();
+  if (referralCode) body.referralCode = referralCode;
+
+  const data = await apiPost<unknown>("/custom/register", body);
   return { token: pickToken(data) };
 }

+ 8 - 2
src/lib/remittance-icon-url.ts

@@ -1,16 +1,22 @@
 import { getMediaOrigin } from "@/lib/env";
 
+function isIpHostname(hostname: string): boolean {
+  return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");
+}
+
 /**
  * 与 legacy Vue 中 `Host80 = ht + "//secure." + ho + "." + tld` 一致,用于汇款通道 icon 等相对路径。
  * 使用 `location.hostname`(不含端口),避免 `host` 里端口影响分段。
  *
- * 注意:`localhost`、单段主机名无法按该规则拼出 `secure.*`,会返回 `null`,此时请用
+ * 注意:`localhost`、局域网 IP、单段主机名无法按该规则拼出 `secure.*`,会返回 `null`,此时请用
  * {@link getRemittanceIconBase} 的回退(`NEXT_PUBLIC_MEDIA_ORIGIN` 或当前站 `origin`)。
  */
 export function getSecureDynamicHostBase(): string | null {
   if (typeof window === "undefined") return null;
+  const hostname = window.location.hostname;
+  if (isIpHostname(hostname)) return null;
   const ht = window.location.protocol;
-  const hostParts = window.location.hostname.split(".");
+  const hostParts = hostname.split(".");
   if (hostParts.length < 2) return null;
 
   let ho: string;

+ 20 - 0
src/lib/user-info-api.ts

@@ -1,10 +1,13 @@
 import { apiPost } from "@/lib/api";
+import { parseLevelLabel, type UserLevel } from "@/lib/user-level";
 
 export type CustomUserInfo = {
   name: string;
   phone: string;
   identity: string;
   email?: string;
+  levelLabel?: UserLevel;
+  referralCode?: string;
 };
 
 export async function updateUserInfo(input: {
@@ -31,11 +34,28 @@ export async function fetchCustomUserInfo(): Promise<CustomUserInfo> {
       ? (root.data as Record<string, unknown>)
       : root;
 
+  const levelRaw = data.levelLabel ?? root.levelLabel;
+  const levelLabel = parseLevelLabel(levelRaw) ?? undefined;
+
+  const referralCode =
+    pickString(data.referralCode ?? root.referralCode) || undefined;
+
   return {
     name: pickString(data.name ?? data.nickname ?? root.name ?? root.nickname),
     phone: pickString(data.phone ?? data.mobile ?? root.phone ?? root.mobile),
     identity: pickString(data.identity ?? data.idCard ?? root.identity ?? root.idCard),
     email: pickString(data.email ?? data.loginName ?? root.email ?? root.loginName) || undefined,
+    levelLabel,
+    referralCode,
   };
 }
 
+/** 静默拉取用户信息,接口失败时返回 null,避免阻断页面渲染 */
+export async function tryFetchCustomUserInfo(): Promise<CustomUserInfo | null> {
+  try {
+    return await fetchCustomUserInfo();
+  } catch {
+    return null;
+  }
+}
+

+ 108 - 0
src/lib/user-level.ts

@@ -0,0 +1,108 @@
+export type UserLevel = 1 | 2 | 3 | 4 | 5;
+
+export const LEVEL_LABELS: Record<UserLevel, string> = {
+  1: "初探",
+  2: "研习",
+  3: "学成",
+  4: "深耕",
+  5: "传誉",
+};
+
+export function parseLevelLabel(v: unknown): UserLevel | null {
+  const n = typeof v === "number" ? v : Number(v);
+  if (Number.isInteger(n) && n >= 1 && n <= 5) return n as UserLevel;
+  return null;
+}
+
+export function getLevelLabelText(level: UserLevel): string {
+  return LEVEL_LABELS[level];
+}
+
+export const LEVEL_ROMAN: Record<UserLevel, string> = {
+  1: "I",
+  2: "II",
+  3: "III",
+  4: "IV",
+  5: "V",
+};
+
+/** 等级越高,金色调越饱满 */
+export const LEVEL_THEME: Record<
+  UserLevel,
+  {
+    ring: string;
+    text: string;
+    textOnLight: string;
+    icon: string;
+    glow: string;
+    glowLight: string;
+    sheen: string;
+    sheenLight: string;
+  }
+> = {
+  1: {
+    ring: "from-[#c8bba8] via-[#a89880] to-[#8a7968]",
+    text: "from-[#e8ddd0] via-[#c8bba8] to-[#a89880]",
+    textOnLight: "from-[#4a3d30] via-[#6b5d4e] to-[#8a7968]",
+    icon: "text-[#c8bba8]",
+    glow: "shadow-[#a89880]/15",
+    glowLight: "shadow-[0_2px_10px_rgba(138,121,104,0.12)]",
+    sheen: "from-transparent via-[#c8bba8]/10 to-transparent",
+    sheenLight: "from-transparent via-[#f5efe6]/80 to-transparent",
+  },
+  2: {
+    ring: "from-[#dcc9a8] via-[#bfa070] to-[#9a7d52]",
+    text: "from-[#f0e2c8] via-[#dcc9a8] to-[#bfa070]",
+    textOnLight: "from-[#3d2e14] via-[#6b5428] to-[#9a7d52]",
+    icon: "text-[#dcc9a8]",
+    glow: "shadow-[#bfa070]/18",
+    glowLight: "shadow-[0_2px_12px_rgba(184,148,88,0.14)]",
+    sheen: "from-transparent via-[#dcc9a8]/12 to-transparent",
+    sheenLight: "from-transparent via-[#faf4ea]/90 to-transparent",
+  },
+  3: {
+    ring: "from-[#f0e0b8] via-[#d4bc82] to-[#b89458]",
+    text: "from-[#fff4dc] via-[#f0e0b8] to-[#d4bc82]",
+    textOnLight: "from-[#3d2e14] via-[#5c461a] to-[#926e3a]",
+    icon: "text-[#f0e0b8]",
+    glow: "shadow-[#d4bc82]/22",
+    glowLight: "shadow-[0_2px_14px_rgba(184,148,88,0.16)]",
+    sheen: "from-transparent via-[#f0e0b8]/14 to-transparent",
+    sheenLight: "from-transparent via-[#fff9f0]/90 to-transparent",
+  },
+  4: {
+    ring: "from-[#f5e8c0] via-[#e0c888] to-[#c8a050]",
+    text: "from-[#fff8e8] via-[#f5e8c0] to-[#d4b060]",
+    textOnLight: "from-[#3d2e14] via-[#5c461a] to-[#a8864a]",
+    icon: "text-[#f5e8c0]",
+    glow: "shadow-[#e0c888]/26",
+    glowLight: "shadow-[0_2px_14px_rgba(200,160,80,0.18)]",
+    sheen: "from-transparent via-[#f5e8c0]/16 to-transparent",
+    sheenLight: "from-transparent via-[#fffbf2]/90 to-transparent",
+  },
+  5: {
+    ring: "from-[#fff0d0] via-[#f0d890] to-[#d4a840]",
+    text: "from-[#fffbf0] via-[#fff0d0] to-[#e8c060]",
+    textOnLight: "from-[#3d2e14] via-[#5c461a] to-[#b89458]",
+    icon: "text-[#fff0d0]",
+    glow: "shadow-[#f0d890]/30",
+    glowLight: "shadow-[0_2px_16px_rgba(212,168,64,0.2)]",
+    sheen: "from-transparent via-[#fff0d0]/18 to-transparent",
+    sheenLight: "from-transparent via-[#fffdf5]/90 to-transparent",
+  },
+};
+
+export function getLevelBadgeStyle(
+  _level: UserLevel,
+  variant: "dark" | "light" = "dark",
+): string {
+  const base = "text-[#f3deae] border-[#f3deae]";
+  return variant === "light" ? `${base} bg-[#f3deae]/15` : `${base} bg-[#f3deae]/10`;
+}
+
+export const LEVEL_BADGE_SIZE_STYLES = {
+  xs: "px-2 py-0.5 text-[10px] tracking-wider",
+  sm: "px-2.5 py-0.5 text-[10px] tracking-widest",
+  md: "px-3 py-0.5 text-[11px] tracking-widest",
+  lg: "px-3.5 py-1 text-xs tracking-widest",
+} as const;

+ 9 - 2
src/providers/auth-provider.tsx

@@ -38,10 +38,13 @@ type AuthContextValue = {
     password: string;
     name: string;
     code: string;
+    referralCode?: string;
   }) => Promise<{ ok: boolean; error?: string; message?: string }>;
   logout: () => void;
   updateProfile: (
-    patch: Partial<Pick<UserSession, "name" | "phone" | "identity" | "scholarship">>,
+    patch: Partial<
+      Pick<UserSession, "name" | "phone" | "identity" | "scholarship" | "levelLabel">
+    >,
   ) => void;
   addMockOrder: (order: Omit<MockOrder, "id" | "createdAt">) => void;
   /** 本地演示:将门槛达成时间设为 181 天前,用于验证问答解锁 */
@@ -103,6 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
             name: info.name || base.name,
             phone: info.phone || base.phone,
             identity: info.identity || base.identity,
+            levelLabel: info.levelLabel,
           });
         } catch {
           persist(base);
@@ -123,6 +127,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
       password: string;
       name: string;
       code: string;
+      referralCode?: string;
     }) => {
       if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
         return { ok: false, error: "invalid_email" };
@@ -143,6 +148,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
           email: input.email.trim(),
           emailCode: input.code.trim(),
           password: input.password,
+          referralCode: input.referralCode?.trim() || undefined,
         });
         if (token) setApiAuthToken(token);
         const base = newUserSession(input.email.trim(), input.name.trim() || "学员");
@@ -154,6 +160,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
             name: info.name || base.name,
             phone: info.phone || base.phone,
             identity: info.identity || base.identity,
+            levelLabel: info.levelLabel,
           });
         } catch {
           persist(base);
@@ -175,7 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const updateProfile = useCallback(
     (
       patch: Partial<
-        Pick<UserSession, "name" | "phone" | "identity" | "scholarship">
+        Pick<UserSession, "name" | "phone" | "identity" | "scholarship" | "levelLabel">
       >,
     ) => {
       if (!user) return;