diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 47d6ca55..388c561f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1916,6 +1916,7 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled color: var(--color-ink); cursor: pointer; font-size: 14px; + text-decoration: none; } .membership-back svg { @@ -1932,6 +1933,13 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled letter-spacing: -.03em; } +.membership-header-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.membership-orders-entry, .membership-redeem-entry { min-height: 44px; display: inline-flex; @@ -1947,22 +1955,18 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled font-weight: 500; } +.membership-orders-entry { + border-color: var(--color-border); + background: var(--color-canvas); + color: var(--color-ink-secondary); + text-decoration: none; +} + .membership-redeem-entry svg { width: 16px; height: 16px; } -.membership-notice { - margin: 0; - padding: var(--space-3) var(--space-4); - border: 1px solid color-mix(in srgb, var(--color-action) 22%, var(--color-border)); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--color-action-soft) 62%, transparent); - color: var(--color-ink-secondary); - font-size: var(--type-body-sm); - line-height: 1.55; -} - .membership-hero { position: relative; min-height: 260px; @@ -2124,6 +2128,11 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled margin-bottom: var(--space-4); } +.membership-section-heading > div { + display: grid; + gap: var(--space-1); +} + .membership-section-heading h2 { margin: 0; color: var(--color-ink); @@ -2524,29 +2533,43 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled } .membership-tabs { - display: flex; - gap: var(--space-2); + display: inline-flex; + align-items: center; + width: fit-content; + gap: 4px; margin-bottom: var(--space-5); - border-bottom: 1px solid var(--color-border); + padding: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas-soft); } .membership-tab { - position: relative; min-height: 44px; padding: 0 16px; - border: 0; - border-bottom: 2px solid transparent; - background: none; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; color: var(--color-ink-secondary); cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; } -.membership-tab.is-active, -.membership-tab[aria-selected="true"] { +.membership-tab:hover { + color: var(--color-ink); +} + +.membership-tab:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action) 46%, transparent); + outline-offset: 2px; +} + +.membership-tab[data-active] { + border-color: var(--color-border); + background: var(--color-canvas); color: var(--color-action); - border-bottom-color: var(--color-action); + box-shadow: var(--shadow-soft); } .membership-panel { diff --git a/frontend/src/app/membership/orders/page.tsx b/frontend/src/app/membership/orders/page.tsx new file mode 100644 index 00000000..2384fb73 --- /dev/null +++ b/frontend/src/app/membership/orders/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { formatPrice, orderStatusLabel } from "@/lib/membership"; + +type PaymentOrder = { + orderNo: string; + status: string; + credits: number; + moneyCents: number; + currency: string; + productName: string; + paidAt: string | null; + createdAt: string; +}; + +function normalizeOrders(list: readonly unknown[]): PaymentOrder[] { + return list.map((raw) => { + const item = (raw ?? {}) as Record; + const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record; + const price = + typeof item.price === "number" + ? item.price + : typeof item.money_cents === "number" + ? item.money_cents + : typeof item.moneyCents === "number" + ? item.moneyCents + : 0; + return { + orderNo: String(item.order_no ?? item.orderNo ?? ""), + status: String(item.status ?? "unknown"), + credits: typeof item.credits === "number" ? item.credits : 0, + moneyCents: price, + currency: String(item.currency ?? "CNY"), + productName: + typeof snapshot.name === "string" + ? snapshot.name + : typeof item.name === "string" + ? item.name + : String(item.product_code ?? item.productCode ?? ""), + paidAt: item.paid_at ? String(item.paid_at) : item.paidAt ? String(item.paidAt) : null, + createdAt: String(item.created_at ?? item.createdAt ?? ""), + }; + }); +} + +export default function MembershipOrdersPage() { + const [orders, setOrders] = useState([]); + const [ordersState, setOrdersState] = useState<"loading" | "ready" | "unavailable">("loading"); + + const fetchOrders = useCallback(async () => { + setOrdersState("loading"); + try { + const response = await fetch("/api/payment/orders", { cache: "no-store" }); + if (response.status === 401) { + window.location.assign("/login"); + return; + } + if (!response.ok) throw new Error("订单记录暂时不可用"); + const payload = await response.json().catch(() => null); + const list = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" && Array.isArray(payload.orders) + ? payload.orders + : []; + setOrders(normalizeOrders(list)); + setOrdersState("ready"); + } catch { + setOrders([]); + setOrdersState("unavailable"); + } + }, []); + + useEffect(() => { + void (async () => { + await fetchOrders(); + })(); + }, [fetchOrders]); + + return ( +
+
+
+
+ +
+
+ +
+
+
+

全部订单

+

查看套餐与点数充值的支付和到账状态。

+
+ +
+ {ordersState === "loading" &&

正在加载订单…

} + {ordersState === "unavailable" &&

订单记录暂时不可用,请稍后刷新。

} + {ordersState === "ready" && orders.length === 0 &&

暂无订单。

} + {orders.length > 0 && ( +
    + {orders.map((order) => ( +
  • +
    + {order.productName || "订单"} + {order.orderNo} +
    + + {orderStatusLabel(order.status)} + + {formatPrice(order.moneyCents, order.currency)} +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/membership/page.tsx b/frontend/src/app/membership/page.tsx index 4acfbbd3..ad0b0d9a 100644 --- a/frontend/src/app/membership/page.tsx +++ b/frontend/src/app/membership/page.tsx @@ -2,17 +2,17 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; +import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { ArrowLeft, CheckCircle2, Gift, Sparkles, X } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { keepFocusWithin } from "@/lib/focus-trap"; import { entitlementLabel, formatMembershipDate, formatPrice, formatProductDuration, - membershipSourceNotice, notifyBalanceChanged, - orderStatusLabel, planAlias, planDisplayLabel, productAudience, @@ -37,17 +37,6 @@ type MembershipAccount = { } | null; }; -type PaymentOrder = { - orderNo: string; - status: string; - credits: number; - moneyCents: number; - currency: string; - productName: string; - paidAt: string | null; - createdAt: string; -}; - type PaymentOrderState = { orderNo: string; payUrl: string | null; @@ -56,36 +45,6 @@ type PaymentOrderState = { productId: string; }; -function normalizeOrders(list: readonly unknown[]): PaymentOrder[] { - return list.map((raw) => { - const item = (raw ?? {}) as Record; - const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record; - const price = - typeof item.price === "number" - ? item.price - : typeof item.money_cents === "number" - ? item.money_cents - : typeof item.moneyCents === "number" - ? item.moneyCents - : 0; - return { - orderNo: String(item.order_no ?? item.orderNo ?? ""), - status: String(item.status ?? "unknown"), - credits: typeof item.credits === "number" ? item.credits : 0, - moneyCents: price, - currency: String(item.currency ?? "CNY"), - productName: - typeof snapshot.name === "string" - ? snapshot.name - : typeof item.name === "string" - ? item.name - : String(item.product_code ?? item.productCode ?? ""), - paidAt: item.paid_at ? String(item.paid_at) : item.paidAt ? String(item.paidAt) : null, - createdAt: String(item.created_at ?? item.createdAt ?? ""), - }; - }); -} - function goBack() { if (window.history.length > 1) window.history.back(); else window.location.assign("/"); @@ -93,7 +52,6 @@ function goBack() { function MembershipContent() { const searchParams = useSearchParams(); - const source = searchParams.get("source"); const planHint = searchParams.get("plan"); const highlightedPlan: MembershipPlanAlias | null = planHint === "trial" || planHint === "monthly" || planHint === "yearly" ? planHint : null; @@ -103,8 +61,6 @@ function MembershipContent() { const [paymentPackages, setPaymentPackages] = useState([]); const [paymentEnabled, setPaymentEnabled] = useState(false); const [packagesError, setPackagesError] = useState(""); - const [orders, setOrders] = useState([]); - const [ordersState, setOrdersState] = useState<"loading" | "ready" | "unavailable">("loading"); const [redeemOpen, setRedeemOpen] = useState(() => searchParams.get("redeem") === "1"); const [redeemCode, setRedeemCode] = useState(""); const [redeemError, setRedeemError] = useState(""); @@ -114,7 +70,6 @@ function MembershipContent() { const [paymentError, setPaymentError] = useState(""); const [payingProductId, setPayingProductId] = useState(null); const [selectedProductId, setSelectedProductId] = useState(null); - const [activeSegment, setActiveSegment] = useState<"membership" | "credits">("membership"); const [redeemDone, setRedeemDone] = useState(false); const redeemDialogRef = useRef(null); const redeemInputRef = useRef(null); @@ -173,35 +128,12 @@ function MembershipContent() { } }, [resetPaymentState]); - const fetchOrders = useCallback(async () => { - try { - const response = await fetch("/api/payment/orders", { cache: "no-store" }); - if (!response.ok) { - setOrdersState("unavailable"); - setOrders([]); - return; - } - const payload = await response.json().catch(() => null); - const list = Array.isArray(payload) - ? payload - : payload && typeof payload === "object" && Array.isArray(payload.orders) - ? payload.orders - : []; - setOrders(normalizeOrders(list)); - setOrdersState("ready"); - } catch { - setOrdersState("unavailable"); - setOrders([]); - } - }, []); - useEffect(() => { void (async () => { await fetchAccountData(); await fetchPackages(); - await fetchOrders(); })(); - }, [fetchAccountData, fetchOrders, fetchPackages]); + }, [fetchAccountData, fetchPackages]); useEffect(() => { if (!redeemOpen) return; @@ -273,7 +205,6 @@ function MembershipContent() { setRedeemMessage(result.message || `本次到账 ${awarded} 点,最新余额 ${result.credits} 点。`); const refreshed = await fetchAccountData(); notifyBalanceChanged(refreshed?.credits ?? result.credits); - void fetchOrders(); } catch (caught) { setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试"); } finally { @@ -333,16 +264,14 @@ function MembershipContent() { if (paid) { const refreshed = await fetchAccountData(); notifyBalanceChanged(refreshed?.credits ?? 0); - void fetchOrders(); } }); }, 3000); return () => window.clearInterval(timer); - }, [fetchAccountData, fetchOrders, paymentOrder]); + }, [fetchAccountData, paymentOrder]); const plans = selectMembershipPlans(paymentPackages); const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack"); - const sourceNotice = membershipSourceNotice(source); return (
@@ -355,17 +284,15 @@ function MembershipContent() {

套餐与会员

- +
+ 订单记录 + +
- {sourceNotice &&

{sourceNotice}

} - {highlightedPlan && ( -

已为你定位到{planDisplayLabel(highlightedPlan)}套餐。

- )} -

{account?.user.email ? `账户:${account.user.email}` : "欢迎回来"}

@@ -376,14 +303,13 @@ function MembershipContent() {
会员状态 {activeSubscription ? activeSubscription.product?.name || activeSubscription.productCode : "未开通会员"} - {activeSubscription - ? `${formatMembershipDate(activeSubscription.startsAt)} 至 ${formatMembershipDate(activeSubscription.endsAt)}` - : "体验 / 月卡 / 年卡见下方套餐"} + {activeSubscription && ( + {formatMembershipDate(activeSubscription.startsAt)} 至 {formatMembershipDate(activeSubscription.endsAt)} + )}
当前余额 {account ? `${account.credits} 点` : accountError || "正在读取…"} - 用于咨询与生时校正等服务
@@ -397,59 +323,18 @@ function MembershipContent() {
-
-
-

订单记录

- -
- {ordersState === "loading" &&

正在加载订单…

} - {ordersState === "unavailable" &&

订单记录暂时不可用,请稍后刷新。

} - {ordersState === "ready" && orders.length === 0 &&

暂无订单。

} - {orders.length > 0 && ( -
    - {orders.map((order) => ( -
  • -
    - {order.productName || "订单"} - {order.orderNo} -
    - - {orderStatusLabel(order.status)} - - {formatPrice(order.moneyCents, order.currency)} -
  • - ))} -
- )} -
-
-
- - -
+ + + + 会员套餐 + + + 点数充值 + + - {paymentError && ( + {paymentError && (

{paymentError}

{selectedProductId && ( @@ -463,16 +348,10 @@ function MembershipContent() { )}
- )} - {packagesError &&

{packagesError}

} + )} + {packagesError &&

{packagesError}

} - {activeSegment === "membership" && ( -
+ {!packagesError && !paymentEnabled &&

在线支付暂未开放。

} {plans.length > 0 && (
@@ -516,7 +395,7 @@ function MembershipContent() {
  • {isCurrent ? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。` : "支付成功后权益立即生效。"}
  • -
  • 订单与到账状态可在本页「订单记录」中查看。
  • +
  • 订单与到账状态可在「订单记录」页面查看。
  • 支付由合作渠道处理;如长时间未到账,请联系支持并出示订单号。
  • @@ -534,16 +413,9 @@ function MembershipContent() {
    )} {!packagesError && paymentEnabled && plans.length === 0 &&

    会员套餐正在上架中。

    } -
    - )} + - {activeSegment === "credits" && ( -
    + {creditPacks.length > 0 && (
    {creditPacks.map((product) => ( @@ -563,7 +435,6 @@ function MembershipContent() { 详细规则
    • 点数充值支付成功后立即到账。
    • -
    • 点数用于咨询、报告与生时校正等服务。
    - )} +
    +
    {paymentOrder && ( @@ -607,7 +478,7 @@ function MembershipContent() { <>

    等待完成支付

    订单号:{paymentOrder.orderNo}

    -

    请在已打开的收银台页面完成支付;本页会自动刷新余额与订单记录。

    +

    请在已打开的收银台页面完成支付;本页会自动检查支付状态并刷新余额。

    )} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 00000000..53908a00 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" + +import { cn } from "@/lib/utils" + +function Tabs({ className, ...props }: TabsPrimitive.Root.Props) { + return ( + + ) +} + +function TabsList({ className, ...props }: TabsPrimitive.List.Props) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsContent, TabsList, TabsTrigger } diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 0709ad42..c9c6821a 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -68,7 +68,7 @@ export async function runConsultationWorkflow(input: ConsultationInput) { question_text: workflowRequest.question, theme: workflowRequest.themes, }), - signal: AbortSignal.timeout(45_000), + signal: AbortSignal.timeout(90_000), }); const data = await response.json().catch(() => null); diff --git a/frontend/tests/consultation-workflow-request.test.ts b/frontend/tests/consultation-workflow-request.test.ts index 2961f805..5c116fb7 100644 --- a/frontend/tests/consultation-workflow-request.test.ts +++ b/frontend/tests/consultation-workflow-request.test.ts @@ -20,6 +20,14 @@ test("timing questions use a legal report theme and preserve a timing route hint assert.equal(request.claimBoundary, "candidate_day_month_window_only_until_holdout_passes"); }); +test("consultation workflow allows a cold engine run to finish", async () => { + const source = await import("node:fs/promises").then(({ readFile }) => + readFile(new URL("../src/mastra/index.ts", import.meta.url), "utf8") + ); + + assert.match(source, /AbortSignal\.timeout\(90_000\)/); +}); + test("timing input projects only legal private workflow fields", async () => { // Given: a public timing input and a Python workflow response. const input: ConsultationInput = { diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts index 9f1c6f27..504f9ccc 100644 --- a/frontend/tests/membership-page.test.ts +++ b/frontend/tests/membership-page.test.ts @@ -6,6 +6,8 @@ const projectFile = (path: string) => new URL(`../${path}`, import.meta.url); const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8"); const globalStyles = readProjectFile("src/app/globals.css"); const pageSource = readProjectFile("src/app/membership/page.tsx"); +const ordersPageSource = readProjectFile("src/app/membership/orders/page.tsx"); +const tabsSource = readProjectFile("src/components/ui/tabs.tsx"); const membershipLib = readProjectFile("src/lib/membership.ts"); function cssBlock(selector: string) { @@ -23,10 +25,10 @@ test("membership page exists as a client component with a loading boundary", () assert.match(pageSource, /membership-loading/); }); -test("membership page loads account, packages, orders and redeem endpoints", () => { +test("membership page loads account, packages and redeem endpoints without fetching orders", () => { assert.match(pageSource, /fetch\("\/api\/account"/); assert.match(pageSource, /fetch\("\/api\/payment\/packages"/); - assert.match(pageSource, /fetch\("\/api\/payment\/orders"/); + assert.doesNotMatch(pageSource, /\/api\/payment\/orders|fetchOrders/); assert.match(pageSource, /fetch\("\/api\/payment\/epay\/create"/); assert.match(pageSource, /fetch\(`\/api\/payment\/epay\/status\?orderNo=/); assert.match(pageSource, /fetch\("\/api\/redeem"/); @@ -58,13 +60,13 @@ test("failed status beats paid and non-terminal statuses keep polling", () => { assert.doesNotMatch(pageSource, /payload\.status === "paid" \? "paid"/); }); -test("polls order status and refreshes balance on paid", () => { +test("polls order status and refreshes only the balance on paid", () => { assert.match(pageSource, /const paid = payload\.status === "paid" && !failed;/); assert.match(pageSource, /if \(paid\) \{/); assert.match(pageSource, /status: paid \? "paid" : failed \? "failed" : "pending"/); assert.match(pageSource, /fetchAccountData\(\)/); assert.match(pageSource, /notifyBalanceChanged\(refreshed\?\.credits/); - assert.match(pageSource, /void fetchOrders\(\)/); + assert.doesNotMatch(pageSource, /fetchOrders/); }); test("keeps the selected product and allows retry on payment failure", () => { @@ -109,12 +111,12 @@ test("redeem success reports awarded credits, latest balance and completes", () assert.match(pageSource, /onClick=\{closeRedeem\}/); }); -test("supports plan and source query hints", () => { +test("supports plan hints without rendering source notices", () => { assert.match(pageSource, /searchParams\.get\("plan"\)/); - assert.match(pageSource, /searchParams\.get\("source"\)/); - assert.match(pageSource, /membershipSourceNotice\(source\)/); - assert.match(membershipLib, /"insufficient-credits": "余额不足,请先补充点数或开通会员后再发送问题"/); - assert.match(membershipLib, /rectification: "生时校正需要点数或会员权益"/); + assert.doesNotMatch(pageSource, /searchParams\.get\("source"\)|membershipSourceNotice|sourceNotice/); + assert.doesNotMatch(pageSource, /membership-notice|已为你定位到/); + assert.doesNotMatch(globalStyles, /\.membership-notice/); + assert.doesNotMatch(pageSource, /体验 \/ 月卡 \/ 年卡见下方套餐|用于咨询与生时校正等服务/); }); test("membership section shows fixed trial/monthly/yearly plans with monthly recommended", () => { @@ -129,11 +131,11 @@ test("membership section shows fixed trial/monthly/yearly plans with monthly rec assert.match(pageSource, /点数充值/); assert.match(pageSource, /id="membership-plans-tab"/); assert.match(pageSource, /id="membership-credits-tab"/); - assert.match(pageSource, /role="tablist"/); - assert.match(pageSource, /role="tab"/); - assert.match(pageSource, /role="tabpanel"/); - assert.match(pageSource, /aria-controls="membership-plans-panel"/); - assert.match(pageSource, /aria-labelledby="membership-plans-tab"/); + assert.match(pageSource, //); + assert.match(pageSource, /]*value="membership"/); + assert.match(pageSource, /]*value="credits"/); + assert.match(pageSource, /]*value="membership"/); + assert.match(pageSource, /]*value="credits"/); }); test("plan cards carry price, duration, audience, entitlements, purchase and rules", () => { @@ -153,12 +155,14 @@ test("renewal semantics map the active subscription to the matching plan", () => assert.match(pageSource, /当前套餐/); }); -test("membership and credits render as switchable tab panels, membership first", () => { - assert.match(pageSource, /const \[activeSegment, setActiveSegment\] = useState<"membership" \| "credits">\("membership"\)/); - assert.match(pageSource, /setActiveSegment\("membership"\)/); - assert.match(pageSource, /setActiveSegment\("credits"\)/); - assert.match(pageSource, /activeSegment === "membership" && \(/); - assert.match(pageSource, /activeSegment === "credits" && \(/); +test("membership and credits use the local Base UI tabs wrapper without manual segment state", () => { + assert.match(pageSource, /import \{ Tabs, TabsContent, TabsList, TabsTrigger \} from "@\/components\/ui\/tabs"/); + assert.doesNotMatch(pageSource, /activeSegment|setActiveSegment/); + assert.match(tabsSource, /import \{ Tabs as TabsPrimitive \} from "@base-ui\/react\/tabs"/); + assert.match(tabsSource, /TabsPrimitive\.Root/); + assert.match(tabsSource, /TabsPrimitive\.List/); + assert.match(tabsSource, /TabsPrimitive\.Tab/); + assert.match(tabsSource, /TabsPrimitive\.Panel/); assert.match(pageSource, /const plans = selectMembershipPlans\(paymentPackages\);/); assert.match(pageSource, /const creditPacks = paymentPackages\.filter\(\(product\) => product\.productType === "credit_pack"\);/); assert.match(pageSource, /className="membership-plan-grid"/); @@ -173,7 +177,6 @@ test("plan param forces the membership segment and highlights the matching card" assert.match(pageSource, /highlightedCardRef/); assert.match(pageSource, /scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/); assert.match(pageSource, /card\.focus\(\{ preventScroll: true \}\)/); - assert.match(pageSource, /已为你定位到/); assert.doesNotMatch(pageSource, /setActiveSegment\(\"credits\"\)[\s\S]{0,80}highlightedPlan/); }); @@ -184,24 +187,41 @@ test("page main title is 套餐与会员", () => { test("payment errors sit in a common position above the visible segment", () => { const errorAt = pageSource.indexOf("membership-payment-error"); - const plansPanelAt = pageSource.indexOf('activeSegment === "membership" && ('); + const plansPanelAt = pageSource.indexOf(''); assert.notEqual(errorAt, -1, "missing common payment error surface"); assert.notEqual(plansPanelAt, -1, "missing plans panel"); assert.ok(errorAt < plansPanelAt, "payment error must render before the segment panels"); assert.doesNotMatch(pageSource, /

    \{paymentError\}<\/p>/); }); -test("summary shows member status and balance; orders section lists records", () => { +test("summary stays on the membership page and links to the independent orders page", () => { assert.match(pageSource, /会员状态/); assert.match(pageSource, /当前余额/); assert.match(pageSource, /membership-summary-card/); - assert.match(pageSource, /订单记录/); - assert.match(pageSource, /membership-order-row/); - assert.match(pageSource, /orderStatusLabel\(order\.status\)/); - assert.match(pageSource, /orderStatusLabel/); + assert.match(pageSource, /href="\/membership\/orders"[^>]*>订单记录<\/Link>/); + assert.doesNotMatch(pageSource, /membership-order-row|orderStatusLabel/); +}); + +test("independent orders page preserves loading, empty, error, refresh and list states", () => { + assert.equal(existsSync(projectFile("src/app/membership/orders/page.tsx")), true); + assert.match(ordersPageSource, /fetch\("\/api\/payment\/orders"/); + assert.match(ordersPageSource, /response\.status === 401[\s\S]*window\.location\.assign\("\/login"\)/); + assert.match(ordersPageSource, /正在加载订单…/); + assert.match(ordersPageSource, /订单记录暂时不可用,请稍后刷新。/); + assert.match(ordersPageSource, /暂无订单。/); + assert.match(ordersPageSource, /onClick=\{\(\) => void fetchOrders\(\)\}>刷新/); + assert.match(ordersPageSource, /membership-order-row/); + assert.match(ordersPageSource, /orderStatusLabel\(order\.status\)/); + assert.match(ordersPageSource, /formatPrice\(order\.moneyCents, order\.currency\)/); assert.match(membershipLib, /paid: "已支付"/); }); +test("payment and plan copy points users to the separate orders page", () => { + assert.match(pageSource, /本页会自动检查支付状态并刷新余额。/); + assert.match(pageSource, /「订单记录」页面/); + assert.doesNotMatch(pageSource, /本页会自动刷新余额与订单记录|本页「订单记录」|用于咨询、报告与生时校正等服务/); +}); + test("orbit / ring guidance visual is present in the hero", () => { assert.match(pageSource, /membership-celestial-visual/); assert.match(pageSource, /membership-orbit membership-orbit-outer/); @@ -239,9 +259,11 @@ test("desktop uses three columns, tablet two and mobile one with monthly first", assert.doesNotMatch(cssBlock(".membership-plan-card--recommended"), /grid-column/); }); -test("segment tabs and highlight styles are defined", () => { - assert.match(cssBlock(".membership-tabs"), /border-bottom:\s*1px solid var\(--color-border\)/); - assert.match(globalStyles, /\.membership-tab\[aria-selected="true"\][^{]*\{[^}]*color:\s*var\(--color-action\)/); +test("segment tabs use a rounded container and active card treatment", () => { + assert.match(cssBlock(".membership-tabs"), /border-radius:\s*var\(--radius-lg\)/); + assert.match(cssBlock(".membership-tabs"), /background:\s*var\(--color-canvas-soft\)/); + assert.match(cssBlock(".membership-tab[data-active]"), /background:\s*var\(--color-canvas\)/); + assert.match(cssBlock(".membership-tab[data-active]"), /box-shadow:\s*var\(--shadow-soft\)/); assert.match(cssBlock(".membership-plan-card--highlighted"), /outline:\s*2px solid color-mix\(in srgb, var\(--color-action\)/); assert.match(cssBlock(".membership-payment-error"), /background:\s*var\(--color-danger-muted\)/); });