439 lines
17 KiB
JavaScript
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 "--";
|
|
}
|