hyapp-admin-platform/src/features/payment/pages/PaymentBillListPage.jsx
2026-06-07 14:45:51 +08:00

531 lines
17 KiB
JavaScript
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.

import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Tooltip from "@mui/material/Tooltip";
import { useEffect, useMemo, useState } from "react";
import { listRechargeBills } from "@/features/payment/api";
import {
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/payment/payment.module.css";
const pageSize = 50;
const exportPageSize = 100;
const statusOptions = [
["", "全部状态"],
["succeeded", "成功"],
];
const rechargeTypeOptions = [
["", "全部途径"],
["coin_seller_transfer", "币商充值"],
["google_play_recharge", "谷歌充值"],
];
const columns = [
{
key: "bill",
label: "账单",
width: "minmax(82px, 0.35fr)",
render: (bill) => <BillCopyButton value={bill.transactionId} />,
},
{
key: "userId",
label: "用户信息",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="user" />,
},
{
key: "sellerUserId",
label: "币商信息",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="seller" />,
},
{
key: "coinAmount",
label: "金币",
width: "minmax(120px, 0.6fr)",
render: (bill) => formatNumber(bill.coinAmount),
},
{
key: "money",
label: "金额",
width: "minmax(120px, 0.6fr)",
render: (bill) => billMoneyText(bill),
},
{
key: "region",
label: "国家/区域",
width: "minmax(150px, 0.75fr)",
render: (bill, _index, context) => (
<Stack
primary={billCountryName(bill)}
secondary={regionName(bill.targetRegionId, context?.regionNames)}
/>
),
},
{
key: "rechargeType",
label: "充值途径",
width: "minmax(130px, 0.7fr)",
render: (bill) => rechargeTypeLabel(bill.rechargeType),
},
{
key: "status",
label: "状态",
width: "minmax(100px, 0.55fr)",
render: (bill) => <StatusBadge status={bill.status} />,
},
{
key: "time",
label: "创建时间",
width: "minmax(160px, 0.8fr)",
render: (bill) => formatMillis(bill.createdAtMs),
},
];
export function PaymentBillListPage() {
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [rechargeType, setRechargeType] = useState("");
const [regionId, setRegionId] = useState("");
const [userId, setUserId] = useState("");
const [sellerUserId, setSellerUserId] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [exporting, setExporting] = useState(false);
const { loadingRegions, regionOptions } = useRegionOptions();
const { showToast } = useToast();
const regionNames = useMemo(() => regionNameMap(regionOptions), [regionOptions]);
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs,
keyword,
recharge_type: rechargeType,
region_id: regionId,
seller_user_id: sellerUserId,
start_at_ms: timeRange.startMs,
status,
user_id: userId,
}),
[keyword, rechargeType, regionId, sellerUserId, status, timeRange.endMs, timeRange.startMs, userId],
);
const query = usePaginatedQuery({
errorMessage: "获取账单列表失败",
fetcher: listRechargeBills,
filters,
page,
pageSize,
queryKey: ["payment-bills", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const resetPage = (setter) => (value) => {
setter(value);
setPage(1);
};
const changeTimeRange = (value) => {
setTimeRange(value);
setPage(1);
};
const downloadBills = async () => {
setExporting(true);
try {
const bills = await fetchAllRechargeBills(filters);
downloadTextFile(createBillsCsv(bills, regionNames), `hyapp-payment-bills-${filenameTimestamp()}.csv`);
showToast(`已导出 ${bills.length} 条账单`, "success");
} catch (err) {
showToast(err?.message || "导出账单失败", "error");
} finally {
setExporting(false);
}
};
const tableColumns = columns.map((column) => {
if (column.key === "bill") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索账单号、命令号、外部单号",
value: keyword,
onChange: resetPage(setKeyword),
}),
};
}
if (column.key === "userId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户 ID",
value: userId,
onChange: (value) => resetPage(setUserId)(digitsOnly(value)),
}),
};
}
if (column.key === "sellerUserId") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索币商 ID",
value: sellerUserId,
onChange: (value) => resetPage(setSellerUserId)(digitsOnly(value)),
}),
};
}
if (column.key === "rechargeType") {
return {
...column,
filter: createOptionsColumnFilter({
options: rechargeTypeOptions,
placeholder: "搜索充值途径",
value: rechargeType,
onChange: resetPage(setRechargeType),
}),
};
}
if (column.key === "region") {
return {
...column,
filter: createRegionColumnFilter({
loading: loadingRegions,
options: regionOptions,
value: regionId,
onChange: resetPage(setRegionId),
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: status,
onChange: resetPage(setStatus),
}),
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
actions={
<Button
disabled={exporting}
startIcon={<DownloadOutlined fontSize="small" />}
onClick={downloadBills}
>
{exporting ? "导出中" : "导出"}
</Button>
}
filters={
<TimeRangeFilter
disabled={exporting}
label="创建时间"
value={timeRange}
onChange={changeTimeRange}
/>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
context={{ regionNames }}
items={items}
minWidth="1380px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(bill) => bill.transactionId}
/>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function Stack({ primary, secondary }) {
return (
<div className="cell-stack">
<span>{primary || "-"}</span>
<span className="muted">{secondary || "-"}</span>
</div>
);
}
function BillCopyButton({ value }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) {
return undefined;
}
const timer = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timer);
}, [copied]);
const copyBill = async () => {
if (!value) {
return;
}
await copyText(String(value));
setCopied(true);
};
return (
<Tooltip arrow title={copied ? "已复制" : "复制账单"}>
<span className={styles.copyWrap}>
<IconButton disabled={!value} label="复制账单" onClick={copyBill}>
<ContentCopyOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
);
}
function BillUser({ bill, type }) {
const fallbackId = type === "seller" ? bill.sellerUserId : bill.userId;
const profile = normalizeBillUser(type === "seller" ? bill.seller : bill.user, fallbackId);
if (!profile.userId && !profile.displayUserId && !profile.username) {
return "-";
}
const shortId = profile.displayUserId || profile.userId || "-";
const name = profile.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.identityId}>{name}</span>
<span className={styles.identityName}>{shortId}</span>
</span>
</div>
);
}
function StatusBadge({ status }) {
const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{statusLabel(status)}
</span>
);
}
function rechargeTypeLabel(value) {
return rechargeTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
}
function statusLabel(value) {
if (value === "succeeded") {
return "成功";
}
if (value === "failed") {
return "失败";
}
return value || "-";
}
function formatNumber(value) {
if (value === 0 || value) {
return Number(value).toLocaleString("zh-CN");
}
return "-";
}
function formatMoney(value, currency = "USD") {
if (!(value === 0 || value)) {
return "-";
}
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})}`;
}
function billMoneyText(bill) {
if (bill?.rechargeType === "coin_seller_transfer") {
return "";
}
return formatMoney(bill?.usdMinorAmount, bill?.currencyCode);
}
function normalizeBillUser(profile, fallbackId) {
const source = profile || {};
return {
avatar: source.avatar || "",
countryCode: source.countryCode || source.country_code || "",
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
countryName: source.countryName || source.country_name || "",
displayUserId: source.displayUserId || source.display_user_id || "",
userId: String(source.userId || source.user_id || fallbackId || ""),
username: source.username || source.name || "",
};
}
function billCountryName(bill) {
const user = normalizeBillUser(bill?.user, bill?.userId);
return user.countryDisplayName || user.countryName || user.countryCode || "-";
}
function regionNameMap(regionOptions = []) {
return Object.fromEntries(
regionOptions.map((region) => [String(region.regionId || region.value), region.name || region.label]),
);
}
function regionName(regionId, regionNames = {}) {
if (!regionId) {
return "-";
}
return regionNames[String(regionId)] || `区域 ${regionId}`;
}
async function fetchAllRechargeBills(filters) {
const items = [];
let page = 1;
while (true) {
const data = await listRechargeBills({ ...filters, page, page_size: exportPageSize });
const batch = Array.isArray(data?.items) ? data.items : [];
items.push(...batch);
const total = Number(data?.total || 0);
if (!batch.length || (total > 0 && items.length >= total) || batch.length < exportPageSize) {
return items;
}
page += 1;
}
}
function createBillsCsv(bills, regionNames) {
const headers = [
"账单",
"命令号",
"外部单号",
"用户长ID",
"用户短ID",
"用户名称",
"币商长ID",
"币商短ID",
"币商名称",
"金币",
"金额",
"国家",
"区域",
"充值途径",
"状态",
"创建时间",
];
const rows = bills.map((bill) => {
const user = normalizeBillUser(bill.user, bill.userId);
const seller = normalizeBillUser(bill.seller, bill.sellerUserId);
return [
bill.transactionId,
bill.commandId,
bill.externalRef,
user.userId,
user.displayUserId,
user.username,
seller.userId,
seller.displayUserId,
seller.username,
formatNumber(bill.coinAmount),
billMoneyText(bill),
billCountryName(bill),
regionName(bill.targetRegionId, regionNames),
rechargeTypeLabel(bill.rechargeType),
statusLabel(bill.status),
formatMillis(bill.createdAtMs),
];
});
return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
}
function csvCell(value) {
const text = value === 0 || value ? String(value) : "";
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
function downloadTextFile(content, filename) {
const blob = new Blob([`\uFEFF${content}`], { type: "text/csv;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// 浏览器可能因权限或非安全上下文拒绝 Clipboard API这里退回同步复制。
}
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
function filenameTimestamp() {
const now = new Date();
return [
now.getFullYear(),
pad2(now.getMonth() + 1),
pad2(now.getDate()),
"-",
pad2(now.getHours()),
pad2(now.getMinutes()),
pad2(now.getSeconds()),
].join("");
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function digitsOnly(value) {
return value.replace(/\D/g, "");
}