// RetentionView(留存质量):整体 D1/D7/D30 概要卡、新增×次日留存组合图、 // 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。 import { useMemo, useState } from "react"; import { useSocialBi } from "../SocialBiApp.jsx"; import { EChart } from "../../charts/EChart.jsx"; import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js"; import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.js"; import { ALL, bucketDay } from "../state.js"; import "./retention-view.css"; // 图表 canvas 内无法读 CSS 变量,这里用与 social-v2.css token 一致的十六进制值。 const CHART_ACCENT = "#2557d6"; // var(--sbi-accent) const CHART_ACCENT_2 = "#0e7490"; // var(--sbi-accent-2) const CHART_GRID_LINE = "#eef3f9"; const CHART_TEXT = "#5b7089"; const CHART_GRID = { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 }; const CHART_LEGEND = { itemHeight: 9, itemWidth: 14, textStyle: { color: CHART_TEXT, fontSize: 12 }, top: 0 }; const CHART_TOOLTIP = { backgroundColor: "#ffffff", borderColor: CHART_GRID_LINE, textStyle: { color: CHART_TEXT }, trigger: "axis" }; const RETENTION_SERIES = [ { color: CHART_ACCENT, key: "d1_retention_rate" }, { color: CHART_ACCENT_2, key: "d7_retention_rate" }, { color: "#7c3aed", key: "d30_retention_rate" } ]; const HEAT_KEYS = new Set(["d1_retention_rate", "d7_retention_rate", "d30_retention_rate"]); const TABLE_METRIC_KEYS = [ "new_users", "active_users", "d1_retention_rate", "d7_retention_rate", "d30_retention_rate", "paid_conversion_rate" ]; const GRANULARITY_LABELS = { day: "日", month: "月", week: "周" }; // 把日粒度行按 bucketDay 分桶后逐桶聚合(比例按分子分母重算),桶 key 升序。 function bucketAggregates(rows, granularity) { const map = new Map(); rows.forEach((row) => { const bucket = bucketDay(row.stat_day, granularity); const list = map.get(bucket); if (list) { list.push(row); } else { map.set(bucket, [row]); } }); const buckets = [...map.keys()].sort(); return { aggregates: buckets.map((key) => aggregateMetricMaps(map.get(key))), buckets }; } function categoryAxis(buckets) { return { axisLabel: { color: CHART_TEXT }, axisLine: { lineStyle: { color: CHART_GRID_LINE } }, axisTick: { show: false }, data: buckets, type: "category" }; } // 留存热力单元格:值越高蓝底越深(上限 0.85),深度超过 0.35 换白字保证对比度;null 无底色显示 "--"。 function renderMetricCell(key, value) { if (!HEAT_KEYS.has(key)) { return {formatMetric(key, value)}; } if (isBlank(value)) { return --; } const depth = Math.min(0.85, Number(value) * 2); const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` }; if (depth > 0.35) { style.color = "#ffffff"; } return ( {formatRatioPercent(value)} ); } export function RetentionView() { const { derived, filters, isLoading } = useSocialBi(); const { appDaily, appTotals, regionDaily, regionScoped, regionTotals } = derived; const [trendApp, setTrendApp] = useState(ALL); const [tableDim, setTableDim] = useState("app"); const granularityLabel = GRANULARITY_LABELS[filters.granularity] || "日"; // 筛选变化后所选 App 可能不在结果里,回退到汇总(不直接改 state,渲染期兜底即可)。 const activeTrendApp = trendApp !== ALL && appTotals.some((row) => row.app_code === trendApp) ? trendApp : ALL; // 选了具体区域时,全视图(概要/图表/对比表)统一切到区域行口径,避免同屏两套数字。 const dailyRows = regionScoped ? regionDaily : appDaily; // 1) 概要卡:选了具体区域时 App 级合计改用区域行聚合,与顶栏语义一致。 const summary = useMemo(() => { const rows = regionScoped ? regionTotals : appTotals; return rows.length ? aggregateMetricMaps(rows) : null; }, [appTotals, regionScoped, regionTotals]); // 2) 新增(柱/左轴)× 次日留存率(线/右轴),按全局粒度分桶;null 让线断开。 const comboOption = useMemo(() => { const { aggregates, buckets } = bucketAggregates(dailyRows, filters.granularity); if (!buckets.length) { return null; } return { grid: { ...CHART_GRID }, legend: { ...CHART_LEGEND }, series: [ { barMaxWidth: 26, data: aggregates.map((row) => metricChartValue("new_users", row.new_users)), itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_ACCENT }, name: metricLabel("new_users"), tooltip: { valueFormatter: (value) => formatCount(value) }, type: "bar" }, { connectNulls: false, data: aggregates.map((row) => metricChartValue("d1_retention_rate", row.d1_retention_rate)), itemStyle: { color: CHART_ACCENT_2 }, lineStyle: { color: CHART_ACCENT_2, width: 2.5 }, name: metricLabel("d1_retention_rate"), symbol: "circle", symbolSize: 6, tooltip: { valueFormatter: (value) => formatPercentValue(value) }, type: "line", yAxisIndex: 1 } ], tooltip: { ...CHART_TOOLTIP, axisPointer: { type: "shadow" } }, xAxis: categoryAxis(buckets), yAxis: [ { axisLabel: { color: CHART_TEXT }, splitLine: { lineStyle: { color: CHART_GRID_LINE } }, type: "value" }, { axisLabel: { color: CHART_TEXT, formatter: "{value}%" }, splitLine: { show: false }, type: "value" } ] }; }, [dailyRows, filters.granularity]); // 3) D1/D7/D30 留存趋势:汇总或单 App 的日行分桶聚合(区域筛选时用区域日行)。 const trendOption = useMemo(() => { const rows = activeTrendApp === ALL ? dailyRows : dailyRows.filter((row) => row.app_code === activeTrendApp); const { aggregates, buckets } = bucketAggregates(rows, filters.granularity); if (!buckets.length) { return null; } return { grid: { ...CHART_GRID }, legend: { ...CHART_LEGEND }, series: RETENTION_SERIES.map(({ color, key }) => ({ connectNulls: false, data: aggregates.map((row) => metricChartValue(key, row[key])), itemStyle: { color }, lineStyle: { color, width: 2.5 }, name: metricLabel(key), symbol: "circle", symbolSize: 5, tooltip: { valueFormatter: (value) => formatPercentValue(value) }, type: "line" })), tooltip: { ...CHART_TOOLTIP }, xAxis: categoryAxis(buckets), yAxis: { axisLabel: { color: CHART_TEXT, formatter: "{value}%" }, splitLine: { lineStyle: { color: CHART_GRID_LINE } }, type: "value" } }; }, [activeTrendApp, dailyRows, filters.granularity]); // 4) 热力对比表:按 App / 按区域;区域筛选时"按 App"改为区域行按 App 聚合,保持口径一致。 const tableSource = useMemo(() => { if (tableDim === "region") { return regionTotals; } if (!regionScoped) { return appTotals; } const byApp = new Map(); regionTotals.forEach((row) => { const list = byApp.get(row.app_code) || []; list.push(row); byApp.set(row.app_code, list); }); return [...byApp.entries()].map(([appCode, rows]) => ({ app_code: appCode, app_name: rows[0].app_name, ...aggregateMetricMaps(rows) })); }, [appTotals, regionScoped, regionTotals, tableDim]); const tableRows = useMemo(() => { return tableSource .map((row) => ({ key: tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code, label: tableDim === "region" ? `${row.app_name}·${row.region_name}` : row.app_name, row })) .sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0)); }, [tableDim, tableSource]); const tableTotal = useMemo(() => { return tableSource.length ? aggregateMetricMaps(tableSource) : null; }, [tableSource]); const hasRows = appTotals.length > 0 || appDaily.length > 0 || regionTotals.length > 0; if (isLoading && !hasRows) { return (
{RETENTION_SERIES.map(({ key }) => (
))}
); } if (!hasRows) { return (
暂无留存数据 当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。
); } return (
{RETENTION_SERIES.map(({ key }) => { const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")]; return (
整体{metricLabel(key)} {formatRatioPercent(summary?.[key])} {regionScoped ? "所选区域汇总" : "全部所选 App 汇总"} {isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`}
); })}
新增与次日留存 柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶
{comboOption ? ( ) : (
暂无按日数据 当前筛选没有返回按日序列,无法绘制新增与留存趋势。
)}
留存趋势 D1 / D7 / D30 · 按{granularityLabel}分桶
{appTotals.map((row) => ( ))}
{trendOption ? ( ) : (
暂无留存趋势数据 {activeTrendApp === ALL ? "当前筛选没有返回按日序列。" : "该 App 在当前区间没有按日序列。"}
)}
留存对比 D1/D7/D30 底色越深留存越高
{tableRows.length ? (
{TABLE_METRIC_KEYS.map((key) => ( ))} {TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))} {tableRows.map((item) => ( {TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))} ))}
对象 {metricLabel(key)}
合计
{item.label}
) : (
暂无对比数据 {tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}
)}

留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。

); }