口径统计更新
This commit is contained in:
parent
2ebacae7c2
commit
480b6e0825
@ -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
|
||||
|
||||
@ -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 || "--";
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user