fix staging reports and membership navigation
Staging Backend Quality Gate / validate (push) Failing after 14m31s
Staging Backend Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-09 08:36:14 +08:00
parent b3f06efc30
commit ac870d84d6
7 changed files with 311 additions and 214 deletions
+44 -21
View File
@@ -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 {
+126
View File
@@ -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<string, unknown>;
const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record<string, unknown>;
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<PaymentOrder[]>([]);
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 (
<main className="membership-page">
<div className="membership-shell">
<header className="membership-header">
<div className="membership-header-left">
<Link className="membership-back" href="/membership" aria-label="返回套餐与会员">
<ArrowLeft aria-hidden="true" />
<span></span>
</Link>
<h1 className="membership-title"></h1>
</div>
</header>
<section className="membership-orders" aria-labelledby="membership-orders-title">
<div className="membership-section-heading">
<div>
<h2 id="membership-orders-title"></h2>
<p></p>
</div>
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}></button>
</div>
{ordersState === "loading" && <p className="membership-empty"></p>}
{ordersState === "unavailable" && <p className="membership-empty"></p>}
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty"></p>}
{orders.length > 0 && (
<ul className="membership-order-list">
{orders.map((order) => (
<li className="membership-order-row" key={order.orderNo}>
<div>
<strong>{order.productName || "订单"}</strong>
<small>{order.orderNo}</small>
</div>
<span className={`membership-order-status status-${order.status}`}>
{orderStatusLabel(order.status)}
</span>
<b>{formatPrice(order.moneyCents, order.currency)}</b>
</li>
))}
</ul>
)}
</section>
</div>
</main>
);
}
+33 -162
View File
@@ -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<string, unknown>;
const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record<string, unknown>;
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<MembershipProduct[]>([]);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [packagesError, setPackagesError] = useState("");
const [orders, setOrders] = useState<PaymentOrder[]>([]);
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<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [activeSegment, setActiveSegment] = useState<"membership" | "credits">("membership");
const [redeemDone, setRedeemDone] = useState(false);
const redeemDialogRef = useRef<HTMLElement>(null);
const redeemInputRef = useRef<HTMLInputElement>(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 (
<main className="membership-page">
@@ -355,17 +284,15 @@ function MembershipContent() {
</button>
<h1 className="membership-title"></h1>
</div>
<button className="membership-redeem-entry" type="button" onClick={() => setRedeemOpen(true)}>
<Gift aria-hidden="true" />
<span></span>
</button>
<div className="membership-header-actions">
<Link className="membership-orders-entry" href="/membership/orders"></Link>
<button className="membership-redeem-entry" type="button" onClick={() => setRedeemOpen(true)}>
<Gift aria-hidden="true" />
<span></span>
</button>
</div>
</header>
{sourceNotice && <p className="membership-notice" role="status">{sourceNotice}</p>}
{highlightedPlan && (
<p className="membership-notice" role="status">{planDisplayLabel(highlightedPlan)}</p>
)}
<section className="membership-hero" aria-labelledby="membership-hero-title">
<div className="membership-hero-copy">
<p className="membership-greeting">{account?.user.email ? `账户:${account.user.email}` : "欢迎回来"}</p>
@@ -376,14 +303,13 @@ function MembershipContent() {
<article className="membership-summary-card">
<span></span>
<strong>{activeSubscription ? activeSubscription.product?.name || activeSubscription.productCode : "未开通会员"}</strong>
<small>{activeSubscription
? `${formatMembershipDate(activeSubscription.startsAt)}${formatMembershipDate(activeSubscription.endsAt)}`
: "体验 / 月卡 / 年卡见下方套餐"}</small>
{activeSubscription && (
<small>{formatMembershipDate(activeSubscription.startsAt)} {formatMembershipDate(activeSubscription.endsAt)}</small>
)}
</article>
<article className="membership-summary-card">
<span></span>
<strong>{account ? `${account.credits}` : accountError || "正在读取…"}</strong>
<small></small>
</article>
</div>
</div>
@@ -397,59 +323,18 @@ function MembershipContent() {
</div>
</section>
<section className="membership-orders" aria-labelledby="membership-orders-title">
<div className="membership-section-heading">
<h2 id="membership-orders-title"></h2>
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}></button>
</div>
{ordersState === "loading" && <p className="membership-empty"></p>}
{ordersState === "unavailable" && <p className="membership-empty"></p>}
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty"></p>}
{orders.length > 0 && (
<ul className="membership-order-list">
{orders.map((order) => (
<li className="membership-order-row" key={order.orderNo}>
<div>
<strong>{order.productName || "订单"}</strong>
<small>{order.orderNo}</small>
</div>
<span className={`membership-order-status status-${order.status}`}>
{orderStatusLabel(order.status)}
</span>
<b>{formatPrice(order.moneyCents, order.currency)}</b>
</li>
))}
</ul>
)}
</section>
<section className="membership-segment" aria-label="套餐与点数">
<div className="membership-tabs" role="tablist" aria-label="套餐分类">
<button
className={`membership-tab${activeSegment === "membership" ? " is-active" : ""}`}
id="membership-plans-tab"
role="tab"
type="button"
aria-selected={activeSegment === "membership"}
aria-controls="membership-plans-panel"
onClick={() => setActiveSegment("membership")}
>
</button>
<button
className={`membership-tab${activeSegment === "credits" ? " is-active" : ""}`}
id="membership-credits-tab"
role="tab"
type="button"
aria-selected={activeSegment === "credits"}
aria-controls="membership-credits-panel"
onClick={() => setActiveSegment("credits")}
>
</button>
</div>
<Tabs defaultValue="membership">
<TabsList className="membership-tabs" aria-label="套餐分类">
<TabsTrigger className="membership-tab" id="membership-plans-tab" value="membership">
</TabsTrigger>
<TabsTrigger className="membership-tab" id="membership-credits-tab" value="credits">
</TabsTrigger>
</TabsList>
{paymentError && (
{paymentError && (
<div className="membership-payment-error" role="alert">
<p>{paymentError}</p>
{selectedProductId && (
@@ -463,16 +348,10 @@ function MembershipContent() {
</button>
)}
</div>
)}
{packagesError && <p className="form-error" role="alert">{packagesError}</p>}
)}
{packagesError && <p className="form-error" role="alert">{packagesError}</p>}
{activeSegment === "membership" && (
<div
className="membership-panel"
id="membership-plans-panel"
role="tabpanel"
aria-labelledby="membership-plans-tab"
>
<TabsContent className="membership-panel" value="membership">
{!packagesError && !paymentEnabled && <p className="membership-empty">线</p>}
{plans.length > 0 && (
<div className="membership-plan-grid">
@@ -516,7 +395,7 @@ function MembershipContent() {
<li>{isCurrent
? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。`
: "支付成功后权益立即生效。"}</li>
<li></li>
<li></li>
<li></li>
</ul>
</details>
@@ -534,16 +413,9 @@ function MembershipContent() {
</div>
)}
{!packagesError && paymentEnabled && plans.length === 0 && <p className="membership-empty"></p>}
</div>
)}
</TabsContent>
{activeSegment === "credits" && (
<div
className="membership-panel"
id="membership-credits-panel"
role="tabpanel"
aria-labelledby="membership-credits-tab"
>
<TabsContent className="membership-panel" value="credits">
{creditPacks.length > 0 && (
<div className="membership-credit-grid">
{creditPacks.map((product) => (
@@ -563,7 +435,6 @@ function MembershipContent() {
<summary></summary>
<ul>
<li></li>
<li></li>
</ul>
</details>
<button
@@ -579,8 +450,8 @@ function MembershipContent() {
</div>
)}
{!packagesError && paymentEnabled && creditPacks.length === 0 && <p className="membership-empty"></p>}
</div>
)}
</TabsContent>
</Tabs>
</section>
{paymentOrder && (
@@ -607,7 +478,7 @@ function MembershipContent() {
<>
<h3></h3>
<p>{paymentOrder.orderNo}</p>
<p></p>
<p></p>
</>
)}
</div>
+47
View File
@@ -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 (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn("inline-flex w-fit items-center", className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn("inline-flex items-center justify-center", className)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger }
+1 -1
View File
@@ -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);
@@ -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 = {
+52 -30
View File
@@ -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, /<Tabs defaultValue="membership">/);
assert.match(pageSource, /<TabsTrigger[^>]*value="membership"/);
assert.match(pageSource, /<TabsTrigger[^>]*value="credits"/);
assert.match(pageSource, /<TabsContent[^>]*value="membership"/);
assert.match(pageSource, /<TabsContent[^>]*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('<TabsContent className="membership-panel" value="membership">');
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, /<p className="form-error" role="alert">\{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\)/);
});