2026-06-30 15:17:09 +08:00

1105 lines
42 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 { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
import { EChart } from "../charts/EChart.jsx";
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
const reportTabs = [
{ key: "country", label: "国家经营报表" },
{ key: "revenue", label: "收入趋势" },
{ key: "gift", label: "礼物消费" },
{ key: "lucky", label: "幸运礼物" },
{ key: "game", label: "游戏流水" },
{ key: "robot", label: "机器人送礼" }
];
const platformGrantRecordSourceOptions = [
{ label: "全部来源", value: "all" },
{ label: "任务奖励", value: "WalletTaskRewardCredited" },
{ label: "房间流水奖励", value: "WalletRoomTurnoverRewardCredited" },
{ label: "邀请活动奖励", value: "WalletInviteActivityRewardCredited" },
{ label: "代理开通奖励", value: "WalletAgencyOpeningRewardCredited" },
{ label: "资源发放", value: "WalletBalanceChanged" },
{ label: "平台发放", value: "WalletPlatformGrantCoinCredited" }
];
const countryColumns = [
{ align: "left", key: "date_label", label: "日期", render: (row) => <DateCell row={row} />, sortable: false, stickyLeft: true },
{ align: "left", key: "country", label: "国家", render: (row) => <CountryCell row={row} />, sortable: false },
{ key: "registrations", label: "注册", render: (row) => formatOptionalCoin(row.registrations), sortable: false },
{ key: "active_users", label: "活跃用户", render: (row) => formatOptionalCoin(row.active_users), sortable: false },
{ key: "paid_users", label: "付费用户", render: (row) => formatOptionalCoin(row.paid_users), sortable: false },
{ key: "recharge_users", label: "充值用户", render: (row) => formatOptionalCoin(row.recharge_users), sortable: false },
{ key: "recharge_usd_minor", label: "总充值", render: (row) => formatOptionalMoney(row.recharge_usd_minor), sortable: false },
{ key: "new_user_recharge_usd_minor", label: "新用户充值", render: (row) => formatOptionalMoney(row.new_user_recharge_usd_minor), sortable: false },
{ key: "coin_seller_recharge_usd_minor", label: "币商充值", render: (row) => formatOptionalMoney(row.coin_seller_recharge_usd_minor), sortable: false },
{ key: "coin_seller_stock_coin", label: "币商充值金币", render: (row) => formatOptionalCoin(row.coin_seller_stock_coin), sortable: false },
{ key: "coin_seller_transfer_coin", label: "币商出货金币", render: (row) => formatOptionalCoin(row.coin_seller_transfer_coin), sortable: false },
{ key: "google_recharge_usd_minor", label: "谷歌充值", render: (row) => formatOptionalMoney(row.google_recharge_usd_minor), sortable: false },
{ key: "mifapay_recharge_usd_minor", label: "三方充值", render: (row) => formatOptionalMoney(row.mifapay_recharge_usd_minor), sortable: false },
{ key: "game_turnover", label: "游戏流水/利润率", render: (row) => <FlowRateCell rate={row.game_profit_rate} value={row.game_turnover} />, sortable: false },
{ key: "lucky_gift_turnover", label: "幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.lucky_gift_profit_rate} value={row.lucky_gift_turnover} />, sortable: false },
{ key: "super_lucky_gift_turnover", label: "超级幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.super_lucky_gift_profit_rate} value={row.super_lucky_gift_turnover} />, sortable: false },
{ key: "gift_coin_spent", label: "礼物流水", render: (row) => formatOptionalCoin(row.gift_coin_spent), sortable: false },
{ key: "coin_total", label: "用户金币数", render: (row) => formatOptionalCoin(row.coin_total), sortable: false },
{ key: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false },
{ key: "output_coin", label: "产出金币", render: (row) => formatOptionalCoin(row.output_coin), sortable: false },
{ key: "consume_output_ratio", label: "消耗产出比", render: (row) => <ConsumeOutputCell row={row} />, sortable: false },
{ key: "platform_grant_coin", label: "平台发放金币", render: (row, context) => <PlatformGrantCoinCell context={context} row={row} />, sortable: false },
{ key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false },
{ key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false },
{ key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false },
{ key: "avg_mic_online_ms", label: "平均麦上时间", render: (row) => formatDuration(row.avg_mic_online_ms), sortable: false },
{ key: "mic_online_ms", label: "麦上总时长", render: (row) => formatDuration(row.mic_online_ms), sortable: false },
{ key: "arpu_usd_minor", label: "ARPU", render: (row) => formatOptionalMoney(row.arpu_usd_minor), sortable: false },
{ key: "arppu_usd_minor", label: "ARPPU", render: (row) => formatOptionalMoney(row.arppu_usd_minor), sortable: false },
{ key: "payer_rate", label: "付费转化率", render: (row) => formatOptionalPercent(row.payer_rate), sortable: false },
{ key: "recharge_conversion_rate", label: "充值转化率", render: (row) => formatOptionalPercent(row.recharge_conversion_rate), sortable: false }
];
const countryDetailColumns = countryColumns.filter((column) => column.key !== "date_label");
const revenueColumns = [
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
{ key: "mifapay", label: "MifaPay", render: (row) => formatMoneyFull(row.mifapay) },
{ key: "coin_seller", label: "币商", render: (row) => formatMoneyFull(row.coin_seller) },
{ key: "total", label: "渠道合计", render: (row) => formatMoneyFull(row.total) },
{ key: "lineTotal", label: "总计", render: (row) => formatMoneyFull(row.lineTotal ?? row.total) }
];
const giftColumns = [
{ align: "left", key: "name", label: "礼物", render: (row) => row.name, sortValue: (row) => row.name, type: "text" },
{ key: "value", label: "金币消费", render: (row) => formatCoin(row.value) },
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) },
{ key: "trend_rate", label: "近 7 日", render: (row) => <TrendValue value={row.trend_rate} /> }
];
const luckyColumns = [
{ align: "left", key: "pool_id", label: "奖池", render: (row) => row.pool_id, sortValue: (row) => row.pool_id, type: "text" },
{ key: "turnover", label: "流水", render: (row) => formatCoin(row.turnover) },
{ key: "payout", label: "返奖", render: (row) => formatCoin(row.payout) },
{ key: "profit", label: "利润", render: (row) => formatCoin(row.profit) },
{ key: "payout_rate", label: "返奖率", render: (row) => formatPercent(row.payout_rate) },
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) }
];
const gameColumns = [
{ align: "left", key: "game_id", label: "游戏", render: (row) => row.game_label || row.game_id, sortValue: (row) => row.game_label || row.game_id, type: "text" },
{ key: "turnover_coin", label: "流水 (金币)", render: (row) => formatCoin(row.turnover_coin) },
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) },
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) }
];
const metricColumns = [
{ align: "left", key: "label", label: "指标", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
{ key: "value", label: "数值", render: (row) => row.value, sortable: false },
{ key: "unit", label: "单位", render: (row) => row.unit || "--", sortable: false },
{ key: "caption", label: "对比口径", render: (row) => row.caption || "--", sortable: false },
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
];
export function ReportOverview({ grantDrilldownQuery, loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
const [activeTab, setActiveTab] = useState("country");
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
return (
<section className="databi-report" aria-label="数据报表">
<section className="report-metric-grid" aria-label="核心经营指标">
{summaryCards.map((card) => (
<ReportMetricCard card={card} key={card.title} loading={loading} onOpenTrend={onMetricTrend} />
))}
</section>
<section className="report-workbench">
<div className="report-tabs" role="tablist" aria-label="报表切换">
{reportTabs.map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? "is-active" : ""}
key={tab.key}
role="tab"
type="button"
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="report-tab-panel" role="tabpanel">
{activeTab === "country" ? <CountryReport grantDrilldownQuery={grantDrilldownQuery} loading={loading} rows={model.reportCountryRows} /> : null}
{activeTab === "revenue" ? <RevenueReport loading={loading} rows={model.revenueSeries} /> : null}
{activeTab === "gift" ? <GiftReport loading={loading} metrics={model.businessKpis} rows={model.giftRanking} /> : null}
{activeTab === "lucky" ? <LuckyReport loading={loading} metrics={model.businessKpis} onOpenLuckyGiftPools={onOpenLuckyGiftPools} rows={model.luckyGiftPools} /> : null}
{activeTab === "game" ? <GameReport loading={loading} metrics={model.businessKpis} rows={model.gameRanking} /> : null}
{activeTab === "robot" ? <RobotReport loading={loading} rows={model.robotGiftKpis} /> : null}
</div>
</section>
</section>
);
}
function ReportMetricCard({ card, loading, onOpenTrend }) {
const clickable = Boolean(card.trendMetric?.trend?.length && onOpenTrend);
if (loading) {
return (
<article className="report-metric-card is-loading" aria-busy="true">
<span className="skeleton-block report-skeleton-title" />
<span className="skeleton-block report-skeleton-value" />
<span className="skeleton-block report-skeleton-line" />
<span className="skeleton-block report-skeleton-line report-skeleton-line--short" />
</article>
);
}
return (
<article
className={["report-metric-card", clickable ? "report-metric-card--clickable" : ""].filter(Boolean).join(" ")}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => onOpenTrend(card.trendMetric) : undefined}
onKeyDown={clickable ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenTrend(card.trendMetric);
}
} : undefined}
>
<div className="report-metric-head">
<span>{card.title}</span>
{card.delta ? <DeltaText value={card.delta} /> : null}
</div>
<strong>{card.value}</strong>
<div className="report-metric-detail-grid">
{card.details.filter((detail) => detail.label !== card.title).map((detail) => (
<span className="report-metric-detail" key={detail.label}>
<small>{detail.label}</small>
<b>{detail.value}</b>
</span>
))}
</div>
</article>
);
}
function CountryReport({ grantDrilldownQuery, loading, rows }) {
const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
const [grantCountryModal, setGrantCountryModal] = useState(null);
const [grantUsersModal, setGrantUsersModal] = useState(null);
const [grantRecordsModal, setGrantRecordsModal] = useState(null);
const toggleRow = useCallback((rowId) => {
setExpandedRowIds((current) => {
const next = new Set(current);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
}, []);
const tableRows = useMemo(() => rows.map((row) => {
const children = Array.isArray(row.children) ? row.children : [];
if (row.row_type !== "date_total" || !children.length) {
return row;
}
return {
...row,
expanded: expandedRowIds.has(row.row_id),
onToggle: () => toggleRow(row.row_id)
};
}), [expandedRowIds, rows, toggleRow]);
const openGrantUsers = useCallback((row) => {
setGrantCountryModal(null);
setGrantRecordsModal(null);
setGrantUsersModal({
countryLabel: row.country || "国家",
query: buildPlatformGrantQuery(grantDrilldownQuery, row)
});
}, [grantDrilldownQuery]);
const openGrantCountries = useCallback((row) => {
const countryRows = platformGrantCountryRows(row, rows);
if (!countryRows.length) {
return;
}
setGrantRecordsModal(null);
setGrantUsersModal(null);
setGrantCountryModal({
rows: countryRows,
title: row.row_type === "date_total" ? `${row.date_label} 平台发放金币` : "平台发放金币"
});
}, [rows]);
const grantContext = useMemo(() => ({
onOpenPlatformGrantCountries: openGrantCountries,
onOpenPlatformGrantUsers: openGrantUsers
}), [openGrantCountries, openGrantUsers]);
return (
<ReportPanel>
<ReportTable
columns={countryColumns}
context={grantContext}
loading={loading}
preserveOrder
renderExpandedRow={(row) => <CountryDetailTable context={grantContext} dateLabel={row.date_label} rows={row.children || []} />}
rows={tableRows}
wide
/>
{grantCountryModal ? (
<PlatformGrantCountryModal
modal={grantCountryModal}
onClose={() => setGrantCountryModal(null)}
onOpenUsers={openGrantUsers}
/>
) : null}
{grantUsersModal ? (
<PlatformGrantUsersModal
modal={grantUsersModal}
onClose={() => setGrantUsersModal(null)}
onOpenRecords={(userRow) => setGrantRecordsModal({
countryLabel: grantUsersModal.countryLabel,
query: { ...grantUsersModal.query, userId: userRow.user_id },
user: userRow
})}
/>
) : null}
{grantRecordsModal ? (
<PlatformGrantRecordsModal
modal={grantRecordsModal}
onClose={() => setGrantRecordsModal(null)}
/>
) : null}
</ReportPanel>
);
}
function RevenueReport({ loading, rows }) {
return (
<ReportPanel>
{loading ? (
<div className="report-chart-skeleton" aria-busy="true">
<span className="skeleton-block report-skeleton-line" />
<span className="skeleton-block report-skeleton-chart" />
</div>
) : rows.length ? (
<EChart className="report-revenue-chart" option={createReportRevenueOption(rows)} />
) : (
<div className="report-empty">当前无数据</div>
)}
<ReportTable columns={revenueColumns} defaultSort={{ direction: "asc", key: "label", type: "text" }} loading={loading} rows={rows} />
</ReportPanel>
);
}
function GiftReport({ loading, metrics, rows }) {
const total = rows.reduce((sum, row) => sum + Number(row.value || 0), 0) || 1;
const giftRows = rows.map((row) => ({ ...row, share: Number(row.value || 0) / total }));
return (
<ReportPanel>
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("礼物") || item.label.includes("币商"))} />
<ReportTable columns={giftColumns} defaultSort={{ direction: "desc", key: "value", type: "number" }} loading={loading} rows={giftRows} />
</ReportPanel>
);
}
function LuckyReport({ loading, metrics, onOpenLuckyGiftPools, rows }) {
return (
<ReportPanel
action={(
<button className="report-panel-action" disabled={!rows.length} type="button" onClick={onOpenLuckyGiftPools}>
查看奖池明细
</button>
)}
>
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("幸运礼物"))} />
<ReportTable columns={luckyColumns} defaultSort={{ direction: "desc", key: "turnover", type: "number" }} loading={loading} rows={rows} />
</ReportPanel>
);
}
function GameReport({ loading, metrics, rows }) {
const total = rows.reduce((sum, row) => sum + Number(row.turnover_coin || 0), 0) || 1;
const gameRows = rows.map((row) => ({ ...row, share: Number(row.turnover_coin || 0) / total }));
return (
<ReportPanel>
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("游戏"))} />
<ReportTable columns={gameColumns} defaultSort={{ direction: "desc", key: "turnover_coin", type: "number" }} loading={loading} rows={gameRows} />
</ReportPanel>
);
}
function RobotReport({ loading, rows }) {
return (
<ReportPanel>
<ReportMetricTable loading={loading} rows={rows} />
</ReportPanel>
);
}
function ReportPanel({ action, children }) {
return (
<section className="report-panel">
{action ? <div className="report-panel-head">{action}</div> : null}
{children}
</section>
);
}
function ReportMetricTable({ loading, rows }) {
return (
<div className="report-metric-table-block">
<ReportTable columns={metricColumns} loading={loading} rows={rows} />
</div>
);
}
function ReportTable({ columns, context, defaultSort, loading, preserveOrder = false, renderExpandedRow, rows, wide = false }) {
const sortableDefault = defaultSort || firstSortableColumn(columns);
const [sortState, setSortState] = useState(sortableDefault);
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
const handleSort = (column) => {
if (preserveOrder || column.sortable === false) {
return;
}
setSortState((current) => ({
direction: current?.key === column.key && current.direction === "desc" ? "asc" : "desc",
key: column.key,
type: column.type || "number"
}));
};
return (
<div className="report-table-wrap">
<table className={["report-table", wide ? "report-table--wide" : ""].filter(Boolean).join(" ")}>
<thead>
<tr>
<th>#</th>
{columns.map((column) => {
const isSortable = !preserveOrder && column.sortable !== false;
const isActive = sortState?.key === column.key;
const headerClassName = [
isSortable ? "is-sortable" : "",
column.stickyLeft ? "is-sticky-left" : ""
].filter(Boolean).join(" ");
return (
<th className={headerClassName} key={column.key}>
{isSortable ? (
<button
className={["report-sort-button", column.align === "left" ? "report-sort-button--left" : "", isActive ? "is-active" : ""].filter(Boolean).join(" ")}
type="button"
onClick={() => handleSort(column)}
>
<span>{column.label}</span>
<i>{isActive ? (sortState.direction === "asc" ? "↑" : "↓") : "↕"}</i>
</button>
) : (
column.label
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
{!loading && sortedRows.map((row, index) => {
const key = reportRowKey(row, index);
const rowProps = reportTableRowProps(row);
const { className: rowClassName, ...interactiveRowProps } = rowProps;
return (
<Fragment key={key}>
<tr
{...interactiveRowProps}
className={[reportRowClass(row), rowClassName].filter(Boolean).join(" ")}
>
<td>{reportRowIndex(row, index)}</td>
{columns.map((column) => (
<td
className={[
column.align === "left" ? "is-left" : "",
column.stickyLeft ? "is-sticky-left" : ""
].filter(Boolean).join(" ")}
key={column.key}
>
{column.render ? column.render(row, context) : row[column.key]}
</td>
))}
</tr>
{row.expanded && renderExpandedRow ? (
<tr className="report-row-expanded-detail">
<td colSpan={columns.length + 1}>{renderExpandedRow(row)}</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}
function CountryDetailTable({ context, dateLabel, rows }) {
if (!rows.length) {
return <div className="report-subtable-empty">当前无数据</div>;
}
return (
<div className="report-subtable-wrap">
<table aria-label={`${dateLabel || "日期"} 国家明细`} className="report-table report-subtable">
<thead>
<tr>
<th>#</th>
{countryDetailColumns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={reportRowKey(row, index)}>
<td>{index + 1}</td>
{countryDetailColumns.map((column) => (
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row, context) : row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function PlatformGrantCoinCell({ context, row }) {
const value = Number(row.platform_grant_coin || 0);
const clickable = value > 0 && (row.row_type === "country" ? context?.onOpenPlatformGrantUsers : context?.onOpenPlatformGrantCountries);
if (!clickable) {
return formatOptionalCoin(row.platform_grant_coin);
}
const label = row.row_type === "country"
? `查看 ${row.country || "国家"} 平台发放金币用户明细`
: `查看 ${row.date_label || "全部"} 平台发放金币国家明细`;
return (
<button
aria-label={label}
className="report-link-button"
type="button"
onClick={(event) => {
event.stopPropagation();
if (row.row_type === "country") {
context.onOpenPlatformGrantUsers(row);
return;
}
context.onOpenPlatformGrantCountries(row);
}}
>
{formatCoin(value)}
</button>
);
}
function PlatformGrantCountryModal({ modal, onClose, onOpenUsers }) {
return (
<ReportDialog title={modal.title} onClose={onClose}>
<div className="report-modal-table-wrap">
<table className="report-modal-table">
<thead>
<tr>
<th>国家</th>
<th>平台发放金币</th>
</tr>
</thead>
<tbody>
{modal.rows.length ? modal.rows.map((row) => (
<tr key={reportRowKey(row, row.country_id || row.country)}>
<td>
<button
aria-label={`查看 ${row.country || "国家"} 平台发放金币用户明细`}
className="report-link-button report-link-button--left"
type="button"
onClick={() => onOpenUsers(row)}
>
<CountryCell row={row} />
</button>
</td>
<td>{formatOptionalCoin(row.platform_grant_coin)}</td>
</tr>
)) : (
<tr className="report-table-empty-row"><td colSpan={2}>当前无数据</td></tr>
)}
</tbody>
</table>
</div>
</ReportDialog>
);
}
function PlatformGrantUsersModal({ modal, onClose, onOpenRecords }) {
const pageSize = 20;
const [page, setPage] = useState(1);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setLoading(true);
setError("");
fetchPlatformGrantUsers({ ...modal.query, page, pageSize })
.then((nextData) => {
if (active) {
setData(nextData || {});
}
})
.catch((err) => {
if (active) {
setError(err.message || "请求失败");
setData(null);
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [modal.query, page]);
const items = Array.isArray(data?.items) ? data.items : [];
const total = Number(data?.total || 0);
return (
<ReportDialog title={`${modal.countryLabel} 平台发放金币用户`} onClose={onClose}>
<div className="report-modal-table-wrap">
<table className="report-modal-table">
<thead>
<tr>
<th>用户</th>
<th>平台发放金币</th>
<th>记录数</th>
<th>最近发放时间</th>
</tr>
</thead>
<tbody>
{loading ? <tr className="report-table-skeleton-row"><td colSpan={4}><span className="skeleton-block report-skeleton-line" /></td></tr> : null}
{!loading && error ? <tr className="report-table-empty-row"><td colSpan={4}>{error}</td></tr> : null}
{!loading && !error && !items.length ? <tr className="report-table-empty-row"><td colSpan={4}>当前无数据</td></tr> : null}
{!loading && !error ? items.map((item) => {
const openRecords = () => onOpenRecords(item);
return (
<tr
aria-label={`查看 ${platformGrantUserLabel(item)} 平台发放金币来源明细`}
className="report-clickable-row"
key={item.user_id}
role="button"
tabIndex={0}
onClick={openRecords}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openRecords();
}
}}
>
<td>
<span className="report-user-cell">
{item.avatar ? <img alt="" src={item.avatar} /> : null}
<span>
<b>{platformGrantUserLabel(item)}</b>
<small>{platformGrantUserMeta(item)}</small>
</span>
</span>
</td>
<td>{formatOptionalCoin(item.total_coin)}</td>
<td>{formatOptionalCoin(item.record_count)}</td>
<td>{formatDateTime(item.last_granted_at_ms)}</td>
</tr>
);
}) : null}
</tbody>
</table>
</div>
<ReportPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
</ReportDialog>
);
}
function PlatformGrantRecordsModal({ modal, onClose }) {
const pageSize = 20;
const [page, setPage] = useState(1);
const [source, setSource] = useState("all");
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setLoading(true);
setError("");
fetchPlatformGrantRecords({ ...modal.query, page, pageSize, source })
.then((nextData) => {
if (active) {
setData(nextData || {});
}
})
.catch((err) => {
if (active) {
setError(err.message || "请求失败");
setData(null);
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [modal.query, page, source]);
const items = Array.isArray(data?.items) ? data.items : [];
const total = Number(data?.total || 0);
const selectSource = (event) => {
setSource(event.target.value);
setPage(1);
};
return (
<ReportDialog title={`${platformGrantUserLabel(modal.user)} 平台发放金币来源`} onClose={onClose}>
<div className="report-modal-toolbar">
<label className="report-source-filter">
<span>来源</span>
<select aria-label="来源筛选" value={source} onChange={selectSource}>
{platformGrantRecordSourceOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
</div>
<div className="report-modal-table-wrap">
<table className="report-modal-table">
<thead>
<tr>
<th>来源</th>
<th>金币</th>
<th>发放时间</th>
<th>事件 ID</th>
</tr>
</thead>
<tbody>
{loading ? <tr className="report-table-skeleton-row"><td colSpan={4}><span className="skeleton-block report-skeleton-line" /></td></tr> : null}
{!loading && error ? <tr className="report-table-empty-row"><td colSpan={4}>{error}</td></tr> : null}
{!loading && !error && !items.length ? <tr className="report-table-empty-row"><td colSpan={4}>当前无数据</td></tr> : null}
{!loading && !error ? items.map((item) => (
<tr key={item.event_id}>
<td>{platformGrantSourceLabel(item.event_type)}</td>
<td>{formatOptionalCoin(item.amount)}</td>
<td>{formatDateTime(item.occurred_at_ms)}</td>
<td><span className="report-event-id">{item.event_id || "--"}</span></td>
</tr>
)) : null}
</tbody>
</table>
</div>
<ReportPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
</ReportDialog>
);
}
function ReportDialog({ children, onClose, title }) {
return (
<div className="report-modal-backdrop">
<section aria-label={title} aria-modal="true" className="report-modal" role="dialog">
<header className="report-modal-head">
<h2>{title}</h2>
<button className="report-modal-close" type="button" onClick={onClose}>关闭</button>
</header>
<div className="report-modal-body">{children}</div>
</section>
</div>
);
}
function ReportPagination({ onPageChange, page, pageSize, total }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="report-pagination">
<span> {formatCoin(total)} </span>
<div>
<button disabled={page <= 1} type="button" onClick={() => onPageChange(page - 1)}>上一页</button>
<b>{page} / {totalPages}</b>
<button disabled={page >= totalPages} type="button" onClick={() => onPageChange(page + 1)}>下一页</button>
</div>
</div>
);
}
function CountryCell({ row }) {
return (
<span className="report-country-cell">
{row.flag ? <i>{row.flag}</i> : null}
<span>{row.country}</span>
</span>
);
}
function DateCell({ row }) {
const childCount = Array.isArray(row.children) ? row.children.length : 0;
return (
<span className={["report-date-cell", childCount ? "report-date-cell--expandable" : ""].filter(Boolean).join(" ")}>
{childCount ? (
<span className={["report-expand-caret", row.expanded ? "is-open" : ""].filter(Boolean).join(" ")} aria-hidden="true">
</span>
) : null}
<span>{row.date_label || "--"}</span>
{childCount ? <small>{childCount} </small> : null}
</span>
);
}
function FlowRateCell({ rate, value }) {
if (isMissing(value) && isMissing(rate)) {
return <span className="report-placeholder">--</span>;
}
return (
<span className="report-pair-cell">
<b>{formatOptionalCoin(value)}</b>
<small>{formatOptionalPercent(rate)}</small>
</span>
);
}
function ConsumeOutputCell({ row }) {
if (isMissing(row.consume_output_ratio) && isMissing(row.consume_output_delta)) {
return <span className="report-placeholder">--</span>;
}
return (
<span className="report-pair-cell">
<b>{formatOptionalPercent(row.consume_output_ratio)}</b>
<small>差额 {formatOptionalCoin(row.consume_output_delta)}</small>
</span>
);
}
function TrendValue({ value }) {
return <span className={Number(value) >= 0 ? "report-trend-up" : "report-trend-down"}>{formatSignedPercent(value)}</span>;
}
function DeltaText({ value }) {
const text = String(value || "").trim();
if (!text) {
return <span className="report-delta report-delta--empty">--</span>;
}
return <span className={text.startsWith("-") ? "report-delta report-delta--down" : "report-delta"}>{text.startsWith("-") ? "↓" : "↑"} {text.replace(/^[-+]/, "")}</span>;
}
function ReportSkeletonRows({ colSpan }) {
return Array.from({ length: 6 }).map((_, index) => (
<tr className="report-table-skeleton-row" key={index}>
<td colSpan={colSpan}><span className="skeleton-block report-skeleton-line" /></td>
</tr>
));
}
function ReportEmptyRow({ colSpan }) {
return (
<tr className="report-table-empty-row">
<td colSpan={colSpan}>当前无数据</td>
</tr>
);
}
function createSummaryCards(model) {
const totalRecharge = model.kpis[0] || {};
const rechargeSplit = model.kpis[1]?.subMetrics || [];
const newUsers = model.kpis[2] || {};
const activeUsers = model.kpis[3] || {};
const paidSplit = model.kpis[4]?.subMetrics || [];
const arpu = model.kpis[5] || {};
const arppu = model.kpis[6] || {};
const payerRate = model.sideMetrics?.find((item) => item.label === "付费转化率") || {};
return [
{
delta: totalRecharge.delta,
details: [
{ label: "用户充值", value: rechargeSplit[0]?.value || "--" },
{ label: "新用户充值", value: rechargeSplit[1]?.value || "--" }
],
title: "总充值",
trendMetric: totalRecharge,
value: totalRecharge.value || "--",
valueLabel: "总充值"
},
{
delta: activeUsers.delta,
details: [
{ label: "新增用户", value: newUsers.value || "--" },
{ label: "活跃用户", value: activeUsers.value || "--" }
],
title: "活跃用户",
trendMetric: activeUsers,
value: activeUsers.value || "--",
valueLabel: "活跃用户"
},
{
delta: paidSplit[0]?.delta,
details: [
{ label: "付费用户", value: paidSplit[0]?.value || "--" },
{ label: "新增付费", value: paidSplit[1]?.value || "--" }
],
title: "付费转化率",
trendMetric: model.kpis[4],
value: payerRate.value || "--",
valueLabel: "付费转化率"
},
{
delta: arpu.delta,
details: [
{ label: "ARPU", value: arpu.value || "--" },
{ label: "ARPPU", value: arppu.value || "--" }
],
title: "ARPPU",
trendMetric: arpu,
value: arppu.value || "--",
valueLabel: "ARPPU"
}
];
}
function firstSortableColumn(columns) {
const column = columns.find((item) => item.sortable !== false);
return column ? { direction: column.type === "text" ? "asc" : "desc", key: column.key, type: column.type || "number" } : null;
}
function sortRows(rows, sortState, columns) {
if (!sortState?.key) {
return rows;
}
const column = columns.find((item) => item.key === sortState.key);
if (!column) {
return rows;
}
const multiplier = sortState.direction === "asc" ? 1 : -1;
return rows
.map((row, index) => ({ index, row }))
.sort((left, right) => {
const result = compareValues(readSortValue(left.row, column), readSortValue(right.row, column), sortState.type);
return result === 0 ? left.index - right.index : result * multiplier;
})
.map(({ row }) => row);
}
function readSortValue(row, column) {
return column.sortValue ? column.sortValue(row) : row[column.key];
}
function compareValues(leftValue, rightValue, type) {
if (type === "text") {
return textCollator.compare(String(leftValue || ""), String(rightValue || ""));
}
const leftNumber = Number(leftValue);
const rightNumber = Number(rightValue);
if (!Number.isFinite(leftNumber) && !Number.isFinite(rightNumber)) {
return 0;
}
if (!Number.isFinite(leftNumber)) {
return -1;
}
if (!Number.isFinite(rightNumber)) {
return 1;
}
return leftNumber - rightNumber;
}
function buildPlatformGrantQuery(baseQuery = {}, row = {}) {
const countryId = Number(row.country_id ?? row.countryId ?? baseQuery.countryId ?? 0);
const regionId = row.region_id ?? row.regionId ?? baseQuery.regionId;
return {
appCode: baseQuery.appCode,
countryId,
endMs: baseQuery.endMs,
regionId,
startMs: baseQuery.startMs,
statDay: row.stat_day || row.statDay || "",
statTz: baseQuery.statTz
};
}
function platformGrantCountryRows(row, rows) {
const sourceRows = row.row_type === "date_total" && Array.isArray(row.children) && row.children.length
? row.children
: collectPlatformGrantCountryRows(rows);
return aggregatePlatformGrantCountryRows(sourceRows)
.filter((item) => Number(item.platform_grant_coin || 0) > 0)
.sort((left, right) => Number(right.platform_grant_coin || 0) - Number(left.platform_grant_coin || 0));
}
function collectPlatformGrantCountryRows(rows) {
const out = [];
rows.forEach((row) => {
if (row.row_type === "country") {
out.push(row);
}
if (Array.isArray(row.children)) {
out.push(...collectPlatformGrantCountryRows(row.children));
}
});
return out;
}
function aggregatePlatformGrantCountryRows(rows) {
const grouped = new Map();
rows.forEach((row) => {
const key = [row.country_id ?? row.country, row.region_id ?? ""].join(":");
const current = grouped.get(key);
if (!current) {
grouped.set(key, { ...row, platform_grant_coin: Number(row.platform_grant_coin || 0) });
return;
}
const nextStatDay = current.stat_day && row.stat_day && current.stat_day === row.stat_day ? current.stat_day : "";
grouped.set(key, {
...current,
platform_grant_coin: Number(current.platform_grant_coin || 0) + Number(row.platform_grant_coin || 0),
stat_day: nextStatDay
});
});
return Array.from(grouped.values());
}
function platformGrantUserLabel(item = {}) {
return item.user?.display_user_id || item.display_user_id || item.user?.username || item.username || item.nickname || item.user_id || "用户";
}
function platformGrantUserMeta(item = {}) {
return [item.user?.username || item.username || item.nickname, item.user_id]
.filter((value, index, values) => value && values.indexOf(value) === index)
.join(" · ") || "--";
}
function platformGrantSourceLabel(value) {
switch (String(value || "").trim()) {
case "WalletLuckyGiftRewardCredited":
return "幸运礼物返奖";
case "WalletTaskRewardCredited":
return "任务奖励";
case "WalletWheelRewardCredited":
return "转盘奖励";
case "WalletRoomTurnoverRewardCredited":
return "房间流水奖励";
case "WalletInviteActivityRewardCredited":
return "邀请活动奖励";
case "WalletAgencyOpeningRewardCredited":
return "代理开通奖励";
case "WalletBalanceChanged":
return "资源发放";
case "WalletPlatformGrantCoinCredited":
return "平台发放";
default:
return value || "--";
}
}
function formatDateTime(value) {
const ms = Number(value || 0);
if (!ms) {
return "--";
}
return new Date(ms).toLocaleString("zh-CN", {
day: "2-digit",
hour: "2-digit",
hour12: false,
minute: "2-digit",
month: "2-digit",
second: "2-digit",
year: "numeric"
});
}
function formatSignedPercent(value) {
if (value === undefined || value === null || Number.isNaN(Number(value))) {
return "--";
}
return `${Number(value) >= 0 ? "+" : ""}${formatPercent(value)}`;
}
function reportRowKey(row, index) {
return [row.row_id, row.country, row.name, row.label, row.pool_id, row.game_id, row.platform_code, row.value, index]
.filter((item) => item !== undefined && item !== null && item !== "")
.join(":");
}
function reportRowClass(row) {
if (row.row_type === "total") {
return "report-row-total";
}
if (row.row_type === "date_total") {
return "report-row-date-total";
}
return "";
}
function reportTableRowProps(row) {
if (!row.onToggle) {
return {};
}
const action = row.expanded ? "收起" : "展开";
const label = `${action} ${row.date_label || "日期"} 国家明细`;
return {
"aria-expanded": row.expanded,
"aria-label": label,
className: "report-row-expandable",
role: "button",
tabIndex: 0,
onClick: row.onToggle,
onKeyDown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
row.onToggle();
}
}
};
}
function reportRowIndex(row, index) {
if (row.row_type === "total") {
return "总";
}
if (row.row_type === "date_total") {
return "日";
}
return index + 1;
}
function formatOptionalMoney(value) {
return isMissing(value) ? <span className="report-placeholder">--</span> : formatMoneyFull(value);
}
function formatOptionalCoin(value) {
return isMissing(value) ? <span className="report-placeholder">--</span> : formatCoin(value);
}
function formatOptionalPercent(value) {
return isMissing(value) ? <span className="report-placeholder">--</span> : formatPercent(value);
}
function formatDuration(value) {
if (isMissing(value)) {
return <span className="report-placeholder">--</span>;
}
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function isMissing(value) {
return value === undefined || value === null || Number.isNaN(Number(value));
}