495 lines
21 KiB
JavaScript
495 lines
21 KiB
JavaScript
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;
|
||
}
|