feat: unify payment administration
Deploy staging to test server / deploy (push) Successful in 3m28s

This commit is contained in:
linmeng
2026-07-29 13:40:10 +08:00
parent 43c8b59c24
commit 9a20b748e0
7 changed files with 405 additions and 81 deletions
+10 -9
View File
@@ -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
- 修复版本:待提交(本地可测)
+5 -12
View File
@@ -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<Package[]>([]); const [form, setForm] = useState(empty); const [editing, setEditing] = useState<string | null>(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 <main className="standalone-page admin-page"><header className="admin-header"><h1></h1><div><Link className="button-secondary" href="/admin/codes"></Link> <Link className="button-secondary" href="/admin/payments"></Link> <Link className="button-secondary" href="/"></Link></div></header><div className="admin-scroll"><section className="admin-section"><div className="section-title"><div><h2>{editing ? "编辑套餐" : "添加套餐"}</h2><p></p></div></div><form className="code-form" onSubmit={save}><label><span></span><input required maxLength={80} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label><label><span></span><input type="number" min={1} required value={form.priceCents} onChange={(e) => setForm({ ...form, priceCents: Number(e.target.value) })} /></label><label><span></span><input type="number" min={1} required value={form.credits} onChange={(e) => setForm({ ...form, credits: Number(e.target.value) })} /></label><label><span></span><input type="number" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} /></label><label className="note-field"><span></span><input maxLength={500} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label><label><span></span><input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} /></label><button className="button-primary" type="submit">{editing ? "保存修改" : "添加套餐"}</button></form>{error && <p className="form-error" role="alert">{error}</p>}</section><section className="admin-section"><div className="section-title"><div><h2></h2><p>{items.length} </p></div></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td>{item.name}<br /><small>{item.description}</small></td><td>¥{(item.priceCents / 100).toFixed(2)}</td><td>{item.credits}</td><td>{item.enabled ? "启用" : "停用"}</td><td><button className="button-secondary" type="button" onClick={() => { setEditing(item.id); setForm({ name: item.name, description: item.description, priceCents: item.priceCents, credits: item.credits, sortOrder: item.sortOrder, enabled: item.enabled }); }}></button> {item.enabled && <button className="button-secondary" type="button" onClick={() => void disable(item.id)}></button>}</td></tr>)}</tbody></table></div></section></div></main>; }
import { redirect } from "next/navigation";
export default function AdminPackagesPage() {
redirect("/admin/payments");
}
+2 -42
View File
@@ -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<string, string> = { 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<Order[]>([]);
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 <main className="standalone-page admin-page"><header className="admin-header"><h1></h1><div><Link className="button-secondary" href="/admin/packages"></Link> <Link className="button-secondary" href="/admin/codes"></Link> <Link className="button-secondary" href="/"></Link></div></header><div className="admin-scroll">
<section className="admin-section"><div className="section-title"><div><h2></h2><p></p></div></div><div className="payment-stats"><div><span></span><strong>{stats.totalOrders}</strong></div><div><span></span><strong>{stats.paidOrders}</strong></div><div><span></span><strong>{stats.pendingOrders}</strong></div><div><span>/</span><strong>{stats.failedExpiredOrders}</strong></div><div><span></span><strong>{formatMoney(stats.paidAmountCents)}</strong></div><div><span></span><strong>{stats.grantedCredits}</strong></div></div></section>
<section className="admin-section"><div className="section-title"><div><h2></h2><p>{total} </p></div></div><div className="payment-filters"><label><span></span><input type="date" value={from} onChange={(e) => filterChange(setFrom, e.target.value)} /></label><label><span></span><input type="date" value={to} onChange={(e) => filterChange(setTo, e.target.value)} /></label><label><span></span><select value={status} onChange={(e) => filterChange(setStatus, e.target.value)}><option value=""></option><option value="paid"></option><option value="pending"></option><option value="failed"></option><option value="expired"></option></select></label></div>{error && <p className="form-error" role="alert">{error}</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>{orders.map((order) => <tr key={order.orderNo}><td><code>{order.orderNo}</code></td><td>{order.userEmail || "—"}</td><td>{order.packageName || "—"}</td><td>{formatMoney(order.moneyCents)}</td><td>{order.credits}</td><td>{statusLabels[order.status] || order.status}</td><td>{order.epayTradeNo || "—"}</td><td>{formatDate(order.createdAt)}</td><td>{formatDate(order.paidAt)}</td></tr>)}{orders.length === 0 && <tr><td colSpan={9} className="empty-cell"></td></tr>}</tbody></table></div><div className="payment-pagination"><span> {total ? offset + 1 : 0}{Math.min(offset + orders.length, total)} </span><button className="button-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button className="button-secondary" disabled={!hasMore} onClick={() => setOffset(offset + limit)}></button></div></section>
</div></main>;
return <PaymentManagement />;
}
+21 -6
View File
@@ -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 (
<ThemedSider
render={({ items, collapsed }) => (
<>
{items}
<Menu.Item key="return-to-chat" icon={<ArrowLeftOutlined />} title="返回对话">
<Link href="/" aria-label="返回对话">{collapsed ? null : "返回对话"}</Link>
</Menu.Item>
</>
)}
/>
);
}
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: <GiftOutlined /> } },
{ name: "payments", list: "/admin/payments", meta: { label: "支付记录", icon: <CreditCardOutlined /> } },
{ name: "packages", list: "/admin/packages", meta: { label: "支付配置", icon: <SettingOutlined /> } },
{ name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: <CreditCardOutlined /> } },
{ name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: <TeamOutlined /> } },
{ name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
{ name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
@@ -51,7 +66,7 @@ export function AdminApp({ children }: { children: ReactNode }) {
key="admin-authenticated"
loading={<div className="admin-loading"><Spin size="large" /><span></span></div>}
>
<ThemedLayout>{children}</ThemedLayout>
<ThemedLayout Sider={AdminSider}>{children}</ThemedLayout>
</Authenticated>
</Refine>
</AntdApp>
@@ -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<PaymentPackage, "id" | "priceCents"> & { 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<string, string> = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" };
const statusColors: Record<string, string> = { 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<PaymentFilters>();
const [packageForm] = Form.useForm<PackageFormValues>();
const [orders, setOrders] = useState<Order[]>([]);
const [stats, setStats] = useState(initialStats);
const [paymentLoading, setPaymentLoading] = useState(true);
const [paymentError, setPaymentError] = useState("");
const [filters, setFilters] = useState<PaymentFilters>({});
const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0);
const [packages, setPackages] = useState<PaymentPackage[]>([]);
const [packagesLoading, setPackagesLoading] = useState(true);
const [packagesError, setPackagesError] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [editingPackage, setEditingPackage] = useState<PaymentPackage | null>(null);
const [saving, setSaving] = useState(false);
const [disablingId, setDisablingId] = useState<string | null>(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<Order> = [
{ title: "订单号", dataIndex: "orderNo", render: (value) => <Text code copyable>{value}</Text> },
{ 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) => <Tag color={statusColors[value]}>{statusLabels[value] || value}</Tag> },
{ title: "易支付交易号", dataIndex: "epayTradeNo", render: (value) => value || "—" },
{ title: "创建时间", dataIndex: "createdAt", render: formatDate },
{ title: "支付时间", dataIndex: "paidAt", render: formatDate },
];
const packageColumns: TableColumnsType<PaymentPackage> = [
{ title: "名称", dataIndex: "name", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.description || "暂无描述"}</Text></Space> },
{ title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney },
{ title: "点数", dataIndex: "credits", align: "right" },
{ title: "排序", dataIndex: "sortOrder", align: "right" },
{ title: "状态", dataIndex: "enabled", render: (enabled) => <Tag color={enabled ? "green" : "default"}>{enabled ? "启用" : "停用"}</Tag> },
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}></Button>{item.enabled && <Popconfirm title="停用此套餐?" description="停用后用户将无法继续购买。" okText="停用" cancelText="取消" onConfirm={() => disablePackage(item.id)}><Button type="link" danger loading={disablingId === item.id}></Button></Popconfirm>}</Space> },
];
return (
<List title="支付管理">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Card title="支付概览">
<Row gutter={[16, 16]}>
<Col xs={12} md={8} xl={4}><Statistic title="总订单" value={stats.totalOrders} /></Col>
<Col xs={12} md={8} xl={4}><Statistic title="已支付" value={stats.paidOrders} /></Col>
<Col xs={12} md={8} xl={4}><Statistic title="待支付" value={stats.pendingOrders} /></Col>
<Col xs={12} md={8} xl={4}><Statistic title="失败 / 过期" value={stats.failedExpiredOrders} /></Col>
<Col xs={12} md={8} xl={4}><Statistic title="已支付金额" value={stats.paidAmountCents / 100} precision={2} prefix="¥" /></Col>
<Col xs={12} md={8} xl={4}><Statistic title="已赠送点数" value={stats.grantedCredits} /></Col>
</Row>
</Card>
<Card title="支付记录" extra={<Text type="secondary"> {total} </Text>}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form form={filterForm} layout="inline" onValuesChange={(_, values) => { setOffset(0); setFilters(values); }}>
<Form.Item name="dates" label="创建日期"><DatePicker.RangePicker allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear placeholder="全部状态" style={{ minWidth: 140 }} options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Button onClick={() => { filterForm.resetFields(); setOffset(0); setFilters({}); }}></Button>
</Form>
{paymentError && <Alert type="error" showIcon message="支付记录读取失败" description={paymentError} action={<Button size="small" onClick={() => void loadPayments()}></Button>} />}
<Table<Order>
rowKey="orderNo"
columns={orderColumns}
dataSource={orders}
loading={paymentLoading}
scroll={{ x: "max-content" }}
pagination={{ current: Math.floor(offset / pageSize) + 1, pageSize, total, showSizeChanger: false, showTotal: (count, range) => `${range[0]}${range[1]} 条,共 ${count}`, onChange: (page) => setOffset((page - 1) * pageSize) }}
/>
</Space>
</Card>
<Card title="套餐列表" extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}></Button>}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
{packagesError && <Alert type="error" showIcon message="套餐列表读取失败" description={packagesError} action={<Button size="small" onClick={() => void loadPackages()}></Button>} />}
<Table<PaymentPackage> rowKey="id" columns={packageColumns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} />
</Space>
</Card>
</Space>
<Modal
title={editingPackage ? "编辑套餐" : "添加套餐"}
open={modalOpen}
okText={editingPackage ? "保存修改" : "添加套餐"}
cancelText="取消"
confirmLoading={saving}
onOk={() => packageForm.submit()}
onCancel={closeModal}
destroyOnHidden
afterClose={() => packageForm.resetFields()}
maskClosable={!saving}
keyboard={!saving}
>
<Form<PackageFormValues> form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入套餐名称" }, { max: 80 }]}><Input autoFocus /></Form.Item>
<Row gutter={16}>
<Col span={12}><Form.Item name="priceYuan" label="价格(元)" extra="保存时转换为 API 的 priceCents" rules={[{ required: true, message: "请输入价格" }]}><InputNumber min={0.01} max={1_000_000} precision={2} step={1} style={{ width: "100%" }} prefix="¥" /></Form.Item></Col>
<Col span={12}><Form.Item name="credits" label="点数" rules={[{ required: true, message: "请输入点数" }]}><InputNumber min={1} max={10_000_000} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Form.Item name="sortOrder" label="排序" rules={[{ required: true, message: "请输入排序" }]}><InputNumber min={-100_000} max={100_000} precision={0} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="description" label="描述" rules={[{ max: 500 }]}><Input.TextArea rows={3} showCount maxLength={500} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item>
</Form>
</Modal>
</List>
);
}
+13 -4
View File
@@ -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, /<Link href="\/" aria-label="返回对话">\{collapsed \? null : "返回对话"\}<\/Link>/);
assert.doesNotMatch(adminApp, /logout\s*[,(}]|authProvider\.logout/);
});
test("admin pages and root route are server-gated before rendering or redirecting", () => {
+41 -8
View File
@@ -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, /<Card title="套餐列表" extra=\{<Button[\s\S]*?添加套餐<\/Button>\}>/);
assert.doesNotMatch(management, /<List[\s\S]{0,120}headerButtons/);
assert.match(management, /<Modal/);
assert.match(management, /title=\{editingPackage \? "编辑套餐" : "添加套餐"\}/);
assert.match(management, /价格(元)/);
assert.match(management, /priceCents: Math\.round\(values\.priceYuan \* 100\)/);
for (const method of ["POST", "PATCH", "DELETE"]) assert.match(management, new RegExp(`method: .*${method}`));
assert.match(management, /message\.success/);
assert.match(management, /message\.error/);
assert.match(management, /setPackagesLoading/);
assert.match(management, /packagesError/);
assert.match(management, /packageForm\.resetFields\(\)/);
assert.match(packagesRoute, /requireAdminSession\("read"\)/);
assert.match(packagesRoute, /requireAdminSession\("write"\)/);
});
test("旧套餐路由服务端重定向到统一支付管理页", () => {
assert.match(packagesPage, /import \{ redirect \} from "next\/navigation"/);
assert.match(packagesPage, /redirect\("\/admin\/payments"\)/);
assert.doesNotMatch(packagesPage, /use client|<form|\/api\/admin\/packages/);
});