口径统计更新

This commit is contained in:
zhx 2026-06-12 12:07:15 +08:00
parent 2ebacae7c2
commit 480b6e0825
5 changed files with 159 additions and 27 deletions

View File

@ -4,7 +4,7 @@ import { Panel } from "./Panel.jsx";
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
return (
<Panel title="ARPU 转化漏斗">
<Panel title="付费转化漏斗">
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
<div className="funnel-stack">
{loading

View File

@ -82,19 +82,22 @@ function buildSelfGameModel(overview) {
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 humanNetWin = participantNetWin(participants, "human", numberValue(totals.payout_coin) + numberValue(totals.refund_coin) - numberValue(totals.user_stake_coin));
const robotNetWin = participantNetWin(participants, "robot", numberValue(totals.robot_payout_coin) - numberValue(totals.robot_stake_coin));
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 = {
drawCount: numberValue(totals.draw_count),
humanNetWin,
loserCount: numberValue(totals.loser_count),
robotNetWin,
winnerCount: numberValue(totals.winner_count)
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 {
@ -111,7 +114,7 @@ function buildSelfGameModel(overview) {
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.humanNetWin)}`),
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 || [],
@ -215,7 +218,9 @@ function ParticipantTable({ items, loading }) {
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) },
@ -230,11 +235,13 @@ function ParticipantTable({ items, loading }) {
function WinLossTable({ loading, summary }) {
const rows = [
{ metric: "赢家数", value: summary.winnerCount, note: "按参与人结果统计" },
{ metric: "输家数", value: summary.loserCount, note: "按参与人结果统计" },
{ metric: "平局数", value: summary.drawCount, note: "按参与人结果统计" },
{ metric: "真人净输赢", value: summary.humanNetWin, note: "真人派奖加退款减真人下注", type: "coin" },
{ metric: "机器人净输赢", value: summary.robotNetWin, note: "机器人派奖减机器人下注", type: "coin" }
{ 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="用户输赢">
@ -260,7 +267,7 @@ function RiskTable({ items, loading }) {
loading={loading}
rows={items}
columns={[
{ key: "user_id", label: "用户", render: (row) => row.user_id || "--" },
{ 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) },
@ -271,6 +278,24 @@ function RiskTable({ items, loading }) {
);
}
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="口径">
@ -327,12 +352,36 @@ function rowKey(row, rowIndex) {
.join(":");
}
function participantNetWin(items, participantType, fallback) {
const matched = items.filter((item) => item.participant_type === participantType);
if (!matched.length) {
return fallback;
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 matched.reduce((total, item) => total + numberValue(item.net_win_coin), 0);
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) {
@ -359,7 +408,7 @@ function participantLabel(value) {
if (value === "robot") {
return "机器人";
}
if (value === "human") {
if (isRealUserParticipant(value)) {
return "真人";
}
return value || "--";

View File

@ -106,7 +106,7 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
],
sideMetrics: [
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, activeUsers)) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
],
topCountries: countryBreakdown.slice(0, 4),
@ -223,8 +223,11 @@ function normalizeCountryRow(item, index, totalRecharge) {
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
const meta = resolveCountryMeta({ code, name: country });
const recharge = effectiveRechargeUSDMinor(item);
const activeUsers = numberValue(item.active_users);
const paidUsers = numberValue(item.paid_users);
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
return {
active_users: numberValue(item.active_users),
active_users: activeUsers,
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
arppu_usd_minor: numberValue(item.arppu_usd_minor),
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
@ -234,11 +237,13 @@ function normalizeCountryRow(item, index, totalRecharge) {
flag: countryFlag(code || meta?.code),
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
paid_users: numberValue(item.paid_users),
payer_rate: ratio(item.paid_users, item.active_users),
paid_users: paidUsers,
payer_rate: ratio(paidUsers, activeUsers),
recharge_usd_minor: recharge,
recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate),
recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers),
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
: ratio(rechargeUsers, activeUsers),
recharge_users: rechargeUsers,
share: recharge / totalRecharge,
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)

View File

@ -67,6 +67,30 @@ test("exposes new user KPI and removes visitor stage from funnel", () => {
expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
});
test("uses active users as payer and recharge conversion denominator", () => {
const model = createDashboardModel({
active_users: 80,
country_breakdown: [
{
active_users: 20,
country: "巴西",
paid_users: 8,
recharge_users: 6,
recharge_usd_minor: 500
}
],
new_users: 4,
paid_users: 8,
recharge_users: 6
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
expect(model.sideMetrics.find((item) => item.label === "付费转化率")?.value).toBe("10.00%");
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({
payer_rate: 0.4,
recharge_conversion_rate: 0.3
}));
});
function metricValue(model, label) {
return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
}

View File

@ -197,3 +197,57 @@
font-size: 12px;
line-height: 1.45;
}
.risk-user-cell {
display: grid;
min-width: 0;
grid-template-columns: 30px minmax(0, 1fr);
align-items: center;
gap: 8px;
text-align: left;
}
.risk-user-avatar {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
border: 1px solid rgba(39, 228, 245, 0.28);
border-radius: 50%;
background: rgba(39, 228, 245, 0.12);
color: #e9f6ff;
font-size: 12px;
font-weight: 760;
object-fit: cover;
}
.risk-user-avatar--empty {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.risk-user-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.risk-user-copy strong,
.risk-user-copy small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-user-copy strong {
color: #e9f6ff;
font-size: 12px;
font-weight: 760;
}
.risk-user-copy small {
color: #88a7c2;
font-size: 10px;
}