Files
Jyotisha/frontend/src/components/admin/payment-management.tsx
T
Jesse_Chen 945d61fa1c
Independent Staging Quality Gate / validate (push) Failing after 12m38s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(admin): remove redundant confirmations and repair code access
2026-08-16 17:55:15 +08:00

277 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { List } from "@refinedev/antd";
import { useGetIdentity } from "@refinedev/core";
import {
Alert,
App,
Button,
Card,
Col,
Collapse,
DatePicker,
Form,
Input,
Row,
Select,
Space,
Statistic,
Switch,
Table,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import type { Dayjs } from "dayjs";
import { useCallback, useEffect, useState } from "react";
import type { AdminIdentity } from "@/lib/admin/providers";
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 PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] };
type EpaySettings = {
gatewayUrl: string;
pid: string;
notifyUrl: string;
returnUrl: string;
siteName: string;
chatEnabled: boolean;
keyConfigured: boolean;
complete: boolean;
source: "database" | "environment" | "unconfigured";
};
type EpaySettingsForm = Pick<EpaySettings, "gatewayUrl" | "pid" | "notifyUrl" | "returnUrl" | "siteName" | "chatEnabled"> & { newKey?: string };
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 { data: identity } = useGetIdentity<AdminIdentity>();
const [filterForm] = Form.useForm<PaymentFilters>();
const [epayForm] = Form.useForm<EpaySettingsForm>();
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 [epaySettings, setEpaySettings] = useState<EpaySettings | null>(null);
const [epayLoading, setEpayLoading] = useState(true);
const [epaySaving, setEpaySaving] = useState(false);
const [epayTesting, setEpayTesting] = useState(false);
const [epayError, setEpayError] = useState("");
const canAdjustBilling = Boolean(identity?.permissions.includes("billing.adjustments.write"));
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 loadEpaySettings = useCallback(async () => {
setEpayLoading(true);
setEpayError("");
try {
const payload: EpaySettings = await responsePayload(await fetch("/api/admin/epay-settings", { cache: "no-store" }));
setEpaySettings(payload);
epayForm.setFieldsValue({
gatewayUrl: payload.gatewayUrl,
pid: payload.pid,
notifyUrl: payload.notifyUrl,
returnUrl: payload.returnUrl,
siteName: payload.siteName,
chatEnabled: payload.chatEnabled,
newKey: "",
});
} catch (error) {
setEpayError(error instanceof Error ? error.message : "读取易支付配置失败");
} finally {
setEpayLoading(false);
}
}, [epayForm]);
useEffect(() => {
const timer = window.setTimeout(() => void loadPayments(), 0);
return () => window.clearTimeout(timer);
}, [loadPayments]);
useEffect(() => {
const timer = window.setTimeout(() => void loadEpaySettings(), 0);
return () => window.clearTimeout(timer);
}, [loadEpaySettings]);
async function testEpayAvailability() {
setEpayTesting(true);
try {
const payload = await responsePayload(await fetch("/api/admin/epay-settings/test", { method: "POST" }));
if (payload.available) message.success(`${payload.message}${payload.status}${payload.latencyMs}ms`);
else message.error(payload.message || "当前已保存的易支付配置暂不可用");
} catch (error) {
message.error(error instanceof Error ? error.message : "当前已保存的易支付配置暂不可用");
} finally {
setEpayTesting(false);
}
}
async function saveEpaySettings(values: EpaySettingsForm) {
setEpaySaving(true);
try {
await responsePayload(await fetch("/api/admin/epay-settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...values, newKey: values.newKey || undefined }),
}));
epayForm.setFieldValue("newKey", "");
message.success("Z-Pay(易支付)配置已保存");
await loadEpaySettings();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存易支付配置失败");
} finally {
setEpaySaving(false);
}
}
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 },
];
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>
<Collapse
defaultActiveKey={[]}
items={[{
key: "epay-settings",
label: "Z-Pay(易支付)渠道配置",
children: (
<Card
loading={epayLoading}
extra={canAdjustBilling ? <Space><Button disabled={!epaySettings?.keyConfigured || !epaySettings.complete} loading={epayTesting} onClick={() => void testEpayAvailability()}></Button><Button type="primary" loading={epaySaving} onClick={() => epayForm.submit()}></Button></Space> : null}
>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Text type="secondary"> Z-Pay / </Text>
{epaySettings && <Text type="secondary">{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]} · {epaySettings.complete ? "配置完整" : "配置不完整"} · {epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"} · {epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}</Text>}
{epayError && <Alert type="error" showIcon message="易支付配置读取失败" description={epayError} action={<Button size="small" onClick={() => void loadEpaySettings()}></Button>} />}
<Alert type="info" showIcon message="商户密钥不会回显" description="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="gatewayUrl" label="网关地址" rules={[{ required: true, message: "请输入网关地址" }, { type: "url", message: "请输入有效 URL" }]}><Input placeholder="https://pay.example.com" /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="pid" label="商户 ID" rules={[{ required: true, message: "请输入商户 ID" }, { max: 200 }]}><Input /></Form.Item></Col>
</Row>
<Form.Item name="newKey" label="商户密钥" extra="留空保持当前数据库密钥;系统绝不预填或回显密钥。"><Input.Password autoComplete="new-password" placeholder="留空保持原密钥" /></Form.Item>
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="notifyUrl" label="异步通知地址" rules={[{ required: true, message: "请输入异步通知地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="returnUrl" label="支付完成返回地址" rules={[{ required: true, message: "请输入支付完成返回地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
</Row>
<Form.Item name="siteName" label="网站名称" rules={[{ required: true, message: "请输入网站名称" }, { max: 100 }]}><Input /></Form.Item>
<Form.Item name="chatEnabled" label="在对话页开放支付" valuePropName="checked"><Switch checkedChildren="开放" unCheckedChildren="关闭" /></Form.Item>
</Form>
</Space>
</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>
</Space>
</List>
);
}