277 lines
13 KiB
TypeScript
277 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|