2026-06-12 12:07:15 +08:00

439 lines
17 KiB
JavaScript

import { MetricCard } from "./MetricCard.jsx";
import { Panel } from "./Panel.jsx";
import { formatCoin, formatNumber, formatPercent } from "../utils/format.js";
import { numberValue } from "../utils/number.js";
const gameOptions = [
{ label: "全部", value: "all" },
{ label: "Dice", value: "dice" },
{ label: "Rock", value: "rock" }
];
const eventLabels = {
page_open: "页面打开",
config_load_success: "配置成功",
config_load_failed: "配置失败",
gesture_select: "选择手势",
match_api: "匹配接口",
match_cancel: "取消匹配",
stake_selected: "选择档位",
stake_select: "选择档位",
match_click: "点击匹配",
match_success: "匹配成功",
animation_start: "进入开奖",
preload_complete: "预加载完成",
reveal_enter: "进入开奖",
roll_api: "开奖接口",
settlement_show: "结算展示",
play_again: "再来一局",
play_again_click: "再来一局",
exit_click: "退出"
};
export function SelfGameScreen({ gameId = "all", loading = false, onGameChange, overview }) {
const model = buildSelfGameModel(overview);
const showSkeleton = loading && !overview;
return (
<section className="self-game-screen" aria-label="自研游戏统计">
<div className="self-game-toolbar">
<div className="self-game-title">
<strong>自研游戏</strong>
<span>Dice / Rock H5 聚合统计</span>
</div>
<div className="game-switch" role="tablist" aria-label="游戏筛选">
{gameOptions.map((item) => (
<button className={gameId === item.value ? "is-active" : ""} key={item.value} onClick={() => onGameChange?.(item.value)} type="button">
{item.label}
</button>
))}
</div>
</div>
<section className="self-game-kpi-grid" aria-label="自研游戏核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} loading={showSkeleton} />
))}
</section>
<section className="self-game-panel-grid self-game-panel-grid--top">
<FunnelTable funnel={model.funnel} loading={showSkeleton} />
<StakeTable items={model.stakes} loading={showSkeleton} />
<PoolTable items={model.pools} loading={showSkeleton} />
</section>
<section className="self-game-panel-grid">
<ParticipantTable items={model.participants} loading={showSkeleton} />
<WinLossTable summary={model.winLoss} loading={showSkeleton} />
<RiskTable items={model.risks} loading={showSkeleton} />
</section>
<section className="self-game-panel-grid self-game-panel-grid--single">
<MetricDefinitionList items={model.definitions} loading={showSkeleton} />
</section>
</section>
);
}
function buildSelfGameModel(overview) {
const events = eventIndex(overview?.events);
const totals = overview?.match_totals || {};
const retention = overview?.retention || {};
const totalStake = numberValue(totals.user_stake_coin) + numberValue(totals.robot_stake_coin);
const participants = Array.isArray(overview?.participant_distribution) ? overview.participant_distribution : [];
const pools = Array.isArray(overview?.pool_stats) ? overview.pool_stats : [];
const userResult = realUserResultCounts(participants, totals);
const userNetWin = numberValue(totals.payout_coin) + numberValue(totals.refund_coin) - numberValue(totals.user_stake_coin);
const poolBalance = latestPoolBalance(pools);
const settlementEvent = events.get("settlement_show");
const matchClickEvent = events.get("match_click");
const matchSuccessEvent = events.get("match_success");
const pageOpenEvent = events.get("page_open");
const winLoss = {
countNote: userResult.countNote,
drawCount: userResult.draw,
loserCount: userResult.lose,
payoutCoin: numberValue(totals.payout_coin),
refundCoin: numberValue(totals.refund_coin),
userNetWin,
userStakeCoin: numberValue(totals.user_stake_coin),
winnerCount: userResult.win
};
return {
kpis: [
metric("游戏 PV / UV", `${formatNumber(pageOpenEvent.event_count)} / ${formatNumber(pageOpenEvent.user_count)}`, "进入 dice、rock H5 的次数和人数"),
metric("开局人数", `点击 ${formatNumber(matchClickEvent.user_count)} / 成功 ${formatNumber(matchSuccessEvent.user_count)}`, `${formatNumber(matchClickEvent.event_count)} 次点击`),
metric("创建局", formatNumber(totals.created_matches), "服务端创建的对局数"),
metric("匹配成功局", formatNumber(totals.matched_matches), `${formatPercent(overview?.conversion?.match_click_to_success_rate)} 点击后成功`),
metric("结算完成 UV", formatNumber(settlementEvent.user_count), `${formatPercent(overview?.conversion?.page_to_settlement_rate)} 页面到结算`),
metric("已结算局", formatNumber(totals.settled_matches), "已经完成派奖或退款的局"),
metric("取消 / 失败局", `${formatNumber(totals.canceled_matches)} / ${formatNumber(totals.failed_matches)}`, `${formatPercent(totals.cancel_rate)} / ${formatPercent(totals.fail_rate)}`),
metric("参与人次", formatNumber(numberValue(totals.human_participants) + numberValue(totals.robot_participants)), `真人 ${formatNumber(totals.human_participants)} / 机器人 ${formatNumber(totals.robot_participants)}`),
metric("总下注金币", formatCoin(totalStake), `真人 ${formatCoin(totals.user_stake_coin)} / 机器人 ${formatCoin(totals.robot_stake_coin)}`),
metric("派奖金额", formatCoin(totals.payout_coin), `退款 ${formatCoin(totals.refund_coin)}`),
metric("平台抽水", formatCoin(totals.platform_profit_coin), formatPercent(totals.platform_profit_rate)),
metric("奖池变化", formatCoin(totals.pool_delta_coin), `余额 ${formatCoin(poolBalance)} / 强制 ${formatNumber(totals.forced_player_lose_matches)}`),
metric("用户输赢", `${formatNumber(winLoss.winnerCount)} / ${formatNumber(winLoss.loserCount)} / ${formatNumber(winLoss.drawCount)}`, `真实用户净输赢 ${formatCoin(winLoss.userNetWin)}`),
metric("次日 / 7日留存", `${formatPercent(retention.day1_rate)} / ${formatPercent(retention.day7_rate)}`, `样本 ${formatNumber(retention.cohort_users)}`)
],
definitions: overview?.metric_definitions || [],
funnel: overview?.funnel || [],
participants,
pools,
risks: overview?.user_risk || [],
stakes: overview?.stake_breakdown || [],
winLoss
};
}
function eventIndex(items) {
const index = new Map();
(Array.isArray(items) ? items : []).forEach((item) => {
const name = item.event_name || "";
const current = index.get(name) || emptyEvent();
index.set(name, {
event_count: numberValue(current.event_count) + numberValue(item.event_count),
user_count: numberValue(current.user_count) + numberValue(item.user_count)
});
});
return {
get(name) {
return index.get(name) || emptyEvent();
}
};
}
function emptyEvent() {
return { event_count: 0, user_count: 0 };
}
function metric(label, value, caption) {
return { caption, label, value };
}
function FunnelTable({ funnel, loading }) {
return (
<Panel className="panel--self-game" title="转化漏斗">
<DataTable
emptyText="暂无漏斗数据"
loading={loading}
rows={funnel}
columns={[
{ key: "event_name", label: "节点", render: (row) => eventLabels[row.event_name] || row.event_name || "--" },
{ key: "user_count", label: "UV", render: (row) => formatNumber(row.user_count) },
{ key: "event_count", label: "次数", render: (row) => formatNumber(row.event_count) }
]}
/>
</Panel>
);
}
function StakeTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="下注档位">
<DataTable
emptyText="暂无档位数据"
loading={loading}
rows={items}
wide
columns={[
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
{ key: "created_matches", label: "创建局", render: (row) => formatNumber(row.created_matches) },
{ key: "matched_matches", label: "匹配局", render: (row) => formatNumber(row.matched_matches) },
{ key: "settled_matches", label: "结算局", render: (row) => formatNumber(row.settled_matches) },
{ key: "user_stake_coin", label: "真人下注", render: (row) => formatCoin(row.user_stake_coin) },
{ key: "robot_stake_coin", label: "机器人下注", render: (row) => formatCoin(row.robot_stake_coin) },
{ key: "stake", label: "总下注", render: (row) => formatCoin(numberValue(row.user_stake_coin) + numberValue(row.robot_stake_coin)) },
{ key: "payout_coin", label: "派奖", render: (row) => formatCoin(row.payout_coin) },
{ key: "profit", label: "盈利", render: (row) => formatCoin(row.platform_profit_coin) }
]}
/>
</Panel>
);
}
function PoolTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="奖池流水">
<DataTable
emptyText="暂无奖池流水"
loading={loading}
rows={items}
columns={[
{ key: "direction", label: "方向", render: (row) => directionLabel(row.direction) },
{ key: "reason", label: "原因", render: (row) => row.reason || "--" },
{ key: "flow", label: "入 / 出", render: (row) => `${formatCoin(row.in_coin)} / ${formatCoin(row.out_coin)}` },
{ key: "balance_after_coin", label: "余额", render: (row) => formatCoin(row.balance_after_coin) }
]}
/>
</Panel>
);
}
function ParticipantTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="玩法分布">
<DataTable
emptyText="暂无玩法数据"
loading={loading}
rows={items}
wide
columns={[
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
{ key: "type", label: "对象", render: (row) => participantLabel(row.participant_type) },
{ key: "play", label: "手势 / 点数", render: (row) => playLabel(row) },
{ key: "result", label: "结果", render: (row) => resultLabel(row.result) },
{ key: "count", label: "人次", render: (row) => formatNumber(row.participant_count) },
{ key: "net_win_coin", label: "净输赢", render: (row) => formatCoin(row.net_win_coin) },
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) }
]}
/>
</Panel>
);
}
function WinLossTable({ loading, summary }) {
const rows = [
{ metric: "真实用户赢家数", value: summary.winnerCount, note: summary.countNote },
{ metric: "真实用户输家数", value: summary.loserCount, note: summary.countNote },
{ metric: "真实用户平局数", value: summary.drawCount, note: summary.countNote },
{ metric: "真实用户下注", value: summary.userStakeCoin, note: "match_totals.user_stake_coin", type: "coin" },
{ metric: "真实用户派奖", value: summary.payoutCoin, note: "match_totals.payout_coin", type: "coin" },
{ metric: "真实用户退款", value: summary.refundCoin, note: "match_totals.refund_coin", type: "coin" },
{ metric: "真实用户净输赢", value: summary.userNetWin, note: "派奖 + 退款 - 真实用户下注", type: "coin" }
];
return (
<Panel className="panel--self-game" title="用户输赢">
<DataTable
emptyText="暂无输赢数据"
loading={loading}
rows={rows}
columns={[
{ key: "metric", label: "指标", render: (row) => row.metric },
{ key: "value", label: "数值", render: (row) => (row.type === "coin" ? formatCoin(row.value) : formatNumber(row.value)) },
{ key: "note", label: "口径", render: (row) => row.note }
]}
/>
</Panel>
);
}
function RiskTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="高风险用户">
<DataTable
emptyText="暂无风险用户"
loading={loading}
rows={items}
columns={[
{ key: "user_id", label: "用户", render: (row) => <RiskUserCell row={row} /> },
{ key: "match_count", label: "局数", render: (row) => formatNumber(row.match_count) },
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) },
{ key: "net_win_coin", label: "净赢", render: (row) => formatCoin(row.net_win_coin) },
{ key: "cancel_rate", label: "取消率", render: (row) => formatPercent(row.cancel_rate) }
]}
/>
</Panel>
);
}
function RiskUserCell({ row }) {
const nickname = row.nickname || row.username || "";
const shortID = row.short_id || row.display_user_id || "";
const userID = row.user_id ? String(row.user_id) : "";
const avatar = row.avatar || "";
const primary = nickname || shortID || userID || "--";
const secondary = shortID ? `ID ${shortID}` : userID ? `UID ${userID}` : "";
return (
<div className="risk-user-cell">
{avatar ? <img alt="" className="risk-user-avatar" src={avatar} /> : <span className="risk-user-avatar risk-user-avatar--empty">{primary.slice(0, 1).toUpperCase()}</span>}
<span className="risk-user-copy">
<strong>{primary}</strong>
{secondary ? <small>{secondary}</small> : null}
</span>
</div>
);
}
function MetricDefinitionList({ items, loading }) {
return (
<Panel className="panel--self-game" title="口径">
{loading ? <div className="panel-empty">加载中...</div> : null}
{!loading && !items.length ? <div className="panel-empty">暂无口径说明</div> : null}
{!loading && items.length ? (
<div className="definition-list">
{items.map((item) => (
<div className="definition-row" key={item.metric}>
<strong>{item.metric}</strong>
<span>{item.definition}</span>
</div>
))}
</div>
) : null}
</Panel>
);
}
function DataTable({ columns, emptyText, loading, rows, wide = false }) {
if (loading) {
return <div className="panel-empty">加载中...</div>;
}
if (!rows.length) {
return <div className="panel-empty">{emptyText}</div>;
}
return (
<div className="self-game-table-wrap">
<table className={["self-game-table", wide ? "self-game-table--wide" : ""].filter(Boolean).join(" ")}>
<thead>
<tr>
{columns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowKey(row, rowIndex)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function rowKey(row, rowIndex) {
return [row.game_id, row.user_id, row.metric, row.stake_coin, row.event_name, row.reason, row.participant_type, row.result, row.rps_gesture, row.dice_point, rowIndex]
.filter((item) => item !== undefined && item !== null && item !== "")
.join(":");
}
function realUserResultCounts(items, totals) {
const result = { countNote: "按 participant_distribution 中真实用户结果统计", draw: 0, lose: 0, win: 0 };
let userRows = 0;
items.forEach((item) => {
if (!isRealUserParticipant(item.participant_type)) {
return;
}
userRows += 1;
const count = numberValue(item.participant_count);
if (item.result === "win") {
result.win += count;
} else if (item.result === "lose") {
result.lose += count;
} else if (item.result === "draw") {
result.draw += count;
}
});
if (userRows > 0) {
return result;
}
return {
countNote: "缺少真实用户分布时回退 match_totals 参与人结果",
draw: numberValue(totals.draw_count),
lose: numberValue(totals.loser_count),
win: numberValue(totals.winner_count)
};
}
function isRealUserParticipant(value) {
return value === "user" || value === "human";
}
function latestPoolBalance(items) {
if (!items.length) {
return 0;
}
return items.reduce((balance, item) => {
const next = numberValue(item.balance_after_coin);
return next !== 0 ? next : balance;
}, 0);
}
function directionLabel(value) {
if (value === "in") {
return "入池";
}
if (value === "out") {
return "出池";
}
return value || "--";
}
function participantLabel(value) {
if (value === "robot") {
return "机器人";
}
if (isRealUserParticipant(value)) {
return "真人";
}
return value || "--";
}
function resultLabel(value) {
if (value === "win") {
return "胜";
}
if (value === "lose") {
return "负";
}
if (value === "draw") {
return "平";
}
return value || "--";
}
function playLabel(row) {
if (row.rps_gesture) {
return row.rps_gesture;
}
if (numberValue(row.dice_point) > 0) {
return `${row.dice_point}`;
}
return "--";
}