hyapp-admin-platform/finance/src/components/FinanceRechargeDetailList.jsx
2026-07-03 15:53:40 +08:00

495 lines
21 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 CloudSyncOutlined from "@mui/icons-material/CloudSyncOutlined";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import TextField from "@mui/material/TextField";
import { useState } from "react";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import {
EMPTY_RECHARGE_SUMMARY,
formatAmount,
formatMicroMoney,
formatTime,
formatUsdMinor,
rechargeSourceTone,
rechargeTypeLabel,
} from "../format.js";
const SOURCE_FILTERS = [
{ label: "全部来源", value: "" },
{ label: "谷歌充值", value: "google_play_recharge" },
{ label: "三方充值", value: "third_party" },
{ label: "币商充值", value: "coin_seller" },
];
const KPI_CARDS = [
{ filter: "", key: "total", label: "充值总和", tone: "total" },
{ filter: "google_play_recharge", key: "googlePlay", label: "谷歌充值", tone: "google" },
{ filter: "third_party", key: "thirdParty", label: "三方充值", tone: "thirdparty" },
{ filter: "coin_seller", key: "coinSeller", label: "币商充值", tone: "coinseller" },
];
export function FinanceRechargeDetailList({
bills,
error,
exporting,
filters,
googlePaidRefreshing,
loading,
onExport,
onFiltersChange,
onRefreshGooglePaid,
onReload,
options,
regions,
summary,
summaryLoading,
}) {
const [showCoinColumns, setShowCoinColumns] = useState(false);
const items = bills.items || [];
const page = Number(bills.page || filters.page || 1);
const pageSize = Number(bills.pageSize || 50);
const total = Number(bills.total || 0);
const hasNextPage = page * pageSize < total;
const regionOptions = regions || [];
const visibleColumnCount = showCoinColumns ? 9 : 7;
const pendingGoogleTxIds = items
.filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs)
.map((item) => item.transactionId)
.filter(Boolean);
return (
<section className="finance-panel finance-list">
<RechargeKpiBand
activeFilter={filters.rechargeType || ""}
loading={summaryLoading}
showCoins={showCoinColumns}
summary={summary}
onSelect={(value) => onFiltersChange({ rechargeType: value })}
/>
<div className="finance-filterbar">
<TextField
className="finance-filterbar__field"
label="APP"
select
value={filters.appCode}
onChange={(event) => onFiltersChange({ appCode: event.target.value, regionId: "" })}
>
<MenuItem value="">全部 APP</MenuItem>
{options.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
</MenuItem>
))}
</TextField>
<TextField
className="finance-filterbar__field"
disabled={!filters.appCode}
label="区域"
select
title={filters.appCode ? "" : "先选择 APP"}
value={filters.regionId}
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
>
<MenuItem value="">全部区域</MenuItem>
{regionOptions.map((region) => (
<MenuItem key={region.regionId} value={String(region.regionId)}>
{region.name || region.regionCode || region.regionId}
</MenuItem>
))}
</TextField>
<TextField
className="finance-filterbar__field"
label="充值来源"
select
value={filters.rechargeType || ""}
onChange={(event) => onFiltersChange({ rechargeType: event.target.value })}
>
{SOURCE_FILTERS.map((source) => (
<MenuItem key={source.value} value={source.value}>
{source.label}
</MenuItem>
))}
</TextField>
<TimeRangeFilter
label="充值时间 · 中国时区"
value={filters.timeRange}
onChange={(timeRange) => onFiltersChange({ timeRange })}
/>
<TextField
className="finance-filterbar__field finance-filterbar__field--search"
label="订单搜索"
placeholder="交易号 / 三方单号 / 凭证号"
value={filters.keyword}
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
/>
</div>
<div className="finance-actionbar">
<div className="finance-actionbar__presets">
{timePresets().map((preset) => (
<button
className={[
"finance-chip",
isPresetActive(filters.timeRange, preset) ? "finance-chip--active" : "",
]
.filter(Boolean)
.join(" ")}
key={preset.label}
type="button"
onClick={() => onFiltersChange({ timeRange: preset.range })}
>
{preset.label}
</button>
))}
</div>
<div className="finance-actionbar__actions">
<label className="finance-switch-field">
<AdminSwitch
checked={showCoinColumns}
label="金币显示"
onChange={(event) => setShowCoinColumns(event.target.checked)}
/>
<span>金币显示</span>
</label>
<Button
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length}
size="small"
startIcon={<CloudSyncOutlined fontSize="small" />}
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 APP 后可查询"}
variant="outlined"
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
>
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
</Button>
<Button
disabled={exporting || !filters.appCode}
size="small"
startIcon={<FileDownloadOutlined fontSize="small" />}
title={
filters.appCode ? "按当前筛选导出全部明细CSV可用 Excel 打开)" : "先选择 APP 后可导出"
}
variant="contained"
onClick={onExport}
>
{exporting ? "导出中…" : "导出报表"}
</Button>
<Button
size="small"
startIcon={<RefreshOutlined fontSize="small" />}
variant="outlined"
onClick={onReload}
>
刷新
</Button>
</div>
</div>
{error ? <div className="finance-error">{error}</div> : null}
{loading && !items.length ? <div className="finance-skeleton" /> : null}
{!loading || items.length ? (
<TableContainer className="finance-table-wrap">
<Table
className={[
"finance-table",
"finance-table--recharge-details",
showCoinColumns ? "finance-table--recharge-details-with-coins" : "",
]
.filter(Boolean)
.join(" ")}
size="small"
stickyHeader
>
<TableHead>
<TableRow>
<TableCell className="finance-time-cell">充值时间</TableCell>
<TableCell>APP</TableCell>
<TableCell>充值来源</TableCell>
<TableCell>充值订单号</TableCell>
{showCoinColumns ? <TableCell align="right">充值金币</TableCell> : null}
{showCoinColumns ? <TableCell>金币换算</TableCell> : null}
<TableCell align="right">账单金额</TableCell>
<TableCell align="right">用户实付</TableCell>
<TableCell align="right">三方税率扣款</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.length ? (
items.map((item) => (
<TableRow hover key={item.id || item.transactionId}>
<TableCell className="finance-time-cell">
{formatTime(item.createdAtMs)}
</TableCell>
<TableCell>{appName(item, options.apps)}</TableCell>
<TableCell>
<SourceBadge rechargeType={item.rechargeType} />
</TableCell>
<TableCell className="finance-order-cell">
<Stacked
primary={item.transactionId || "-"}
secondary={secondaryOrderNo(item)}
/>
</TableCell>
{showCoinColumns ? (
<TableCell align="right">
<AmountText value={item.coinAmount}>
{coinText(item.coinAmount)}
</AmountText>
</TableCell>
) : null}
{showCoinColumns ? <TableCell>{exchangeText(item)}</TableCell> : null}
<TableCell align="right">
<AmountText value={item.usdMinorAmount}>{billAmountText(item)}</AmountText>
</TableCell>
<TableCell align="right">
<AmountText value={item.userPaidAmountMicro}>
{userPaidText(item)}
</AmountText>
</TableCell>
<TableCell align="right">{taxDeductionText(item)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell align="center" colSpan={visibleColumnCount}>
当前筛选条件下没有账单
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
) : null}
<div className="finance-pagination">
<span className="finance-pagination__meta">
{formatAmount(total)} · {page} / {Math.max(1, Math.ceil(total / pageSize))}
</span>
<div className="finance-pagination__buttons">
<Button
disabled={page <= 1 || loading}
size="small"
variant="outlined"
onClick={() => onFiltersChange({ page: page - 1 })}
>
上一页
</Button>
<Button
disabled={!hasNextPage || loading}
size="small"
variant="outlined"
onClick={() => onFiltersChange({ page: page + 1 })}
>
下一页
</Button>
</div>
</div>
</section>
);
}
function RechargeKpiBand({ activeFilter, loading, onSelect, showCoins, summary }) {
const data = summary || EMPTY_RECHARGE_SUMMARY;
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
return (
<div className="finance-kpi-band">
{KPI_CARDS.map(({ filter, key, label, tone }) => {
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
const active = activeFilter === filter;
return (
<button
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
.filter(Boolean)
.join(" ")}
key={key}
title={filter ? `点击只看${label}` : "点击查看全部来源"}
type="button"
onClick={() => onSelect(active && filter ? "" : filter)}
>
<span className="finance-kpi__label">{label}</span>
<strong className="finance-kpi__value">
{loading ? "…" : formatUsdMinor(bucket.usdMinorAmount, "USD")}
</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : kpiMetaText(bucket, key, totalUsd, showCoins)}
</small>
</button>
);
})}
</div>
);
}
function kpiMetaText(bucket, key, totalUsd, showCoins) {
const parts = [`${formatAmount(bucket.billCount)}`];
if (key !== "total" && totalUsd > 0) {
const share = ((Number(bucket.usdMinorAmount) || 0) / totalUsd) * 100;
parts.push(`占比 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
}
if (showCoins) {
parts.push(`${formatAmount(bucket.coinAmount)} 金币`);
}
return parts.join(" · ");
}
function SourceBadge({ rechargeType }) {
return (
<span className={`finance-badge finance-badge--${rechargeSourceTone(rechargeType)}`}>
{rechargeTypeLabel(rechargeType)}
</span>
);
}
function AmountText({ children, value }) {
const negative = hasNumber(value) && Number(value) < 0;
return <span className={negative ? "finance-amount finance-amount--negative" : "finance-amount"}>{children}</span>;
}
const DAY_MS = 86_400_000;
const CHINA_OFFSET_MS = 8 * 3_600_000;
function chinaDayStart(timestamp) {
return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS;
}
function timePresets() {
const now = Date.now();
const todayStart = chinaDayStart(now);
const chinaNow = new Date(now + CHINA_OFFSET_MS);
const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS;
return [
{ label: "不限", range: { endMs: "", startMs: "" } },
{ label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } },
{ label: "昨天", range: { endMs: todayStart, startMs: todayStart - DAY_MS } },
{ label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } },
{ label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } },
];
}
function isPresetActive(timeRange, preset) {
const current = timeRange || { endMs: "", startMs: "" };
return (
String(current.startMs || "") === String(preset.range.startMs || "") &&
String(current.endMs || "") === String(preset.range.endMs || "")
);
}
function Stacked({ primary, secondary }) {
return (
<span className="finance-stack">
<span>{primary || "-"}</span>
<small>{secondary || ""}</small>
</span>
);
}
function appName(item, apps) {
return item.appName || apps.find((app) => app.appCode === item.appCode)?.appName || item.appCode || "-";
}
function secondaryOrderNo(item) {
return [item.commandId, item.externalRef].filter(Boolean).join(" / ");
}
function coinText(value) {
const text = formatAmount(value);
return text === "-" ? "-" : `${text} 金币`;
}
function exchangeText(item) {
if (hasNumber(item.exchangeCoinAmount) || hasNumber(item.exchangeUsdMinorAmount)) {
return `${coinText(item.exchangeCoinAmount)} = ${formatUsdMinor(item.exchangeUsdMinorAmount, "USD")}`;
}
return formatUsdMinor(item.usdMinorAmount, "USD");
}
function billAmountText(item) {
if (item.billAmountText) {
return item.billAmountText;
}
const amount = hasNumber(item.billAmountMinor)
? item.billAmountMinor
: hasPositiveNumber(item.providerAmountMinor)
? item.providerAmountMinor
: item.usdMinorAmount;
return formatUsdMinor(amount, item.currencyCode || "USD");
}
function userPaidText(item) {
if (item.userPaidText) {
return item.userPaidText;
}
if (hasNonZeroNumber(item.userPaidAmountMicro) && item.userPaidCurrencyCode) {
return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode);
}
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
return "未同步(点“查询谷歌实付”)";
}
if (hasPositiveNumber(item.userPaidAmountMinor)) {
return formatUsdMinor(item.userPaidAmountMinor, item.userPaidCurrencyCode || item.currencyCode || "USD");
}
return "-";
}
function taxDeductionText(item) {
const currency = item.userPaidCurrencyCode || item.currencyCode || "USD";
const fee = hasPositiveNumber(item.providerFeeMicro) ? item.providerFeeMicro : 0;
const tax = hasPositiveNumber(item.providerTaxMicro) ? item.providerTaxMicro : 0;
const deduction = fee + tax;
if (deduction > 0) {
const parts = [formatMicroMoney(deduction, currency)];
if (hasPositiveNumber(item.userPaidAmountMicro)) {
const percent = (deduction / item.userPaidAmountMicro) * 100;
parts.push(`${percent.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`);
}
return (
<span className="finance-stack">
<span>{parts.join(" / ")}</span>
<small>{deductionBreakdown(fee, tax, currency)}</small>
</span>
);
}
if (item.taxDeductionText) {
return item.taxDeductionText;
}
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
return "未同步";
}
return "-";
}
function deductionBreakdown(fee, tax, currency) {
const parts = [];
if (fee > 0) {
parts.push(`手续费 ${formatMicroMoney(fee, currency)}`);
}
if (tax > 0) {
parts.push(`税费 ${formatMicroMoney(tax, currency)}`);
}
return parts.join(" + ");
}
function isGoogleBill(item) {
return item.rechargeType === "google_play_recharge" || item.providerCode === "google_play";
}
function hasNumber(value) {
if (value === null || value === undefined || value === "") {
return false;
}
return Number.isFinite(Number(value));
}
function hasPositiveNumber(value) {
return hasNumber(value) && Number(value) > 0;
}
function hasNonZeroNumber(value) {
return hasNumber(value) && Number(value) !== 0;
}