From 480b6e08256d0ba935b0da521b2c570d74853802 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 12:07:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=A3=E5=BE=84=E7=BB=9F=E8=AE=A1=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- databi/src/components/FunnelPanel.jsx | 2 +- databi/src/components/SelfGameScreen.jsx | 89 +++++++++++++++----- databi/src/data/createDashboardModel.js | 17 ++-- databi/src/data/createDashboardModel.test.js | 24 ++++++ databi/src/styles/tables.css | 54 ++++++++++++ 5 files changed, 159 insertions(+), 27 deletions(-) diff --git a/databi/src/components/FunnelPanel.jsx b/databi/src/components/FunnelPanel.jsx index 1403eb9..9acedd8 100644 --- a/databi/src/components/FunnelPanel.jsx +++ b/databi/src/components/FunnelPanel.jsx @@ -4,7 +4,7 @@ import { Panel } from "./Panel.jsx"; export function FunnelPanel({ funnel, loading = false, sideMetrics }) { return ( - +
{loading diff --git a/databi/src/components/SelfGameScreen.jsx b/databi/src/components/SelfGameScreen.jsx index 218ed2a..54862b6 100644 --- a/databi/src/components/SelfGameScreen.jsx +++ b/databi/src/components/SelfGameScreen.jsx @@ -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 ( @@ -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) => }, { 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 ( +
+ {avatar ? : {primary.slice(0, 1).toUpperCase()}} + + {primary} + {secondary ? {secondary} : null} + +
+ ); +} + function MetricDefinitionList({ items, loading }) { return ( @@ -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 || "--"; diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js index 3b0a2f9..5b71136 100644 --- a/databi/src/data/createDashboardModel.js +++ b/databi/src/data/createDashboardModel.js @@ -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) diff --git a/databi/src/data/createDashboardModel.test.js b/databi/src/data/createDashboardModel.test.js index 66a6f51..02bf059 100644 --- a/databi/src/data/createDashboardModel.test.js +++ b/databi/src/data/createDashboardModel.test.js @@ -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; } diff --git a/databi/src/styles/tables.css b/databi/src/styles/tables.css index 5a8668e..d2d8808 100644 --- a/databi/src/styles/tables.css +++ b/databi/src/styles/tables.css @@ -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; +}