// 埋点漏斗:展示 App 原始 tracking 事件的总体漏斗、失败诊断和 D1 cohort。 // 数据源为 statistics-service 的 app_tracking_events 聚合结果,视图只做跨 App 求和和排序。 import { useMemo, useState } from "react"; import { EChart } from "../../charts/EChart.jsx"; import { formatCount, formatRatioPercent, isBlank } from "../format.js"; import { rangeLabel } from "../state.js"; import { useSocialBi } from "../SocialBiApp.jsx"; import "./funnel-view.css"; const COHORT_DIMENSIONS = [ { key: "country", label: "国家" }, { key: "language", label: "语言" }, { key: "channel", label: "渠道" }, { key: "login_method", label: "登录方式" }, { key: "first_room_stay", label: "首房停留时长" } ]; const COHORT_STEP_COLUMNS = [ { key: "login_success", label: "登录成功" }, { key: "profile_complete", label: "资料完成" }, { key: "room_join_success", label: "进房成功" }, { key: "stay_3m", label: "停留 3m" }, { key: "stay_10m", label: "停留 10m" }, { key: "send_message", label: "发消息" } ]; const SUMMARY_CARDS = [ { key: "login_start_users", label: "登录开始", type: "count" }, { key: "login_success_rate", label: "登录成功率", type: "ratio" }, { key: "room_join_success_users", label: "进房成功", type: "count" }, { key: "d1_retention_rate", label: "D1 留存", type: "ratio" }, { key: "room_join_fail_users", label: "进房失败", type: "count" } ]; const SUPPORTED_FUNNEL_APPS = "Lalu / Huwaa / Fami"; export function FunnelView() { const { funnel, isLoading, range } = useSocialBi(); const [dimension, setDimension] = useState("country"); const appRows = useMemo(() => (funnel?.apps || []).filter((app) => !app.error), [funnel]); const appErrors = useMemo(() => (funnel?.apps || []).filter((app) => app.error), [funnel]); const steps = useMemo(() => aggregateSteps(appRows), [appRows]); const totals = useMemo(() => aggregateTotals(appRows), [appRows]); const cohorts = useMemo(() => aggregateCohorts(appRows), [appRows]); const selectedCohorts = useMemo( () => (cohorts.get(dimension) || []).slice(0, 50), [cohorts, dimension] ); const chartOption = useMemo(() => funnelChartOption(steps), [steps]); if (isLoading && !appRows.length) { return (
{["70%", "92%", "84%", "76%", "88%"].map((width) => ( ))}
); } if (!appRows.length) { return (
当前无埋点漏斗数据 {appErrors.length ? "所选 App 暂未接入漏斗或统计服务返回错误" : `埋点漏斗 App 筛选目前仅支持 ${SUPPORTED_FUNNEL_APPS}`}
); } return (

埋点漏斗

{rangeLabel(range)} · 仅支持 {SUPPORTED_FUNNEL_APPS}
{appRows.map((app) => ( {app.app_name || app.app_code} ))}
{appErrors.length ? (
{appErrors.map((app) => ( {app.app_name || app.app_code}: {app.error} ))}
) : null}
{SUMMARY_CARDS.map((item) => (
{item.label} {item.type === "ratio" ? formatRatioPercent(totals[item.key]) : formatCount(totals[item.key])}
))}

主路径转化

按去重用户数计算

事件明细

含进房失败和互动动作
{steps.map((step) => ( ))}
事件 用户 次数 上一步 总转化 流失
{step.label || step.event_name} {step.event_name} {formatCount(step.user_count)} {formatCount(step.event_count)} {formatRatioPercent(step.previous_conversion_rate)} {formatRatioPercent(step.overall_conversion_rate)} {formatCount(step.dropoff_users)}

D1 Cohort

基准用户为登录成功用户
{COHORT_DIMENSIONS.map((item) => ( ))}
{COHORT_STEP_COLUMNS.map((item) => ( ))} {selectedCohorts.length ? ( selectedCohorts.map((row) => ( {COHORT_STEP_COLUMNS.map((item) => ( ))} )) ) : ( )}
{COHORT_DIMENSIONS.find((item) => item.key === dimension)?.label || "Cohort"} 基准用户 D1 用户 D1 留存{item.label}
{row.label || row.value || "unknown"} {formatCount(row.base_users)} {formatCount(row.d1_retention_users)} {formatRatioPercent(row.d1_retention_rate)}{formatCount(stepUserCount(row, item.key))}
当前维度暂无 cohort 数据
); } function aggregateSteps(appRows) { const order = []; const byEvent = new Map(); appRows.forEach((app) => { (app.steps || []).forEach((step) => { const key = step.event_name || step.key; if (!key) { return; } if (!byEvent.has(key)) { order.push(key); byEvent.set(key, { ...step, device_count: 0, event_count: 0, user_count: 0 }); } const current = byEvent.get(key); current.device_count += Number(step.device_count || 0); current.event_count += Number(step.event_count || 0); current.user_count += Number(step.user_count || 0); current.is_failure = Boolean(current.is_failure || step.is_failure); if (!current.label) { current.label = step.label; } }); }); const baseUsers = Number(byEvent.get("login_start")?.user_count || (order.length ? byEvent.get(order[0])?.user_count || 0 : 0)); let previousUsers = 0; return order.map((key) => { const step = byEvent.get(key); const userCount = Number(step.user_count || 0); const out = { ...step, dropoff_users: previousUsers > userCount ? previousUsers - userCount : 0, overall_conversion_rate: ratio(userCount, baseUsers), previous_conversion_rate: ratio(userCount, previousUsers) }; previousUsers = userCount; return out; }); } function aggregateTotals(appRows) { const totals = {}; let d1Base = 0; let d1Users = 0; appRows.forEach((app) => { Object.entries(app.totals || {}).forEach(([key, value]) => { if (key.endsWith("_rate")) { return; } totals[key] = Number(totals[key] || 0) + Number(value || 0); }); d1Base += Number(app.totals?.d1_retention_base_users || 0); d1Users += Number(app.totals?.d1_retention_users || 0); }); totals.login_success_rate = ratio(totals.login_success_users, totals.login_start_users); totals.d1_retention_rate = ratio(d1Users, d1Base); return totals; } function aggregateCohorts(appRows) { const grouped = new Map(); appRows.forEach((app) => { (app.cohorts || []).forEach((cohort) => { const dimension = cohort.dimension || "unknown"; const value = cohort.value || cohort.label || "unknown"; if (!grouped.has(dimension)) { grouped.set(dimension, new Map()); } const bucket = grouped.get(dimension); if (!bucket.has(value)) { bucket.set(value, { dimension, label: cohort.label || value, value, base_users: 0, d1_retention_users: 0, d1_retention_rate: 0, steps: new Map() }); } const row = bucket.get(value); row.base_users += Number(cohort.base_users || 0); row.d1_retention_users += Number(cohort.d1_retention_users || 0); (cohort.steps || []).forEach((step) => { const eventName = step.event_name; if (!eventName) { return; } row.steps.set(eventName, Number(row.steps.get(eventName) || 0) + Number(step.user_count || 0)); }); }); }); const out = new Map(); grouped.forEach((bucket, dimension) => { const rows = [...bucket.values()].map((row) => ({ ...row, d1_retention_rate: ratio(row.d1_retention_users, row.base_users), steps: [...row.steps.entries()].map(([event_name, user_count]) => ({ event_name, user_count })) })); rows.sort((left, right) => Number(right.base_users || 0) - Number(left.base_users || 0)); out.set(dimension, rows); }); return out; } function stepUserCount(row, eventName) { const step = (row.steps || []).find((item) => item.event_name === eventName); return step?.user_count; } function funnelChartOption(steps) { const data = steps .filter((step) => !step.is_failure && Number(step.user_count || 0) > 0) .map((step) => ({ name: step.label || step.event_name, value: Number(step.user_count || 0) })); return { color: ["#3056d3", "#3b82f6", "#14b8a6", "#22c55e", "#f59e0b", "#ef4444"], series: [ { bottom: 18, data, gap: 4, label: { color: "#1f2a44", formatter: ({ name, value }) => `${name}\n${formatCount(value)}` }, left: 18, minSize: "8%", right: 18, sort: "none", top: 10, type: "funnel" } ], tooltip: { backgroundColor: "#ffffff", borderColor: "#e3eaf3", textStyle: { color: "#263246" }, valueFormatter: (value) => formatCount(value) } }; } function ratio(numerator, denominator) { if (isBlank(denominator) || Number(denominator) <= 0) { return null; } return Number(numerator || 0) / Number(denominator); }