2026-07-03 19:52:18 +08:00

433 lines
20 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 { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js";
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" },
];
const DAILY_FIELDS = {
coinSeller: { coin: "coinSellerCoinAmount", usd: "coinSellerUsdMinor" },
googlePlay: { coin: "googleCoinAmount", usd: "googleUsdMinor" },
thirdParty: { coin: "thirdPartyCoinAmount", usd: "thirdPartyUsdMinor" },
};
export function FinanceOverview({
currency,
filters,
loading,
onFiltersChange,
onGoUnsynced,
onOpenApp,
onOpenWithdrawals,
overview,
perAppSummaries,
prevSummary,
regions,
summary,
}) {
const data = summary || EMPTY_RECHARGE_SUMMARY;
const trend = overview || EMPTY_RECHARGE_OVERVIEW;
const showCoins = currency === "coin";
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
const unsynced = trend.googlePaid.unsyncedCount;
const selectedApp = filters.appCode;
return (
<div className="finance-overview">
{unsynced > 0 ? (
<div className="finance-alerts">
<span className="finance-alert finance-alert--warn">
{formatAmount(unsynced)} 笔谷歌订单实付未同步手续费口径尚不完整
<button type="button" onClick={onGoUnsynced}>
去同步
</button>
</span>
</div>
) : null}
<div className="finance-kpi-band">
{KPI_CARDS.map(({ filter, key, label, tone }) => {
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
const active = (filters.rechargeType || "") === filter;
return (
<button
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
.filter(Boolean)
.join(" ")}
key={key}
title={filter ? `点击只看${label}` : "查看全部来源"}
type="button"
onClick={() => onFiltersChange({ rechargeType: active && filter ? "" : filter })}
>
<span className="finance-kpi__label">{label}</span>
<strong className="finance-kpi__value">
{loading
? "…"
: showCoins
? `${formatAmount(bucket.coinAmount)} 金币`
: formatUsdMinor(bucket.usdMinorAmount, "USD")}
</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : kpiMeta(bucket, key, totalUsd, prevSummary)}
</small>
<Sparkline daily={trend.daily} field={key} showCoins={showCoins} tone={tone} />
</button>
);
})}
<WithdrawalKpi
loading={loading}
totalUsd={totalUsd}
withdrawal={trend.withdrawal}
onOpenWithdrawals={onOpenWithdrawals}
/>
</div>
<div className="finance-overview__split">
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-card__title">
充值趋势<small>按渠道堆叠 · · {showCoins ? "金币" : "USD"}</small>
</span>
<div className="finance-card__tools finance-chartlegend">
<span>
<i style={{ background: "var(--fin-google)" }} />
谷歌
</span>
<span>
<i style={{ background: "var(--fin-third)" }} />
三方
</span>
<span>
<i style={{ background: "var(--fin-seller)" }} />
币商
</span>
</div>
</div>
<div className="finance-card__body">
<TrendChart daily={trend.daily} showCoins={showCoins} />
</div>
</section>
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-card__title">
净入账拆解<small>谷歌已同步口径</small>
</span>
</div>
<div className="finance-card__body">
<DeductionBreakdown paid={trend.googlePaid} />
<div className="finance-card__divider" />
<div className="finance-card__title" style={{ marginBottom: 8 }}>
{selectedApp ? "区域分布 Top 5" : "区域分布"}
</div>
{selectedApp ? (
<RegionRanks regions={regions} />
) : (
<p className="finance-hint">选择单个 App 后查看区域分布</p>
)}
</div>
</section>
</div>
<section className="finance-card">
<div className="finance-card__head">
<span className="finance-card__title">
App 对比<small>点击行进入该 App 流水</small>
</span>
</div>
<div className="finance-tablewrap">
<table className="finance-flat-table">
<thead>
<tr>
<th>App</th>
<th className="r">充值流水</th>
<th className="r">笔数</th>
<th className="r">谷歌</th>
<th className="r">三方</th>
<th className="r">币商</th>
<th style={{ width: 180 }}>占比</th>
</tr>
</thead>
<tbody>
{(perAppSummaries || []).map((app) => {
const appTotal = Number(app.summary?.total?.usdMinorAmount) || 0;
const maxTotal = Math.max(
1,
...(perAppSummaries || []).map(
(item) => Number(item.summary?.total?.usdMinorAmount) || 0,
),
);
return (
<tr
className="finance-flat-table__row"
key={app.appCode}
onClick={() => onOpenApp(app.appCode)}
>
<td>
<b>{app.appName || app.appCode}</b>
</td>
<td className="r num">{formatUsdMinor(appTotal, "USD")}</td>
<td className="r num">{formatAmount(app.summary?.total?.billCount || 0)}</td>
<td className="r num">
{formatUsdMinor(app.summary?.googlePlay?.usdMinorAmount || 0, "USD")}
</td>
<td className="r num">
{formatUsdMinor(app.summary?.thirdParty?.usdMinorAmount || 0, "USD")}
</td>
<td className="r num">
{formatUsdMinor(app.summary?.coinSeller?.usdMinorAmount || 0, "USD")}
</td>
<td>
<span className="finance-rankbar">
<i style={{ width: `${(appTotal / maxTotal) * 100}%` }} />
</span>
</td>
</tr>
);
})}
{!perAppSummaries?.length ? (
<tr>
<td colSpan={7} style={{ textAlign: "center" }}>
暂无数据
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
);
}
// WithdrawalKpi 是两张独立口径的资金流出卡:
// 「用户提现」只统计提现申请审核通过的 USDT点击进入提现审核页
// 「工资兑换」只统计用户工资转币商的 USDTlikei 平台为银行卡余额兑换金币)。
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
const data = withdrawal || {
approvedCount: 0,
approvedUsdMinor: 0,
totalUsdMinor: 0,
transferCount: 0,
transferUsdMinor: 0,
};
return (
<>
<button
className="finance-kpi finance-kpi--withdrawal"
title="点击进入用户提现申请"
type="button"
onClick={onOpenWithdrawals}
>
<span className="finance-kpi__label">用户提现</span>
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.approvedUsdMinor)}</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : outflowMeta(data.approvedUsdMinor, data.approvedCount, totalUsd)}
</small>
<small className="finance-kpi__meta">提现申请审核通过</small>
</button>
<div className="finance-kpi finance-kpi--salary">
<span className="finance-kpi__label">工资兑换</span>
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.transferUsdMinor)}</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : outflowMeta(data.transferUsdMinor, data.transferCount, totalUsd)}
</small>
<small className="finance-kpi__meta">用户工资转币商</small>
</div>
</>
);
}
function usdtText(usdMinor) {
return `USDT ${(usdMinor / 100).toLocaleString("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`;
}
function outflowMeta(usdMinor, count, totalUsd) {
const parts = [];
if (totalUsd > 0) {
const share = (usdMinor / totalUsd) * 100;
parts.push(`占充值 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
}
parts.push(`${formatAmount(count)}`);
return parts.join(" · ");
}
function kpiMeta(bucket, key, totalUsd, prevSummary) {
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 })}%`);
}
const prev = prevSummary?.[key];
if (prev && Number(prev.usdMinorAmount) > 0) {
const delta =
((Number(bucket.usdMinorAmount) - Number(prev.usdMinorAmount)) / Number(prev.usdMinorAmount)) * 100;
const arrow = delta >= 0 ? "▲" : "▼";
parts.push(`${arrow} ${Math.abs(delta).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}% vs 上期`);
}
return parts.join(" · ");
}
function Sparkline({ daily, field, showCoins, tone }) {
const values = (daily || []).map((bucket) => {
if (field === "total") {
return showCoins
? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount
: bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor;
}
const fields = DAILY_FIELDS[field];
return fields ? bucket[showCoins ? fields.coin : fields.usd] : 0;
});
if (values.length < 2) {
return <span className="finance-sparkline" />;
}
const width = 100;
const height = 28;
const max = Math.max(...values);
const min = Math.min(...values);
const points = values.map((value, index) => {
const x = (index * width) / (values.length - 1);
const y = height - 3 - ((value - min) / (max - min || 1)) * (height - 8);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const colors = {
coinseller: "#B45309",
google: "#1B873F",
thirdparty: "#0E7490",
total: "#2557D6",
};
const color = colors[tone] || colors.total;
return (
<svg className="finance-sparkline" preserveAspectRatio="none" viewBox={`0 0 ${width} ${height}`}>
<polyline fill={`${color}22`} points={`${points.join(" ")} ${width},${height} 0,${height}`} stroke="none" />
<polyline
fill="none"
points={points.join(" ")}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</svg>
);
}
function TrendChart({ daily, showCoins }) {
const buckets = daily || [];
if (!buckets.length) {
return <p className="finance-hint">当前时间范围内没有充值数据</p>;
}
const totals = buckets.map((bucket) =>
showCoins
? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount
: bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor,
);
const max = Math.max(1, ...totals);
const value = (bucket, channel) => {
const fields = DAILY_FIELDS[channel];
return bucket[showCoins ? fields.coin : fields.usd];
};
return (
<div>
<div className="finance-trend" role="img" aria-label="按渠道堆叠的每日充值趋势">
{buckets.map((bucket) => (
<div
className="finance-trend__col"
key={bucket.date}
title={`${bucket.date} · 谷歌 ${trendTip(bucket, "googlePlay", showCoins)} · 三方 ${trendTip(bucket, "thirdParty", showCoins)} · 币商 ${trendTip(bucket, "coinSeller", showCoins)}`}
>
{["googlePlay", "thirdParty", "coinSeller"].map((channel) => {
const amount = value(bucket, channel);
if (amount <= 0) {
return null;
}
return (
<span
className={`finance-trend__seg finance-trend__seg--${channel}`}
key={channel}
style={{ height: `${(amount / max) * 100}%` }}
/>
);
})}
</div>
))}
</div>
<div className="finance-trend__axis">
<span>{buckets[0].date.slice(5)}</span>
{buckets.length > 2 ? <span>{buckets[Math.floor(buckets.length / 2)].date.slice(5)}</span> : null}
<span>{buckets[buckets.length - 1].date.slice(5)}</span>
</div>
</div>
);
}
function trendTip(bucket, channel, showCoins) {
const fields = DAILY_FIELDS[channel];
const amount = bucket[showCoins ? fields.coin : fields.usd];
return showCoins ? formatAmount(amount) : formatUsdMinor(amount, "USD");
}
function DeductionBreakdown({ paid }) {
const covered = paid.coveredUsdMinor;
if (covered <= 0) {
return (
<p className="finance-hint">
暂无已同步的谷歌实付明细查询谷歌实付同步后这里会展示手续费与税费拆解
</p>
);
}
const rows = [
{ amount: paid.estNetUsdMinor, color: "var(--fin-pos)", label: "净入账(估)" },
{ amount: paid.estFeeUsdMinor, color: "#E19B3C", label: "谷歌服务费(估)" },
{ amount: paid.estTaxUsdMinor, color: "#C86A6A", label: "代缴税费(估)" },
];
return (
<div>
<div className="finance-mixbar">
{rows.map((row) => (
<span key={row.label} style={{ background: row.color, flex: Math.max(row.amount, 1) }} />
))}
</div>
{rows.map((row) => (
<div className="finance-mixrow" key={row.label}>
<i style={{ background: row.color }} />
<span className="finance-mixrow__name">{row.label}</span>
<span className="finance-mixrow__amt num">{formatUsdMinor(row.amount, "USD")}</span>
<span className="finance-mixrow__pct num">
{((row.amount / covered) * 100).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%
</span>
</div>
))}
<p className="finance-hint" style={{ marginTop: 8 }}>
口径已同步 {formatAmount(paid.syncedCount)} / {formatAmount(paid.googleBillCount)}{" "}
笔谷歌订单覆盖流水 {formatUsdMinor(covered, "USD")}按各订单实付占比折算成 USD
</p>
</div>
);
}
function RegionRanks({ regions }) {
const top = (regions || []).slice(0, 5);
if (!top.length) {
return <p className="finance-hint">当前筛选下暂无区域数据</p>;
}
const max = Math.max(1, ...top.map((region) => region.usdMinorAmount));
return (
<div>
{top.map((region, index) => (
<div className="finance-rankrow" key={region.regionId || region.name || index}>
<span className="finance-rankrow__idx num">{index + 1}</span>
<span className="finance-rankrow__name">{region.name || `区域 ${region.regionId}`}</span>
<span className="finance-rankbar">
<i style={{ width: `${(region.usdMinorAmount / max) * 100}%` }} />
</span>
<span className="finance-rankrow__amt num">{formatUsdMinor(region.usdMinorAmount, "USD")}</span>
</div>
))}
</div>
);
}