433 lines
20 KiB
JavaScript
433 lines
20 KiB
JavaScript
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(点击进入提现审核页);
|
||
// 「工资兑换」只统计用户工资转币商的 USDT(likei 平台为银行卡余额兑换金币)。
|
||
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
|
||
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>
|
||
);
|
||
}
|