ALIEZ 1 неделя назад
Родитель
Сommit
faf1ef593f

+ 6 - 1
src/app/[locale]/account/orders/page.tsx

@@ -13,6 +13,7 @@ import {
 } from "@/lib/order-api";
 import { cn } from "@/lib/utils";
 import { AccountLoginRequired } from "@/components/auth/account-login-required";
+import { OrderItemsDisplay } from "@/components/order-items-display";
 
 const PAGE_SIZE = 10;
 
@@ -121,7 +122,11 @@ export default function AccountOrdersPage() {
                         <FileText size={22} />
                       </div>
                       <div>
-                        <p className="text-base font-bold text-white group-hover:text-[#f3deae] transition-colors">{o.details}</p>
+                        <OrderItemsDisplay
+                          items={o.items}
+                          fallback={o.details}
+                          nameClassName="text-base font-bold text-white group-hover:text-[#f3deae] transition-colors"
+                        />
                         <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs font-medium text-slate-500">
                           <span>NO: {o.serial}</span>
                           <span className="hidden sm:inline">•</span>

+ 9 - 20
src/app/[locale]/account/page.tsx

@@ -10,7 +10,7 @@ import {
   fetchBalanceRecordList,
   type BalanceRecord,
 } from "@/lib/balance-record-api";
-import { fetchCustomUserInfo, tryFetchCustomUserInfo, updateUserInfo } from "@/lib/user-info-api";
+import { fetchCustomUserInfo, updateUserInfo } from "@/lib/user-info-api";
 import {
   cancelOrder,
   fetchOrderList,
@@ -27,6 +27,7 @@ import {
   type WithdrawalRecord,
 } from "@/lib/withdrawal-api";
 import { cn } from "@/lib/utils";
+import { OrderItemsDisplay } from "@/components/order-items-display";
 import { ReferralCodeBadge } from "@/components/referral-code-badge";
 import { AccountLoginRequired } from "@/components/auth/account-login-required";
 
@@ -59,7 +60,6 @@ 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;
@@ -105,21 +105,6 @@ 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);
@@ -243,8 +228,8 @@ export default function AccountPage() {
                 <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} />
+                {user.referralCode ? (
+                  <ReferralCodeBadge code={user.referralCode} />
                 ) : null}
               </div>
               <p className="mt-3 text-sm text-slate-400">欢迎回来,在这里掌控您的个人资产与学习进程。</p>
@@ -404,7 +389,11 @@ export default function AccountPage() {
                             <FileText size={22} />
                           </div>
                           <div>
-                            <p className="text-[15px] font-bold text-white group-hover:text-[#f3deae] transition-colors">{o.details}</p>
+                            <OrderItemsDisplay
+                              items={o.items}
+                              fallback={o.details}
+                              nameClassName="text-[15px] font-bold text-white group-hover:text-[#f3deae] transition-colors"
+                            />
                             <p className="text-xs font-medium text-slate-500 mt-1">NO: {o.serial}</p>
                           </div>
                         </div>

+ 7 - 2
src/app/[locale]/checkout/checkout-form.tsx

@@ -167,6 +167,7 @@ export function CheckoutForm() {
         title: displayCourseTitle,
       });
       setIsPaymentModalOpen(false);
+      resetChannelSelection();
       setSubmitDialog({
         open: true,
         status: "success",
@@ -190,14 +191,18 @@ export function CheckoutForm() {
     }
   };
 
-  const handleClosePaymentModal = () => {
-    setIsPaymentModalOpen(false);
+  const resetChannelSelection = () => {
     setSelectedChannelId("");
     setSelectedBankCode("");
     setGoodsNum(1);
     setDepositAmount("");
   };
 
+  const handleClosePaymentModal = () => {
+    setIsPaymentModalOpen(false);
+    resetChannelSelection();
+  };
+
   const toggleGroup = (groupName: string) => {
     setExpandedGroups((prev) => ({
       ...prev,

+ 32 - 0
src/components/order-items-display.tsx

@@ -0,0 +1,32 @@
+import type { OrderDetailItem } from "@/lib/order-api";
+
+type OrderItemsDisplayProps = {
+  items: OrderDetailItem[];
+  fallback?: string;
+  nameClassName?: string;
+  metaClassName?: string;
+};
+
+export function OrderItemsDisplay({
+  items,
+  fallback = "-",
+  nameClassName = "text-[15px] font-bold text-white",
+  metaClassName = "text-xs font-medium text-slate-400",
+}: OrderItemsDisplayProps) {
+  if (items.length === 0) {
+    return <p className={nameClassName}>{fallback}</p>;
+  }
+
+  return (
+    <div className="space-y-1">
+      {items.map((item, index) => (
+        <div key={`${item.goodsName}-${index}`}>
+          <p className={nameClassName}>{item.goodsName}</p>
+          {item.goodsNum != null ? (
+            <p className={metaClassName}>数量:{item.goodsNum}</p>
+          ) : null}
+        </div>
+      ))}
+    </div>
+  );
+}

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

@@ -21,6 +21,7 @@ export type UserSession = {
   phone?: string;
   identity?: string;
   levelLabel?: UserLevel;
+  referralCode?: string;
   /** ISO:首次累计消费达到门槛的时间(仅本地 Mock) */
   thresholdReachedAt: string | null;
   orders: MockOrder[];

+ 71 - 9
src/lib/order-api.ts

@@ -6,6 +6,11 @@ import {
 
 export type OrderStatus = 1 | 2 | 3 | 4 | 5;
 
+export type OrderDetailItem = {
+  goodsName: string;
+  goodsNum: number | null;
+};
+
 export type OrderRecord = {
   id: string;
   serial: string;
@@ -13,6 +18,9 @@ export type OrderRecord = {
   status: OrderStatus;
   addTime: string;
   payTime: string;
+  /** 订单商品明细 */
+  items: OrderDetailItem[];
+  /** 商品摘要,用于兼容旧展示 */
   details: string;
 };
 
@@ -41,20 +49,63 @@ function pickList(raw: unknown): unknown[] {
   return [];
 }
 
-function normalizeDetails(value: unknown): string {
-  if (typeof value === "string" && value.trim()) return value.trim();
+function pickString(v: unknown): string {
+  return typeof v === "string" ? v.trim() : String(v ?? "").trim();
+}
+
+function pickGoodsNum(v: unknown): number | null {
+  if (v == null || v === "") return null;
+  const n = typeof v === "number" ? v : Number(v);
+  return Number.isFinite(n) ? n : null;
+}
+
+function normalizeOrderItems(value: unknown): OrderDetailItem[] {
   if (Array.isArray(value)) {
     return value
-      .map((v) => (typeof v === "string" ? v.trim() : ""))
-      .filter(Boolean)
-      .join("、");
+      .map((item) => {
+        if (typeof item === "string" && item.trim()) {
+          return { goodsName: item.trim(), goodsNum: null };
+        }
+        if (!item || typeof item !== "object") return null;
+        const o = item as Record<string, unknown>;
+        const goodsName = pickString(o.goodsName ?? o.title ?? o.name ?? o.courseName);
+        if (!goodsName) return null;
+        return {
+          goodsName,
+          goodsNum: pickGoodsNum(o.goodsNum ?? o.num ?? o.quantity ?? o.count),
+        };
+      })
+      .filter((item): item is OrderDetailItem => item !== null);
+  }
+
+  if (typeof value === "string" && value.trim()) {
+    return [{ goodsName: value.trim(), goodsNum: null }];
   }
+
   if (value && typeof value === "object") {
     const o = value as Record<string, unknown>;
-    const name = o.title ?? o.name ?? o.goodsName ?? o.courseName;
-    if (typeof name === "string" && name.trim()) return name.trim();
+    const goodsName = pickString(o.goodsName ?? o.title ?? o.name ?? o.courseName);
+    if (goodsName) {
+      return [
+        {
+          goodsName,
+          goodsNum: pickGoodsNum(o.goodsNum ?? o.num ?? o.quantity ?? o.count),
+        },
+      ];
+    }
   }
-  return "-";
+
+  return [];
+}
+
+function formatOrderDetailsSummary(items: OrderDetailItem[]): string {
+  if (items.length === 0) return "-";
+  return items
+    .map((item) => {
+      const qty = item.goodsNum != null ? ` ×${item.goodsNum}` : "";
+      return `${item.goodsName}${qty}`;
+    })
+    .join("、");
 }
 
 export function getOrderStatusLabel(status: number): string {
@@ -106,7 +157,17 @@ export async function fetchOrderList(page: {
         : 1;
     const addTime = String(o.addTime ?? o.createTime ?? o.createdAt ?? "").trim();
     const payTime = String(o.payTime ?? o.paidAt ?? "").trim();
-    const details = normalizeDetails(o.details ?? o.detail ?? o.goodsName ?? o.courseName);
+    const items = normalizeOrderItems(o.details ?? o.detail ?? o.goodsList ?? o.items);
+    if (items.length === 0) {
+      const fallbackName = pickString(o.goodsName ?? o.courseName);
+      if (fallbackName) {
+        items.push({
+          goodsName: fallbackName,
+          goodsNum: pickGoodsNum(o.goodsNum ?? o.num ?? o.quantity),
+        });
+      }
+    }
+    const details = formatOrderDetailsSummary(items);
 
     out.push({
       id,
@@ -115,6 +176,7 @@ export async function fetchOrderList(page: {
       status,
       addTime,
       payTime,
+      items,
       details,
     });
   }

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

@@ -1,13 +1,10 @@
 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: {
@@ -34,19 +31,11 @@ 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,
   };
 }
 

+ 45 - 0
src/lib/user-label-api.ts

@@ -0,0 +1,45 @@
+import { apiPost } from "@/lib/api";
+import { parseLevelLabel, type UserLevel } from "@/lib/user-level";
+
+export type CustomUserLabel = {
+  levelLabel?: UserLevel;
+  referralCode?: string;
+};
+
+function pickString(v: unknown): string {
+  return typeof v === "string" ? v.trim() : String(v ?? "").trim();
+}
+
+export async function fetchCustomUserLabel(): Promise<CustomUserLabel> {
+  const raw = await apiPost<unknown>("/custom/get/label", {});
+  const root = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
+  const data =
+    root.data && typeof root.data === "object" && root.data !== null
+      ? (root.data as Record<string, unknown>)
+      : root;
+
+  const levelRaw =
+    data.levelLabel ?? data.level ?? data.label ?? root.levelLabel ?? root.level ?? root.label;
+  const levelLabel = parseLevelLabel(levelRaw) ?? undefined;
+
+  const referralCode =
+    pickString(
+      data.referralCode ??
+        data.referral_code ??
+        data.code ??
+        root.referralCode ??
+        root.referral_code ??
+        root.code,
+    ) || undefined;
+
+  return { levelLabel, referralCode };
+}
+
+/** 静默拉取用户标签,接口失败时返回 null,避免阻断页面渲染 */
+export async function tryFetchCustomUserLabel(): Promise<CustomUserLabel | null> {
+  try {
+    return await fetchCustomUserLabel();
+  } catch {
+    return null;
+  }
+}

+ 43 - 5
src/providers/auth-provider.tsx

@@ -6,6 +6,7 @@ import {
   useContext,
   useEffect,
   useMemo,
+  useRef,
   useState,
   type ReactNode,
 } from "react";
@@ -24,6 +25,7 @@ import { loginWithPassword } from "@/lib/auth-api";
 import { isRegisterPasswordValid } from "@/lib/password-rules";
 import { registerWithEmail } from "@/lib/register-api";
 import { fetchCustomUserInfo } from "@/lib/user-info-api";
+import { tryFetchCustomUserLabel } from "@/lib/user-label-api";
 
 type AuthContextValue = {
   user: UserSession | null;
@@ -43,7 +45,10 @@ type AuthContextValue = {
   logout: () => void;
   updateProfile: (
     patch: Partial<
-      Pick<UserSession, "name" | "phone" | "identity" | "scholarship" | "levelLabel">
+      Pick<
+        UserSession,
+        "name" | "phone" | "identity" | "scholarship" | "levelLabel" | "referralCode"
+      >
     >,
   ) => void;
   addMockOrder: (order: Omit<MockOrder, "id" | "createdAt">) => void;
@@ -73,6 +78,7 @@ function newUserSession(
 export function AuthProvider({ children }: { children: ReactNode }) {
   const [user, setUser] = useState<UserSession | null>(null);
   const [isReady, setIsReady] = useState(false);
+  const skipLabelFetchRef = useRef(false);
   useEffect(() => {
     /* eslint-disable react-hooks/set-state-in-effect -- hydrate once from localStorage after mount */
     setUser(loadUser());
@@ -80,6 +86,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
     /* eslint-enable react-hooks/set-state-in-effect */
   }, []);
 
+  useEffect(() => {
+    if (!isReady || !user?.email) return;
+    if (skipLabelFetchRef.current) {
+      skipLabelFetchRef.current = false;
+      return;
+    }
+    let cancelled = false;
+    void tryFetchCustomUserLabel().then((label) => {
+      if (cancelled || !label) return;
+      setUser((current) => {
+        if (!current) return current;
+        const next: UserSession = {
+          ...current,
+          levelLabel: label.levelLabel,
+          referralCode: label.referralCode,
+        };
+        saveUser(next);
+        return next;
+      });
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, [isReady, user?.email]);
+
   const persist = useCallback((next: UserSession | null) => {
     setUser(next);
     if (next) saveUser(next);
@@ -99,14 +130,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
         if (token) setApiAuthToken(token);
         const base = newUserSession(email.trim(), displayName);
         try {
-          const info = await fetchCustomUserInfo();
+          const [info, label] = await Promise.all([
+            fetchCustomUserInfo(),
+            tryFetchCustomUserLabel(),
+          ]);
           persist({
             ...base,
             email: info.email || base.email,
             name: info.name || base.name,
             phone: info.phone || base.phone,
             identity: info.identity || base.identity,
-            levelLabel: info.levelLabel,
+            levelLabel: label?.levelLabel,
+            referralCode: label?.referralCode,
           });
         } catch {
           persist(base);
@@ -152,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
         });
         if (token) setApiAuthToken(token);
         const base = newUserSession(input.email.trim(), input.name.trim() || "学员");
+        skipLabelFetchRef.current = true;
         try {
           const info = await fetchCustomUserInfo();
           persist({
@@ -160,7 +196,6 @@ 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);
@@ -182,7 +217,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const updateProfile = useCallback(
     (
       patch: Partial<
-        Pick<UserSession, "name" | "phone" | "identity" | "scholarship" | "levelLabel">
+        Pick<
+          UserSession,
+          "name" | "phone" | "identity" | "scholarship" | "levelLabel" | "referralCode"
+        >
       >,
     ) => {
       if (!user) return;