From 9a20b748e0e115725af6a68367e3ae67de7b77bb Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Wed, 29 Jul 2026 13:40:10 +0800 Subject: [PATCH] feat: unify payment administration --- docs/BUG_HISTORY.md | 19 +- frontend/src/app/admin/packages/page.tsx | 17 +- frontend/src/app/admin/payments/page.tsx | 44 +-- frontend/src/components/admin/admin-app.tsx | 27 +- .../components/admin/payment-management.tsx | 313 ++++++++++++++++++ frontend/tests/admin-contracts.test.ts | 17 +- .../tests/admin-payments-contract.test.ts | 49 ++- 7 files changed, 405 insertions(+), 81 deletions(-) create mode 100644 frontend/src/components/admin/payment-management.tsx diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3346c46d..a0b306f7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1612,17 +1612,18 @@ - 复发自:BUG-087 - 修复版本:`435e628806390e7ae138363491e7bae63ee801d4`,staging 已验收 -## BUG-093 | 后台导航遗漏支付记录与支付配置入口 +## BUG-093 | 后台支付入口分散且界面风格不一致 - 状态:resolved - 首次发现:2026-07-29 - 最近更新:2026-07-29 -- 影响面:后台 Refine 侧栏导航;支付页面与后台支付 API 本身不受影响。 -- 用户现象:管理员进入后台后只能看到兑换码、用户、积分流水、咨询和审计菜单,看不到支付记录与支付配置。 -- 触发条件:进入同域 `/admin` 后通过 Refine `resources` 生成后台导航。 -- 根因:`/admin/payments`、`/admin/packages` 页面及 `/api/admin/payments`、`/api/admin/packages` 接口已经存在,但 `AdminApp` 的 `resources` 数组没有注册这两个资源。 -- 修复:在后台导航中增加“支付记录”和“支付配置”,分别指向 `/admin/payments` 与 `/admin/packages`,并使用支付卡和设置图标;权限仍由统一的 admin-only 页面/API guard 执行。 -- 验证:后台合同测试锁定两个资源的名称、路径与图标;Next.js 生产构建通过;staging 部署和登录态验收记录在本次交付报告。 -- 防复发:新增独立后台页面时必须同步注册导航资源,后台合同测试必须覆盖页面路由与菜单资源的一致性。 +- 影响面:后台 Refine 侧栏、`/admin/payments`、`/admin/packages` 与后台退出区域;支付 API 数据合同不受影响。 +- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话。 +- 触发条件:进入同域 `/admin` 后管理支付记录或套餐,或点击 Refine 侧栏底部默认 Logout。 +- 根因:BUG-093 首轮只补齐了两个缺失资源,没有收敛同一支付领域的信息架构;支付与套餐页沿用 Refine 接入前的原生页面,默认 `ThemedSider` logout 也未按后台工作流定制。 +- 修复:后台只注册一个“支付管理”资源;`/admin/payments` 使用统一的 Ant Design + Refine `List/Card/Table/Form/Tag/Alert/Statistic` 页面,同时读取支付记录与套餐,保留筛选、统计、分页和订单字段。套餐新增/编辑收进可重置 Modal,保存和停用通过 message 反馈并刷新列表;旧 `/admin/packages` 服务端重定向。自定义 `ThemedSider` 保留资源 items、忽略默认 logout,并追加折叠态可访问的“返回对话”链接到 `/`。 +- 验证:`frontend/tests/admin-contracts.test.ts` 锁定单一支付资源、自定义 Sider 不调用 logout 和返回对话链接;`frontend/tests/admin-payments-contract.test.ts` 锁定统一页面组件、两类 fetch、套餐 Modal、POST/PATCH/DELETE、旧路由重定向以及 `requireAdminSession`。相关测试、Next.js build 与 `git diff --check` 结果记录在本次交付报告。 +- 防复发:同一后台领域默认收敛为单一资源和统一 Refine 页面;套餐创建只能出现在 Modal;旧深链接必须显式重定向;后台侧栏返回主站不得调用 `authProvider.logout`,主站账户菜单退出行为不随之修改。 - 相关记录:BUG-087、BUG-092 -- 修复版本:待 staging 验收 +- 复发自:BUG-093 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/admin/packages/page.tsx b/frontend/src/app/admin/packages/page.tsx index cd9bb1d7..524e8fb2 100644 --- a/frontend/src/app/admin/packages/page.tsx +++ b/frontend/src/app/admin/packages/page.tsx @@ -1,12 +1,5 @@ -"use client"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import type { FormEvent } from "react"; -type Package = { id: string; name: string; description: string; priceCents: number; credits: number; sortOrder: number; enabled: boolean }; -const empty = { name: "", description: "", priceCents: 100, credits: 10, sortOrder: 0, enabled: true }; -export default function AdminPackagesPage() { const [items, setItems] = useState([]); const [form, setForm] = useState(empty); const [editing, setEditing] = useState(null); const [error, setError] = useState(""); - async function load() { const response = await fetch("/api/admin/packages", { cache: "no-store" }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "读取失败"); setItems(data.packages); } - useEffect(() => { void load().catch((e) => setError(e.message)); }, []); - async function save(event: FormEvent) { event.preventDefault(); setError(""); const response = await fetch("/api/admin/packages", { method: editing ? "PATCH" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(editing ? { ...form, id: editing } : form) }); const data = await response.json(); if (!response.ok) { setError(data.error || "保存失败"); return; } setForm(empty); setEditing(null); await load(); } - async function disable(id: string) { const response = await fetch("/api/admin/packages", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ id }) }); if (!response.ok) setError("停用失败"); else await load(); } - return

充值套餐

兑换码管理 支付记录 返回对话

{editing ? "编辑套餐" : "添加套餐"}

只配置公开套餐,不显示易支付密钥。

{error &&

{error}

}

套餐列表

{items.length} 个套餐

{items.map((item) => )}
名称价格点数状态操作
{item.name}
{item.description}
¥{(item.priceCents / 100).toFixed(2)}{item.credits}{item.enabled ? "启用" : "停用"} {item.enabled && }
; } +import { redirect } from "next/navigation"; + +export default function AdminPackagesPage() { + redirect("/admin/payments"); +} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 962a1656..bf3b1f10 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -1,45 +1,5 @@ -"use client"; - -import Link from "next/link"; -import { useEffect, useState } from "react"; - -type Order = { orderNo: string; userEmail: string | null; packageName: string | null; moneyCents: number; credits: number; status: string; epayTradeNo: string | null; createdAt: string; paidAt: string | null }; -type Stats = { totalOrders: number; paidOrders: number; pendingOrders: number; failedExpiredOrders: number; paidAmountCents: number; grantedCredits: number }; -const initialStats: Stats = { totalOrders: 0, paidOrders: 0, pendingOrders: 0, failedExpiredOrders: 0, paidAmountCents: 0, grantedCredits: 0 }; -const statusLabels: Record = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" }; -const dateFormatter = new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Shanghai" }); - -function formatDate(value: string | null) { return value ? dateFormatter.format(new Date(value)) : "—"; } -function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } +import PaymentManagement from "@/components/admin/payment-management"; export default function AdminPaymentsPage() { - const [orders, setOrders] = useState([]); - const [stats, setStats] = useState(initialStats); - const [status, setStatus] = useState(""); - const [from, setFrom] = useState(""); - const [to, setTo] = useState(""); - const [offset, setOffset] = useState(0); - const [total, setTotal] = useState(0); - const [hasMore, setHasMore] = useState(false); - const [error, setError] = useState(""); - const limit = 20; - - useEffect(() => { - const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); - if (status) params.set("status", status); - if (from) params.set("from", new Date(`${from}T00:00:00+08:00`).toISOString()); - if (to) params.set("to", new Date(`${to}T23:59:59.999+08:00`).toISOString()); - void fetch(`/api/admin/payments?${params}`, { cache: "no-store" }).then(async (response) => { - const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || "读取支付记录失败"); - setOrders(payload.orders); setStats(payload.stats); setTotal(payload.pagination.total); setHasMore(payload.pagination.hasMore); setError(""); - }).catch((caught) => setError(caught instanceof Error ? caught.message : "读取支付记录失败")); - }, [status, from, to, offset]); - - function filterChange(setter: (value: string) => void, value: string) { setter(value); setOffset(0); } - - return

支付记录

充值套餐 兑换码管理 返回对话
-

平台支付统计

统计范围按订单创建时间筛选,金额为人民币。

总订单{stats.totalOrders}
已支付{stats.paidOrders}
待支付{stats.pendingOrders}
失败/过期{stats.failedExpiredOrders}
已支付金额{formatMoney(stats.paidAmountCents)}
已赠送点数{stats.grantedCredits}
-

支付记录

{total} 条平台订单

{error &&

{error}

}
{orders.map((order) => )}{orders.length === 0 && }
订单号用户邮箱套餐金额点数状态易支付交易号创建时间支付时间
{order.orderNo}{order.userEmail || "—"}{order.packageName || "—"}{formatMoney(order.moneyCents)}{order.credits}{statusLabels[order.status] || order.status}{order.epayTradeNo || "—"}{formatDate(order.createdAt)}{formatDate(order.paidAt)}
暂无支付记录
第 {total ? offset + 1 : 0}–{Math.min(offset + orders.length, total)} 条
-
; + return ; } diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index d6363921..57bd63d9 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -1,18 +1,19 @@ "use client"; import { + ArrowLeftOutlined, AuditOutlined, CreditCardOutlined, GiftOutlined, MessageOutlined, - SettingOutlined, TeamOutlined, TransactionOutlined, } from "@ant-design/icons"; import { Authenticated, Refine } from "@refinedev/core"; -import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd"; +import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd"; import routerProvider from "@refinedev/nextjs-router"; -import { App as AntdApp, ConfigProvider, Spin, theme } from "antd"; +import { App as AntdApp, ConfigProvider, Menu, Spin, theme } from "antd"; +import Link from "next/link"; import type { ReactNode } from "react"; import { @@ -21,6 +22,21 @@ import { adminDataProvider, } from "@/lib/admin/providers"; +function AdminSider() { + return ( + ( + <> + {items} + } title="返回对话"> + {collapsed ? null : "返回对话"} + + + )} + /> + ); +} + export function AdminApp({ children }: { children: ReactNode }) { const notificationProvider = useNotificationProvider(); return ( @@ -34,8 +50,7 @@ export function AdminApp({ children }: { children: ReactNode }) { notificationProvider={notificationProvider} resources={[ { name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: } }, - { name: "payments", list: "/admin/payments", meta: { label: "支付记录", icon: } }, - { name: "packages", list: "/admin/packages", meta: { label: "支付配置", icon: } }, + { name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: } }, { name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: } }, { name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: } }, { name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: } }, @@ -51,7 +66,7 @@ export function AdminApp({ children }: { children: ReactNode }) { key="admin-authenticated" loading={
正在验证后台权限
} > - {children} + {children} diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx new file mode 100644 index 00000000..0517bc39 --- /dev/null +++ b/frontend/src/components/admin/payment-management.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { PlusOutlined } from "@ant-design/icons"; +import { List } from "@refinedev/antd"; +import { + Alert, + App, + Button, + Card, + Col, + DatePicker, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Space, + Statistic, + Switch, + Table, + Tag, + Typography, + type TableColumnsType, +} from "antd"; +import type { Dayjs } from "dayjs"; +import { useCallback, useEffect, useState } from "react"; + +const { Text } = Typography; + +type Order = { + orderNo: string; + userEmail: string | null; + packageName: string | null; + moneyCents: number; + credits: number; + status: string; + epayTradeNo: string | null; + createdAt: string; + paidAt: string | null; +}; + +type PaymentStats = { + totalOrders: number; + paidOrders: number; + pendingOrders: number; + failedExpiredOrders: number; + paidAmountCents: number; + grantedCredits: number; +}; + +type PaymentPackage = { + id: string; + name: string; + description: string; + priceCents: number; + credits: number; + sortOrder: number; + enabled: boolean; +}; + +type PackageFormValues = Omit & { priceYuan: number }; +type PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] }; + +const initialStats: PaymentStats = { + totalOrders: 0, + paidOrders: 0, + pendingOrders: 0, + failedExpiredOrders: 0, + paidAmountCents: 0, + grantedCredits: 0, +}; +const statusLabels: Record = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" }; +const statusColors: Record = { pending: "gold", paid: "green", failed: "red", expired: "default" }; +const dateFormatter = new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Shanghai" }); +const pageSize = 20; + +function formatDate(value: string | null) { + return value ? dateFormatter.format(new Date(value)) : "—"; +} + +function formatMoney(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +async function responsePayload(response: Response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "请求失败"); + return payload; +} + +export default function PaymentManagement() { + const { message } = App.useApp(); + const [filterForm] = Form.useForm(); + const [packageForm] = Form.useForm(); + const [orders, setOrders] = useState([]); + const [stats, setStats] = useState(initialStats); + const [paymentLoading, setPaymentLoading] = useState(true); + const [paymentError, setPaymentError] = useState(""); + const [filters, setFilters] = useState({}); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [packages, setPackages] = useState([]); + const [packagesLoading, setPackagesLoading] = useState(true); + const [packagesError, setPackagesError] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [editingPackage, setEditingPackage] = useState(null); + const [saving, setSaving] = useState(false); + const [disablingId, setDisablingId] = useState(null); + + const loadPayments = useCallback(async () => { + setPaymentLoading(true); + setPaymentError(""); + const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); + if (filters.status) params.set("status", filters.status); + if (filters.dates?.[0]) params.set("from", filters.dates[0].startOf("day").toISOString()); + if (filters.dates?.[1]) params.set("to", filters.dates[1].endOf("day").toISOString()); + try { + const payload = await responsePayload(await fetch(`/api/admin/payments?${params}`, { cache: "no-store" })); + setOrders(payload.orders); + setStats(payload.stats); + setTotal(payload.pagination.total); + } catch (error) { + setPaymentError(error instanceof Error ? error.message : "读取支付记录失败"); + } finally { + setPaymentLoading(false); + } + }, [filters, offset]); + + const loadPackages = useCallback(async () => { + setPackagesLoading(true); + setPackagesError(""); + try { + const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" })); + setPackages(payload.packages); + } catch (error) { + setPackagesError(error instanceof Error ? error.message : "读取套餐失败"); + } finally { + setPackagesLoading(false); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => void loadPayments(), 0); + return () => window.clearTimeout(timer); + }, [loadPayments]); + useEffect(() => { + const timer = window.setTimeout(() => void loadPackages(), 0); + return () => window.clearTimeout(timer); + }, [loadPackages]); + + function openCreateModal() { + setEditingPackage(null); + packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }); + setModalOpen(true); + } + + function openEditModal(item: PaymentPackage) { + setEditingPackage(item); + packageForm.setFieldsValue({ + name: item.name, + description: item.description, + priceYuan: item.priceCents / 100, + credits: item.credits, + sortOrder: item.sortOrder, + enabled: item.enabled, + }); + setModalOpen(true); + } + + function closeModal() { + if (saving) return; + setModalOpen(false); + setEditingPackage(null); + packageForm.resetFields(); + } + + async function savePackage(values: PackageFormValues) { + setSaving(true); + try { + await responsePayload(await fetch("/api/admin/packages", { + method: editingPackage ? "PATCH" : "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...(editingPackage ? { id: editingPackage.id } : {}), + name: values.name.trim(), + description: values.description?.trim() ?? "", + priceCents: Math.round(values.priceYuan * 100), + credits: values.credits, + sortOrder: values.sortOrder, + enabled: values.enabled, + }), + })); + message.success(editingPackage ? "套餐已更新" : "套餐已添加"); + setModalOpen(false); + setEditingPackage(null); + packageForm.resetFields(); + await loadPackages(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存套餐失败"); + } finally { + setSaving(false); + } + } + + async function disablePackage(id: string) { + setDisablingId(id); + try { + await responsePayload(await fetch("/api/admin/packages", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id }), + })); + message.success("套餐已停用"); + await loadPackages(); + } catch (error) { + message.error(error instanceof Error ? error.message : "停用套餐失败"); + } finally { + setDisablingId(null); + } + } + + const orderColumns: TableColumnsType = [ + { title: "订单号", dataIndex: "orderNo", render: (value) => {value} }, + { title: "用户邮箱", dataIndex: "userEmail", render: (value) => value || "—" }, + { title: "套餐", dataIndex: "packageName", render: (value) => value || "—" }, + { title: "金额", dataIndex: "moneyCents", align: "right", render: formatMoney }, + { title: "点数", dataIndex: "credits", align: "right" }, + { title: "状态", dataIndex: "status", render: (value) => {statusLabels[value] || value} }, + { title: "易支付交易号", dataIndex: "epayTradeNo", render: (value) => value || "—" }, + { title: "创建时间", dataIndex: "createdAt", render: formatDate }, + { title: "支付时间", dataIndex: "paidAt", render: formatDate }, + ]; + const packageColumns: TableColumnsType = [ + { title: "名称", dataIndex: "name", render: (_, item) => {item.name}{item.description || "暂无描述"} }, + { title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney }, + { title: "点数", dataIndex: "credits", align: "right" }, + { title: "排序", dataIndex: "sortOrder", align: "right" }, + { title: "状态", dataIndex: "enabled", render: (enabled) => {enabled ? "启用" : "停用"} }, + { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && disablePackage(item.id)}>} }, + ]; + + return ( + + + + + + + + + + + + + + 共 {total} 条平台订单}> + +
{ setOffset(0); setFilters(values); }}> + + + + + + + + + + +
+ +
+ ); +} diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 61911d3f..003999d8 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -44,11 +44,20 @@ test("self-hosted account entry checks only the persisted admin role", () => { assert.match(auth, /authorizeAdminAccess\(user, access\)/); }); -test("admin navigation exposes payment records and payment configuration", () => { - assert.match(adminApp, /name: "payments", list: "\/admin\/payments", meta: \{ label: "支付记录"/); - assert.match(adminApp, /name: "packages", list: "\/admin\/packages", meta: \{ label: "支付配置"/); +test("admin navigation exposes one unified payment resource", () => { + assert.match(adminApp, /name: "payments", list: "\/admin\/payments", meta: \{ label: "支付管理"/); + assert.doesNotMatch(adminApp, /name: "packages"|list: "\/admin\/packages"|SettingOutlined/); assert.match(adminApp, /CreditCardOutlined/); - assert.match(adminApp, /SettingOutlined/); +}); + +test("admin sider replaces logout with a collapsed-aware return-to-chat link", () => { + assert.match(adminApp, /ThemedLayout Sider=\{AdminSider\}/); + assert.match(adminApp, /ThemedSider/); + assert.match(adminApp, /render=\{\(\{ items, collapsed \}\)/); + assert.match(adminApp, /\{items\}/); + assert.match(adminApp, /ArrowLeftOutlined/); + assert.match(adminApp, /\{collapsed \? null : "返回对话"\}<\/Link>/); + assert.doesNotMatch(adminApp, /logout\s*[,(}]|authProvider\.logout/); }); test("admin pages and root route are server-gated before rendering or redirecting", () => { diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts index 13634de1..4f046847 100644 --- a/frontend/tests/admin-payments-contract.test.ts +++ b/frontend/tests/admin-payments-contract.test.ts @@ -4,12 +4,15 @@ import test from "node:test"; const root = new URL("../", import.meta.url); const route = readFileSync(new URL("src/app/api/admin/payments/route.ts", root), "utf8"); +const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8"); const page = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8"); const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", root), "utf8"); +const management = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8"); const migration = readFileSync(new URL("supabase/migrations/20260727030000_payment_admin_stats.sql", root), "utf8"); test("支付后台接口只允许管理员并查询平台订单", () => { - assert.match(route, /isAdminUser\(user\)/); + assert.match(route, /requireAdminSession\("read"\)/); + assert.doesNotMatch(route, /isAdminUser\(user\)/); assert.match(route, /from\("payment_orders"\)/); assert.match(route, /auth\.admin\.getUserById/); assert.match(route, /payment_packages\(name\)/); @@ -28,11 +31,41 @@ test("支付接口包含筛选、统计和分页契约", () => { assert.match(migration, /status = 'paid'/); }); -test("后台支付页面与现有套餐页有入口", () => { - assert.match(page, /平台支付统计/); - assert.match(page, /支付记录/); - assert.match(page, /paidAmountCents/); - assert.match(page, /上一页/); - assert.match(page, /下一页/); - assert.match(packagesPage, /href="\/admin\/payments"/); +test("统一支付管理页面使用 Ant Design 与 Refine 并保留支付数据合同", () => { + assert.match(page, /PaymentManagement/); + for (const component of ["List", "Card", "Table", "Form", "Tag", "Alert", "Statistic"]) { + assert.match(management, new RegExp(`\\b${component}\\b`)); + } + assert.match(management, /\/api\/admin\/payments/); + assert.match(management, /\/api\/admin\/packages/); + for (const field of ["orderNo", "userEmail", "packageName", "moneyCents", "credits", "status", "epayTradeNo", "createdAt", "paidAt"]) { + assert.match(management, new RegExp(field)); + } + assert.match(management, /paymentLoading/); + assert.match(management, /paymentError/); + assert.match(management, /pagination=\{\{/); + assert.doesNotMatch(management, /standalone-page|admin-header|admin-section/); +}); + +test("套餐列表、Modal 与新增编辑停用操作收敛到支付管理页", () => { + assert.match(management, /\}>/); + assert.doesNotMatch(management, / { + assert.match(packagesPage, /import \{ redirect \} from "next\/navigation"/); + assert.match(packagesPage, /redirect\("\/admin\/payments"\)/); + assert.doesNotMatch(packagesPage, /use client|