383 lines
13 KiB
JavaScript
383 lines
13 KiB
JavaScript
// 埋点漏斗:展示 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 (
|
|
<div className="sbi-funnel" aria-label="埋点漏斗加载中">
|
|
<div className="sbi-funnel-skeleton">
|
|
{["70%", "92%", "84%", "76%", "88%"].map((width) => (
|
|
<span className="sbi-skeleton" key={width} style={{ width }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!appRows.length) {
|
|
return (
|
|
<div className="sbi-funnel">
|
|
<div className="sbi-empty">
|
|
<strong>当前无埋点漏斗数据</strong>
|
|
<span>
|
|
{appErrors.length
|
|
? "所选 App 暂未接入漏斗或统计服务返回错误"
|
|
: `埋点漏斗 App 筛选目前仅支持 ${SUPPORTED_FUNNEL_APPS}`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="sbi-funnel">
|
|
<div className="sbi-funnel-head">
|
|
<div>
|
|
<h1>埋点漏斗</h1>
|
|
<span>
|
|
{rangeLabel(range)} · 仅支持 {SUPPORTED_FUNNEL_APPS}
|
|
</span>
|
|
</div>
|
|
<div className="sbi-funnel-apps">
|
|
{appRows.map((app) => (
|
|
<span key={app.app_code}>{app.app_name || app.app_code}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{appErrors.length ? (
|
|
<div className="sbi-funnel-warning">
|
|
{appErrors.map((app) => (
|
|
<span key={app.app_code}>
|
|
{app.app_name || app.app_code}: {app.error}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<section className="sbi-funnel-summary" aria-label="漏斗核心指标">
|
|
{SUMMARY_CARDS.map((item) => (
|
|
<article className="sbi-card sbi-funnel-kpi" key={item.key}>
|
|
<span>{item.label}</span>
|
|
<strong>{item.type === "ratio" ? formatRatioPercent(totals[item.key]) : formatCount(totals[item.key])}</strong>
|
|
</article>
|
|
))}
|
|
</section>
|
|
|
|
<section className="sbi-funnel-grid">
|
|
<article className="sbi-card sbi-funnel-chart-card">
|
|
<div className="sbi-card-title">
|
|
<div>
|
|
<h2>主路径转化</h2>
|
|
<span>按去重用户数计算</span>
|
|
</div>
|
|
</div>
|
|
<EChart className="sbi-funnel-chart" option={chartOption} />
|
|
</article>
|
|
|
|
<article className="sbi-card sbi-funnel-table-card">
|
|
<div className="sbi-card-title">
|
|
<div>
|
|
<h2>事件明细</h2>
|
|
<span>含进房失败和互动动作</span>
|
|
</div>
|
|
</div>
|
|
<div className="sbi-table-scroll sbi-funnel-step-scroll">
|
|
<table className="sbi-table sbi-funnel-step-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="is-left">事件</th>
|
|
<th>用户</th>
|
|
<th>次数</th>
|
|
<th>上一步</th>
|
|
<th>总转化</th>
|
|
<th>流失</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{steps.map((step) => (
|
|
<tr className={step.is_failure ? "is-failure" : ""} key={step.event_name}>
|
|
<td className="is-left">
|
|
<strong>{step.label || step.event_name}</strong>
|
|
<span>{step.event_name}</span>
|
|
</td>
|
|
<td>{formatCount(step.user_count)}</td>
|
|
<td>{formatCount(step.event_count)}</td>
|
|
<td>{formatRatioPercent(step.previous_conversion_rate)}</td>
|
|
<td>{formatRatioPercent(step.overall_conversion_rate)}</td>
|
|
<td>{formatCount(step.dropoff_users)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section className="sbi-card sbi-funnel-cohort-card">
|
|
<div className="sbi-card-title sbi-funnel-cohort-title">
|
|
<div>
|
|
<h2>D1 Cohort</h2>
|
|
<span>基准用户为登录成功用户</span>
|
|
</div>
|
|
<div className="sbi-funnel-tabs" role="tablist" aria-label="D1 cohort 维度">
|
|
{COHORT_DIMENSIONS.map((item) => (
|
|
<button
|
|
aria-selected={dimension === item.key}
|
|
className={dimension === item.key ? "is-active" : ""}
|
|
key={item.key}
|
|
onClick={() => setDimension(item.key)}
|
|
role="tab"
|
|
type="button"
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="sbi-table-scroll sbi-funnel-cohort-scroll">
|
|
<table className="sbi-table sbi-funnel-cohort-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="is-left">{COHORT_DIMENSIONS.find((item) => item.key === dimension)?.label || "Cohort"}</th>
|
|
<th>基准用户</th>
|
|
<th>D1 用户</th>
|
|
<th>D1 留存</th>
|
|
{COHORT_STEP_COLUMNS.map((item) => (
|
|
<th key={item.key}>{item.label}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{selectedCohorts.length ? (
|
|
selectedCohorts.map((row) => (
|
|
<tr key={`${row.dimension}:${row.value}`}>
|
|
<td className="is-left">
|
|
<strong>{row.label || row.value || "unknown"}</strong>
|
|
</td>
|
|
<td>{formatCount(row.base_users)}</td>
|
|
<td>{formatCount(row.d1_retention_users)}</td>
|
|
<td>{formatRatioPercent(row.d1_retention_rate)}</td>
|
|
{COHORT_STEP_COLUMNS.map((item) => (
|
|
<td key={item.key}>{formatCount(stepUserCount(row, item.key))}</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td className="sbi-funnel-empty-cell" colSpan={4 + COHORT_STEP_COLUMNS.length}>
|
|
当前维度暂无 cohort 数据
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|