531 lines
17 KiB
JavaScript
531 lines
17 KiB
JavaScript
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, "");
|
||
}
|