fix staging reports and membership navigation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user