feat: unify payment administration
Deploy staging to test server / deploy (push) Successful in 3m28s
Deploy staging to test server / deploy (push) Successful in 3m28s
This commit is contained in:
+10
-9
@@ -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
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user