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) => , }, { key: "userId", label: "用户信息", width: "minmax(190px, 1fr)", render: (bill) => , }, { key: "sellerUserId", label: "币商信息", width: "minmax(190px, 1fr)", render: (bill) => , }, { 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) => ( ), }, { key: "rechargeType", label: "充值途径", width: "minmax(130px, 0.7fr)", render: (bill) => rechargeTypeLabel(bill.rechargeType), }, { key: "status", label: "状态", width: "minmax(100px, 0.55fr)", render: (bill) => , }, { 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 ( } onClick={downloadBills} > {exporting ? "导出中" : "导出"} } filters={ } /> 0 ? { page, pageSize: data.pageSize || pageSize, total, onPageChange: setPage, } : undefined } rowKey={(bill) => bill.transactionId} /> ); } function Stack({ primary, secondary }) { return (
{primary || "-"} {secondary || "-"}
); } 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 ( ); } 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 (
{profile.avatar ? : } {name} {shortId}
); } function StatusBadge({ status }) { const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped"; return ( {statusLabel(status)} ); } 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, ""); }