feat(payment): 支付管理 - 新增支付订单与对账 CSV 导出
This commit is contained in:
@@ -6,12 +6,15 @@ import { Switch } from "@/pages/admin/ui/controls";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { Eye, Plus, RefreshCw, Search, Settings2, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PaginationBar } from "@/pages/admin/components/admin-ui";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import {
|
||||
closeAdminPaymentOrder,
|
||||
exportAdminPaymentOrders,
|
||||
exportAdminPaymentReconciliations,
|
||||
exportAdminPaymentReconciliationItems,
|
||||
createAdminTopupProduct,
|
||||
listAdminPaymentOrders,
|
||||
listAdminPaymentProviders,
|
||||
@@ -25,13 +28,15 @@ import {
|
||||
type AdminPaymentProvider,
|
||||
type AdminPaymentOrder,
|
||||
type PaymentOrder,
|
||||
type PaymentOrderFilters,
|
||||
type PaymentReconciliationFilters,
|
||||
type PaymentReconciliationItem,
|
||||
type PaymentReconciliationRun,
|
||||
type TopupProduct,
|
||||
} from "@/services/api/payments";
|
||||
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
|
||||
import { AdminDataTable, AdminExportButton, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
|
||||
import { AdminUserDetailDrawer } from "../components/admin-user-detail-drawer";
|
||||
import "./payments-page.css";
|
||||
|
||||
@@ -92,6 +97,12 @@ export default function AdminPaymentsPage() {
|
||||
const [orderPageSize, setOrderPageSize] = useState(30);
|
||||
const [orderStatusFilter, setOrderStatusFilter] = useState("all");
|
||||
const [orderKeyword, setOrderKeyword] = useState("");
|
||||
const [orderProviderFilter, setOrderProviderFilter] = useState("all");
|
||||
const [orderTimeField, setOrderTimeField] = useState<"created" | "paid" | "credited">("created");
|
||||
const [orderDates, setOrderDates] = useState<[Dayjs | null, Dayjs | null] | null>(null);
|
||||
const [appliedOrders, setAppliedOrders] = useState<PaymentOrderFilters>({ timeField: "created" });
|
||||
const [ordersReady, setOrdersReady] = useState(false);
|
||||
const orderRequest = useRef<AbortController | null>(null);
|
||||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||
const [orderActionId, setOrderActionId] = useState("");
|
||||
|
||||
@@ -101,6 +112,11 @@ export default function AdminPaymentsPage() {
|
||||
const [runPageSize, setRunPageSize] = useState(30);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
const [runProviderFilter, setRunProviderFilter] = useState("all");
|
||||
const [runStatusFilter, setRunStatusFilter] = useState("all");
|
||||
const [runDates, setRunDates] = useState<[Dayjs | null, Dayjs | null] | null>(null);
|
||||
const [appliedRuns, setAppliedRuns] = useState<PaymentReconciliationFilters>({});
|
||||
const [runsReady, setRunsReady] = useState(false);
|
||||
const runRequest = useRef<AbortController | null>(null);
|
||||
const [runningBill, setRunningBill] = useState(false);
|
||||
const [billProviderId, setBillProviderId] = useState("");
|
||||
const [billDate, setBillDate] = useState<Dayjs>(dayjs().subtract(1, "day"));
|
||||
@@ -111,6 +127,25 @@ export default function AdminPaymentsPage() {
|
||||
const [detailPageSize, setDetailPageSize] = useState(50);
|
||||
const [detailResult, setDetailResult] = useState("all");
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailReady, setDetailReady] = useState(false);
|
||||
const detailRequest = useRef<AbortController | null>(null);
|
||||
|
||||
const orderFilters: PaymentOrderFilters = {
|
||||
timeField: orderTimeField,
|
||||
status: orderStatusFilter === "all" ? undefined : orderStatusFilter,
|
||||
keyword: orderKeyword.trim() || undefined,
|
||||
providerId: orderProviderFilter === "all" ? undefined : orderProviderFilter,
|
||||
from: orderDates?.[0]?.format("YYYY-MM-DD"),
|
||||
to: orderDates?.[1]?.format("YYYY-MM-DD"),
|
||||
};
|
||||
const runFilters: PaymentReconciliationFilters = {
|
||||
providerId: runProviderFilter === "all" ? undefined : runProviderFilter,
|
||||
status: runStatusFilter === "all" ? undefined : runStatusFilter,
|
||||
from: runDates?.[0]?.format("YYYY-MM-DD"),
|
||||
to: runDates?.[1]?.format("YYYY-MM-DD"),
|
||||
};
|
||||
const ordersDirty = JSON.stringify(orderFilters) !== JSON.stringify(appliedOrders);
|
||||
const runsDirty = JSON.stringify(runFilters) !== JSON.stringify(appliedRuns);
|
||||
|
||||
const loadBase = async () => {
|
||||
setLoading(true);
|
||||
@@ -126,38 +161,57 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrders = async (page = orderPage, pageSize = orderPageSize) => {
|
||||
const loadOrders = async (page = orderPage, pageSize = orderPageSize, filters = appliedOrders) => {
|
||||
orderRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
orderRequest.current = controller;
|
||||
setOrdersLoading(true);
|
||||
setOrdersReady(false);
|
||||
try {
|
||||
const result = await listAdminPaymentOrders({ status: orderStatusFilter === "all" ? undefined : orderStatusFilter, keyword: orderKeyword.trim() || undefined, page, pageSize });
|
||||
const result = await listAdminPaymentOrders({ ...filters, page, pageSize }, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setOrders(result.orders);
|
||||
setOrderTotal(result.total);
|
||||
setOrderPage(result.page);
|
||||
setOrderPageSize(result.pageSize);
|
||||
setAppliedOrders(filters);
|
||||
setOrdersReady(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取支付订单失败");
|
||||
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "读取支付订单失败");
|
||||
} finally {
|
||||
setOrdersLoading(false);
|
||||
if (!controller.signal.aborted) setOrdersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuns = async (page = runPage, pageSize = runPageSize) => {
|
||||
const loadRuns = async (page = runPage, pageSize = runPageSize, filters = appliedRuns) => {
|
||||
runRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
runRequest.current = controller;
|
||||
setRunsLoading(true);
|
||||
setRunsReady(false);
|
||||
try {
|
||||
const result = await listAdminPaymentReconciliations({ providerId: runProviderFilter === "all" ? undefined : runProviderFilter, page, pageSize });
|
||||
const result = await listAdminPaymentReconciliations({ ...filters, page, pageSize }, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setRuns(result.runs);
|
||||
setRunTotal(result.total);
|
||||
setRunPage(result.page);
|
||||
setRunPageSize(result.pageSize);
|
||||
setAppliedRuns(filters);
|
||||
setRunsReady(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账记录失败");
|
||||
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "读取对账记录失败");
|
||||
} finally {
|
||||
setRunsLoading(false);
|
||||
if (!controller.signal.aborted) setRunsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([loadBase(), loadOrders(1, orderPageSize), loadRuns(1, runPageSize)]);
|
||||
return () => {
|
||||
orderRequest.current?.abort();
|
||||
runRequest.current?.abort();
|
||||
detailRequest.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = async () => {
|
||||
@@ -293,19 +347,26 @@ export default function AdminPaymentsPage() {
|
||||
};
|
||||
|
||||
const openRunDetails = async (run: PaymentReconciliationRun, page = 1, pageSize = detailPageSize, result = detailResult) => {
|
||||
detailRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
detailRequest.current = controller;
|
||||
setDetailRun(run);
|
||||
setDetailResult(result);
|
||||
setDetailLoading(true);
|
||||
setDetailReady(false);
|
||||
try {
|
||||
const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, pageSize });
|
||||
const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, pageSize }, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setDetailRun(response.run);
|
||||
setDetailItems(response.items);
|
||||
setDetailTotal(response.total);
|
||||
setDetailPage(response.page);
|
||||
setDetailPageSize(response.pageSize);
|
||||
setDetailReady(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账明细失败");
|
||||
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "读取对账明细失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
if (!controller.signal.aborted) setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -490,7 +551,7 @@ export default function AdminPaymentsPage() {
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (_, run) => (
|
||||
<Button type="text" size="small" icon={<Eye className="size-3.5" />} onClick={() => void openRunDetails(run)}>
|
||||
<Button type="text" size="small" icon={<Eye className="size-3.5" />} onClick={() => void openRunDetails(run, 1, detailPageSize, "all")}>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
@@ -558,20 +619,57 @@ export default function AdminPaymentsPage() {
|
||||
placeholder="搜索名称、用户名、邮箱、订单号"
|
||||
title="支持名称、用户名、邮箱、订单号、渠道交易号和完整用户 ID"
|
||||
onChange={(event) => setOrderKeyword(event.target.value)}
|
||||
onPressEnter={() => void loadOrders(1)}
|
||||
onPressEnter={() => void loadOrders(1, orderPageSize, orderFilters)}
|
||||
/>
|
||||
}
|
||||
toolbarFilters={
|
||||
<Select
|
||||
className="w-36"
|
||||
value={orderStatusFilter}
|
||||
onChange={setOrderStatusFilter}
|
||||
options={[{ value: "all", label: "全部状态" }, ...Object.entries(paymentOrderStatus).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
<>
|
||||
<Select
|
||||
className="w-36"
|
||||
aria-label="订单状态"
|
||||
value={orderStatusFilter}
|
||||
onChange={setOrderStatusFilter}
|
||||
options={[{ value: "all", label: "全部状态" }, ...Object.entries(paymentOrderStatus).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
<Select
|
||||
className="min-w-44"
|
||||
aria-label="订单支付渠道"
|
||||
value={orderProviderFilter}
|
||||
onChange={setOrderProviderFilter}
|
||||
options={[{ value: "all", label: "全部支付渠道" }, ...providers.map((provider) => ({ value: provider.id, label: provider.name }))]}
|
||||
/>
|
||||
<Select
|
||||
className="w-32"
|
||||
aria-label="订单时间口径"
|
||||
value={orderTimeField}
|
||||
onChange={setOrderTimeField}
|
||||
options={[
|
||||
{ value: "created", label: "创建时间" },
|
||||
{ value: "paid", label: "支付时间" },
|
||||
{ value: "credited", label: "入账时间" },
|
||||
]}
|
||||
/>
|
||||
<DatePicker.RangePicker value={orderDates} onChange={setOrderDates} placeholder={["开始日期(北京时间)", "结束日期"]} />
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{ordersDirty && <span className="text-xs text-foreground/45">请先查询以应用筛选条件</span>}
|
||||
<Button loading={ordersLoading} onClick={() => void loadOrders(1, orderPageSize, orderFilters)}>
|
||||
查询
|
||||
</Button>
|
||||
<span title="导出筛选后的全部订单,最多 10000 行">
|
||||
<AdminExportButton
|
||||
label="导出 CSV"
|
||||
disabled={ordersDirty || !ordersReady || ordersLoading || orderTotal === 0}
|
||||
exportFile={() => exportAdminPaymentOrders(appliedOrders)}
|
||||
fileName={() => `支付订单-${dayjs().format("YYYYMMDD-HHmmss")}.csv`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
trailing={<Button onClick={() => void loadOrders(1)}>查询</Button>}
|
||||
table={{ rowKey: "id", loading: ordersLoading, columns: orderColumns, dataSource: orders, pagination: false, tableLayout: "fixed", scroll: { x: 1110 } }}
|
||||
empty={<AdminTableEmpty filtered={Boolean(orderKeyword || orderStatusFilter !== "all")} title="没有支付订单" />}
|
||||
empty={<AdminTableEmpty filtered={Boolean(appliedOrders.keyword || appliedOrders.status || appliedOrders.providerId || appliedOrders.from)} title="没有支付订单" />}
|
||||
footer={<PaginationBar alwaysShow current={orderPage} pageSize={orderPageSize} total={orderTotal} onChange={(page, size) => void loadOrders(size !== orderPageSize ? 1 : page, size)} />}
|
||||
/>
|
||||
),
|
||||
@@ -599,15 +697,45 @@ export default function AdminPaymentsPage() {
|
||||
toolbar={
|
||||
<Select
|
||||
className="w-52"
|
||||
aria-label="对账支付渠道"
|
||||
value={runProviderFilter}
|
||||
onChange={(value) => {
|
||||
setRunProviderFilter(value);
|
||||
setRunPage(1);
|
||||
}}
|
||||
onChange={setRunProviderFilter}
|
||||
options={[{ value: "all", label: "全部支付渠道" }, ...providers.map((provider) => ({ value: provider.id, label: provider.name }))]}
|
||||
/>
|
||||
}
|
||||
trailing={<Button onClick={() => void loadRuns(1)}>筛选</Button>}
|
||||
toolbarFilters={
|
||||
<>
|
||||
<Select
|
||||
className="w-36"
|
||||
aria-label="对账执行状态"
|
||||
value={runStatusFilter}
|
||||
onChange={setRunStatusFilter}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "running", label: "执行中" },
|
||||
{ value: "completed", label: "已完成" },
|
||||
{ value: "failed", label: "失败" },
|
||||
]}
|
||||
/>
|
||||
<DatePicker.RangePicker value={runDates} onChange={setRunDates} placeholder={["账单开始日期", "账单结束日期"]} />
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{runsDirty && <span className="text-xs text-foreground/45">请先筛选以应用条件</span>}
|
||||
<Button loading={runsLoading} onClick={() => void loadRuns(1, runPageSize, runFilters)}>
|
||||
筛选
|
||||
</Button>
|
||||
<span title="导出筛选后的全部对账汇总,含失败原因,最多 10000 行">
|
||||
<AdminExportButton
|
||||
label="导出汇总 CSV"
|
||||
disabled={runsDirty || !runsReady || runsLoading || runTotal === 0}
|
||||
exportFile={() => exportAdminPaymentReconciliations(appliedRuns)}
|
||||
fileName={() => `支付对账汇总-${dayjs().format("YYYYMMDD-HHmmss")}.csv`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
table={{ rowKey: "id", loading: runsLoading, columns: runColumns, dataSource: runs, pagination: false, scroll: { x: 1000 } }}
|
||||
empty={<AdminTableEmpty title="还没有对账记录" />}
|
||||
footer={<PaginationBar alwaysShow current={runPage} pageSize={runPageSize} total={runTotal} onChange={(page, size) => void loadRuns(size !== runPageSize ? 1 : page, size)} />}
|
||||
@@ -736,6 +864,8 @@ export default function AdminPaymentsPage() {
|
||||
open={Boolean(detailRun)}
|
||||
destroyOnHidden
|
||||
onClose={() => {
|
||||
detailRequest.current?.abort();
|
||||
setDetailReady(false);
|
||||
setDetailRun(undefined);
|
||||
setDetailItems([]);
|
||||
}}
|
||||
@@ -749,14 +879,30 @@ export default function AdminPaymentsPage() {
|
||||
toolbar={
|
||||
<Select
|
||||
className="w-44"
|
||||
aria-label="对账结果"
|
||||
value={detailResult}
|
||||
onChange={(value) => {
|
||||
setDetailResult(value);
|
||||
if (detailRun) void openRunDetails(detailRun, 1, detailPageSize, value);
|
||||
}}
|
||||
options={[{ value: "all", label: "全部结果" }, ...Object.entries(reconciliationResult).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
options={[{ value: "all", label: "全部结果" }, { value: "abnormal", label: "仅异常" }, ...Object.entries(reconciliationResult).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button loading={detailLoading} onClick={() => detailRun && void openRunDetails(detailRun)}>
|
||||
刷新
|
||||
</Button>
|
||||
<span title={detailRun?.status === "running" ? "对账执行中,完成后可导出" : "导出当前账单及结果筛选下的全部明细,最多 10000 行"}>
|
||||
<AdminExportButton
|
||||
label="导出明细 CSV"
|
||||
disabled={!detailReady || detailLoading || runningBill || detailRun?.status !== "completed" || detailTotal === 0}
|
||||
exportFile={() => exportAdminPaymentReconciliationItems(detailRun!.id, detailResult === "all" ? undefined : detailResult)}
|
||||
fileName={() => `支付对账明细-${detailRun?.providerId}-${detailRun?.billDate}-${dayjs().format("YYYYMMDD-HHmmss")}.csv`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
table={{ rowKey: "id", loading: detailLoading, columns: detailColumns, dataSource: detailItems, pagination: false, scroll: { x: 950 } }}
|
||||
empty={<AdminTableEmpty title={detailRun?.status === "failed" ? "本次对账未生成明细" : "账单没有交易记录"} />}
|
||||
footer={<PaginationBar alwaysShow current={detailPage} pageSize={detailPageSize} total={detailTotal} onChange={(page, size) => detailRun && void openRunDetails(detailRun, size !== detailPageSize ? 1 : page, size, detailResult)} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { http } from "@/services/api/request";
|
||||
import { ApiError, compactApiParams, http } from "@/services/api/request";
|
||||
|
||||
export type PaymentProvider = {
|
||||
id: "wechat-native" | "alipay-page-pay" | string;
|
||||
@@ -129,8 +129,11 @@ export type AdminPaymentOrder = PaymentOrder & {
|
||||
user: { id: string; username: string; displayName: string; email: string } | null;
|
||||
};
|
||||
|
||||
export function listAdminPaymentOrders(params: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) {
|
||||
return http.get<{ orders: AdminPaymentOrder[]; total: number; page: number; pageSize: number }>("/admin/payments/orders", { params });
|
||||
export type PaymentOrderFilters = { status?: string; keyword?: string; providerId?: string; timeField?: "created" | "paid" | "credited"; from?: string; to?: string };
|
||||
export type PaymentReconciliationFilters = { providerId?: string; status?: string; from?: string; to?: string };
|
||||
|
||||
export function listAdminPaymentOrders(params: PaymentOrderFilters & { page?: number; pageSize?: number } = {}, signal?: AbortSignal) {
|
||||
return http.get<{ orders: AdminPaymentOrder[]; total: number; page: number; pageSize: number }>("/admin/payments/orders", { params: compactApiParams(params), signal });
|
||||
}
|
||||
|
||||
export function queryAdminPaymentOrder(id: string) {
|
||||
@@ -182,10 +185,44 @@ export function runAdminPaymentReconciliation(input: { providerId: string; billD
|
||||
return http.post<{ run: PaymentReconciliationRun }>("/admin/payments/reconciliations", input);
|
||||
}
|
||||
|
||||
export function listAdminPaymentReconciliations(params: { providerId?: string; status?: string; page?: number; pageSize?: number } = {}) {
|
||||
return http.get<{ runs: PaymentReconciliationRun[]; total: number; page: number; pageSize: number }>("/admin/payments/reconciliations", { params });
|
||||
export function listAdminPaymentReconciliations(params: PaymentReconciliationFilters & { page?: number; pageSize?: number } = {}, signal?: AbortSignal) {
|
||||
return http.get<{ runs: PaymentReconciliationRun[]; total: number; page: number; pageSize: number }>("/admin/payments/reconciliations", { params: compactApiParams(params), signal });
|
||||
}
|
||||
|
||||
export function listAdminPaymentReconciliationItems(id: string, params: { result?: string; page?: number; pageSize?: number } = {}) {
|
||||
return http.get<{ run: PaymentReconciliationRun; items: PaymentReconciliationItem[]; total: number; page: number; pageSize: number }>(`/admin/payments/reconciliations/${encodeURIComponent(id)}/items`, { params });
|
||||
export function listAdminPaymentReconciliationItems(id: string, params: { result?: string; page?: number; pageSize?: number } = {}, signal?: AbortSignal) {
|
||||
return http.get<{ run: PaymentReconciliationRun; items: PaymentReconciliationItem[]; total: number; page: number; pageSize: number }>(`/admin/payments/reconciliations/${encodeURIComponent(id)}/items`, { params: compactApiParams(params), signal });
|
||||
}
|
||||
|
||||
export function exportAdminPaymentOrders(params: PaymentOrderFilters) {
|
||||
return downloadPaymentCSV("/admin/payments/orders/export.csv", params);
|
||||
}
|
||||
|
||||
export function exportAdminPaymentReconciliations(params: PaymentReconciliationFilters) {
|
||||
return downloadPaymentCSV("/admin/payments/reconciliations/export.csv", params);
|
||||
}
|
||||
|
||||
export function exportAdminPaymentReconciliationItems(id: string, result?: string) {
|
||||
return downloadPaymentCSV(`/admin/payments/reconciliations/${encodeURIComponent(id)}/items/export.csv`, { result });
|
||||
}
|
||||
|
||||
async function downloadPaymentCSV(url: string, params: Record<string, string | undefined>) {
|
||||
try {
|
||||
const response = await http.raw<Blob>({ method: "get", url, params: compactApiParams(params), responseType: "blob" });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Axios also wraps JSON error envelopes in a Blob for download requests.
|
||||
const blob = error instanceof ApiError ? (error.cause as { response?: { data?: unknown } } | undefined)?.response?.data : undefined;
|
||||
if (blob instanceof Blob) {
|
||||
let envelope: { msg?: string; code?: number; reason?: string } | undefined;
|
||||
try {
|
||||
envelope = JSON.parse(await blob.text());
|
||||
} catch {
|
||||
/* Keep the original transport error. */
|
||||
}
|
||||
if (envelope?.msg && error instanceof ApiError) {
|
||||
throw new ApiError(envelope.msg, { status: error.status, code: envelope.code, reason: envelope.reason, cause: error });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
33
web/test/payment-export.test.ts
Normal file
33
web/test/payment-export.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, expect, spyOn, test } from "bun:test";
|
||||
import { ApiError, http } from "../src/services/api/request";
|
||||
import { exportAdminPaymentOrders, exportAdminPaymentReconciliations, exportAdminPaymentReconciliationItems } from "../src/services/api/payments";
|
||||
|
||||
let raw: ReturnType<typeof spyOn> | undefined;
|
||||
afterEach(() => raw?.mockRestore());
|
||||
|
||||
test("payment exports pass filters without pagination and preserve downloaded bytes", async () => {
|
||||
const blob = new Blob(["\uFEFF订单号,金额\r\n'000000000000000001,10.01\r\n"], { type: "text/csv" });
|
||||
raw = spyOn(http, "raw").mockResolvedValue({ data: blob } as never);
|
||||
expect(await exportAdminPaymentOrders({ keyword: "alice", providerId: "wechat-native", timeField: "paid", from: "2026-09-01", to: "2026-09-17" })).toBe(blob);
|
||||
expect(raw.mock.calls[0][0]).toMatchObject({ url: "/admin/payments/orders/export.csv", responseType: "blob", params: { keyword: "alice", timeField: "paid", from: "2026-09-01", to: "2026-09-17" } });
|
||||
expect(raw.mock.calls[0][0].params).not.toHaveProperty("page");
|
||||
await exportAdminPaymentReconciliations({ status: "failed" });
|
||||
expect(raw.mock.calls[1][0]).toMatchObject({ url: "/admin/payments/reconciliations/export.csv", params: { status: "failed" } });
|
||||
await exportAdminPaymentReconciliationItems("run/id", "abnormal");
|
||||
expect(raw.mock.calls[2][0]).toMatchObject({ url: "/admin/payments/reconciliations/run%2Fid/items/export.csv", params: { result: "abnormal" } });
|
||||
});
|
||||
|
||||
test("payment downloads expose JSON Blob error messages instead of saving an error CSV", async () => {
|
||||
const error = new ApiError("Request failed", { status: 400, cause: { response: { data: new Blob([JSON.stringify({ code: 400, msg: "单次最多导出 10000 行,请缩小筛选范围后重试", reason: "bad_request" })], { type: "application/json" }) } } });
|
||||
raw = spyOn(http, "raw").mockRejectedValue(error);
|
||||
await expect(exportAdminPaymentOrders({})).rejects.toMatchObject({ status: 400, code: 400, reason: "bad_request", message: "单次最多导出 10000 行,请缩小筛选范围后重试" });
|
||||
});
|
||||
|
||||
test("payment downloads retain non-JSON errors and cancellation", async () => {
|
||||
const error = new ApiError("后端服务暂时不可用", { status: 502, cause: { response: { data: new Blob(["<html>Bad gateway</html>"]) } } });
|
||||
raw = spyOn(http, "raw").mockRejectedValue(error);
|
||||
await expect(exportAdminPaymentOrders({})).rejects.toBe(error);
|
||||
const cancelled = new DOMException("请求已取消", "AbortError");
|
||||
raw.mockRejectedValue(cancelled);
|
||||
await expect(exportAdminPaymentOrders({})).rejects.toBe(cancelled);
|
||||
});
|
||||
Reference in New Issue
Block a user