diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx
index 969e48c..bd56eb0 100644
--- a/databi/src/DatabiApp.test.jsx
+++ b/databi/src/DatabiApp.test.jsx
@@ -33,7 +33,12 @@ vi.mock("./api.js", () => ({
}));
vi.mock("./charts/EChart.jsx", () => ({
- EChart: () =>
({ data, name })))}
+ data-testid="databi-chart"
+ />
+ )
}));
beforeEach(() => {
@@ -474,6 +479,216 @@ test("filters operation KPI by person and shows totals with App and region amoun
expect(screen.getAllByText("$30").length).toBeGreaterThan(0);
});
+test("defaults retention quality to canonical active cohort and can switch to new-user cohort", async () => {
+ window.history.pushState(null, "", "/databi/social/?view=retention&apps=lalu");
+ fetchSocialBiMaster.mockResolvedValue({
+ access: { all: true, scopes: [] },
+ apps: [{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }],
+ operators: [],
+ permissions: {},
+ regions: []
+ });
+ fetchSocialBiOverview.mockResolvedValue({
+ access: { all: true, scopes: [] },
+ apps: [
+ {
+ app_code: "lalu",
+ app_name: "Lalu",
+ country_breakdown: [],
+ daily_region_breakdown: [],
+ daily_series: [
+ {
+ active_users: 20,
+ d1_retention_base_users: 4,
+ d1_retention_rate: 0.25,
+ d1_retention_users: 1,
+ d30_retention_base_users: 2,
+ d30_retention_rate: 0.5,
+ d30_retention_users: 1,
+ d7_retention_base_users: 3,
+ d7_retention_rate: 0.3333,
+ d7_retention_users: 1,
+ new_users: 4,
+ stat_day: "2026-06-05"
+ }
+ ],
+ kind: "hyapp",
+ region_breakdown: [],
+ total: {
+ active_users: 20,
+ d1_retention_base_users: 4,
+ d1_retention_rate: 0.25,
+ d1_retention_users: 1,
+ d30_retention_base_users: 2,
+ d30_retention_rate: 0.5,
+ d30_retention_users: 1,
+ d7_retention_base_users: 3,
+ d7_retention_rate: 0.3333,
+ d7_retention_users: 1,
+ new_users: 4
+ }
+ }
+ ]
+ });
+ fetchSocialBiRequirements.mockResolvedValue({
+ access: { all: true, scopes: [] },
+ apps: [
+ {
+ app_code: "lalu",
+ app_name: "Lalu",
+ kind: "hyapp",
+ sections: [
+ {
+ daily_series: [
+ {
+ metrics: {
+ active_d1_active_base_users: 12,
+ active_d1_active_rate: 0.5,
+ active_d1_active_users: 6,
+ active_d30_active_base_users: 3,
+ active_d30_active_rate: 0.3333,
+ active_d30_active_users: 1,
+ active_d7_active_base_users: 6,
+ active_d7_active_rate: 0.5,
+ active_d7_active_users: 3
+ },
+ stat_day: "2026-06-04"
+ },
+ {
+ metrics: {
+ active_d1_active_base_users: 8,
+ active_d1_active_rate: 0.5,
+ active_d1_active_users: 4,
+ active_d30_active_base_users: 2,
+ active_d30_active_rate: 0,
+ active_d30_active_users: 0,
+ active_d7_active_base_users: 4,
+ active_d7_active_rate: 0.25,
+ active_d7_active_users: 1
+ },
+ stat_day: "2026-06-05"
+ },
+ {
+ metrics: {
+ active_d1_active_base_users: null,
+ active_d1_active_rate: null,
+ active_d1_active_users: null,
+ active_d30_active_base_users: null,
+ active_d30_active_rate: null,
+ active_d30_active_users: null,
+ active_d7_active_base_users: null,
+ active_d7_active_rate: null,
+ active_d7_active_users: null
+ },
+ stat_day: "2026-06-06"
+ }
+ ],
+ key: "retention",
+ total: {
+ metrics: {
+ active_d1_active_base_users: 20,
+ active_d1_active_rate: 0.5,
+ active_d1_active_users: 10,
+ active_d30_active_base_users: 5,
+ active_d30_active_rate: 0.2,
+ active_d30_active_users: 1,
+ active_d7_active_base_users: 10,
+ active_d7_active_rate: 0.4,
+ active_d7_active_users: 4
+ }
+ }
+ }
+ ]
+ }
+ ]
+ });
+
+ render(
);
+ await flushEffects();
+
+ expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
+ newUserType: "all",
+ payerType: "all",
+ section: "retention",
+ userRole: "all"
+ }));
+ // Overview 的 access 结果落地不能改变 requirements callback 身份,否则同一轮 D30 查询会重复执行。
+ expect(fetchSocialBiRequirements).toHaveBeenCalledTimes(1);
+ const modeSwitch = screen.getByRole("radiogroup", { name: "留存口径" });
+ expect(within(modeSwitch).getByRole("radio", { name: "整体次留" })).toHaveAttribute("aria-checked", "true");
+ expect(screen.getByText("活跃基数与次日留存")).toBeTruthy();
+ expect(screen.getByText("整体用户留存趋势")).toBeTruthy();
+ expect(screen.getByRole("columnheader", { name: "活跃用户基数" })).toBeTruthy();
+ expect(screen.getByRole("columnheader", { name: "次留人数" })).toBeTruthy();
+ expect(screen.getAllByText("50.0%").length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/活跃基数 20 人/).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/已接入 Social App 汇总/).length).toBeGreaterThan(0);
+ const overallCharts = screen.getAllByTestId("databi-chart");
+ const comboSeries = JSON.parse(overallCharts[0].dataset.series);
+ const trendSeries = JSON.parse(overallCharts[1].dataset.series);
+ expect(comboSeries.find((series) => series.name === "整体次日留存").data).toEqual([50, 50, null]);
+ expect(trendSeries.find((series) => series.name === "整体次日留存").data).toEqual([50, 50, null]);
+
+ await act(async () => {
+ within(modeSwitch).getByRole("radio", { name: "新增次留" }).click();
+ });
+
+ expect(within(modeSwitch).getByRole("radio", { name: "新增次留" })).toHaveAttribute("aria-checked", "true");
+ expect(screen.getByText("新增与次日留存")).toBeTruthy();
+ expect(screen.getByText("新增用户留存趋势")).toBeTruthy();
+ expect(screen.getByRole("columnheader", { name: "新增" })).toBeTruthy();
+ expect(screen.getAllByText("25.0%").length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/注册基数 4 人/).length).toBeGreaterThan(0);
+});
+
+test("marks overall retention as a partial subset when one app fails", async () => {
+ window.history.pushState(null, "", "/databi/social/?view=retention");
+ fetchSocialBiMaster.mockResolvedValue({
+ access: { all: true, scopes: [] },
+ apps: [
+ { app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
+ { app_code: "huwaa", app_name: "Huwaa", kind: "hyapp" }
+ ],
+ operators: [],
+ permissions: {},
+ regions: []
+ });
+ fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
+ fetchSocialBiRequirements.mockResolvedValue({
+ access: { all: true, scopes: [] },
+ apps: [
+ {
+ app_code: "lalu",
+ app_name: "Lalu",
+ kind: "hyapp",
+ sections: [{
+ daily_series: [{
+ active_d1_active_base_users: 10,
+ active_d1_active_rate: 0.5,
+ active_d1_active_users: 5,
+ stat_day: "2026-06-05"
+ }],
+ key: "retention",
+ total: {
+ active_d1_active_base_users: 10,
+ active_d1_active_rate: 0.5,
+ active_d1_active_users: 5
+ }
+ }]
+ },
+ { app_code: "huwaa", app_name: "Huwaa", error: "statistics timeout", kind: "hyapp" }
+ ]
+ });
+
+ render(
);
+ await flushEffects();
+
+ expect(screen.getByRole("alert")).toHaveTextContent("部分 App 整体留存加载失败");
+ expect(screen.getByRole("alert")).toHaveTextContent("Huwaa: statistics timeout");
+ expect(screen.getAllByText(/成功返回的 Social App 子集/).length).toBeGreaterThan(0);
+ expect(screen.queryByText(/已接入 Social App 汇总/)).toBeNull();
+});
+
test("renders social BI data requirements table with section filters and CSV export", async () => {
window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11");
fetchSocialBiMaster.mockResolvedValue({
diff --git a/databi/src/api.js b/databi/src/api.js
index d44717c..5337b84 100644
--- a/databi/src/api.js
+++ b/databi/src/api.js
@@ -182,7 +182,7 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, regionI
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
}
-export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType, payerType, regionId, regionIds, section, startMs, statTz, userRole }) {
+export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType, payerType, regionId, regionIds, section, signal, startMs, statTz, userRole }) {
const query = {};
if (statTz) {
query.stat_tz = statTz;
@@ -213,7 +213,7 @@ export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType,
if (newUserType) {
query.new_user_type = newUserType;
}
- return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
+ return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query, signal });
}
export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) {
@@ -259,12 +259,13 @@ export async function fetchSocialBiKpi({ appCodes, endMs, operatorUserId, period
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiKpi), { query });
}
-async function fetchDatabiData(path, { appCode, body, method, query } = {}) {
+async function fetchDatabiData(path, { appCode, body, method, query, signal } = {}) {
const headers = requestHeaders(appCode);
const init = {
credentials: "include",
headers,
- method: method || "GET"
+ method: method || "GET",
+ signal
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
diff --git a/databi/src/api.test.js b/databi/src/api.test.js
index 3f3fffb..2f73a1b 100644
--- a/databi/src/api.test.js
+++ b/databi/src/api.test.js
@@ -99,6 +99,7 @@ test("loads social BI apps and operation users from admin APIs", async () => {
test("loads social BI requirements with section role and payer filters", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ section: "revenue", total: { recharge_users: 2 } })));
+ const controller = new AbortController();
const data = await fetchSocialBiRequirements({
appCodes: ["lalu", "huwaa"],
@@ -106,6 +107,7 @@ test("loads social BI requirements with section role and payer filters", async (
payerType: "paid",
regionIds: [9, 11],
section: "revenue",
+ signal: controller.signal,
startMs: 10,
statTz: "Asia/Shanghai",
userRole: "host"
@@ -119,6 +121,7 @@ test("loads social BI requirements with section role and payer filters", async (
expect(requestURL.searchParams.get("payer_type")).toBe("paid");
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
expect(requestURL.searchParams.get("stat_tz")).toBe("Asia/Shanghai");
+ expect(fetch.mock.calls[0][1].signal).toBe(controller.signal);
expect(data.total.recharge_users).toBe(2);
});
diff --git a/databi/src/social/activeRetention.js b/databi/src/social/activeRetention.js
new file mode 100644
index 0000000..6e0f0c8
--- /dev/null
+++ b/databi/src/social/activeRetention.js
@@ -0,0 +1,117 @@
+// SocialRequirements 的 P2 留存是“基准日全体活跃用户 -> D+N 仍活跃”的 canonical cohort。
+// 这里仅负责把动态 metrics 行整理成留存质量页可消费的 App 合计/日行,并按分子分母安全聚合。
+
+import { isBlank } from "./format.js";
+
+export const ACTIVE_RETENTION_WINDOWS = [
+ {
+ baseKey: "active_d1_active_base_users",
+ key: "active_d1_active_rate",
+ label: "次日留存",
+ shortLabel: "D1",
+ usersKey: "active_d1_active_users"
+ },
+ {
+ baseKey: "active_d7_active_base_users",
+ key: "active_d7_active_rate",
+ label: "7日留存",
+ shortLabel: "D7",
+ usersKey: "active_d7_active_users"
+ },
+ {
+ baseKey: "active_d30_active_base_users",
+ key: "active_d30_active_rate",
+ label: "30日留存",
+ shortLabel: "D30",
+ usersKey: "active_d30_active_users"
+ }
+];
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function sectionKey(section) {
+ return String(section?.key ?? section?.section ?? section?.code ?? "").trim().toLowerCase();
+}
+
+function flattenMetricRow(row, context) {
+ if (!row || typeof row !== "object") {
+ return null;
+ }
+ const metrics = row.metrics && typeof row.metrics === "object" && !Array.isArray(row.metrics) ? row.metrics : {};
+ const flattened = { ...context, ...row, ...metrics };
+ delete flattened.metrics;
+ return flattened;
+}
+
+// requirements 可能同时返回多个 App;每个 App 的 retention section 已按顶栏区域筛选裁剪。
+// 不借用 Overview 的注册 cohort 或区域行,避免“整体次留”再次混入另一套口径。
+export function normalizeActiveRetentionRequirements(payload) {
+ const appErrors = [];
+ const appTotals = [];
+ const appDaily = [];
+
+ asArray(payload?.apps).forEach((app) => {
+ if (app?.error) {
+ appErrors.push(`${app.app_name || app.app_code || "未知 App"}: ${app.error}`);
+ }
+ const section = asArray(app?.sections).find((item) => sectionKey(item) === "retention");
+ if (!section) {
+ return;
+ }
+ const context = {
+ app_code: app.app_code || app.appCode || "",
+ app_name: app.app_name || app.appName || app.app_code || app.appCode || "",
+ kind: app.kind || "",
+ restricted: Boolean(app.restricted)
+ };
+ const dailyRows = asArray(section.daily_series || section.dailyRows || section.daily_rows || section.rows || section.items)
+ .map((row) => flattenMetricRow(row, context))
+ .filter(Boolean)
+ .map((row) => ({ ...row, stat_day: row.stat_day || row.label || "" }));
+ appDaily.push(...dailyRows);
+
+ const total = flattenMetricRow(section.total || section.totals || section.summary, context);
+ if (total) {
+ appTotals.push(total);
+ } else if (dailyRows.length) {
+ appTotals.push({ ...context, ...aggregateActiveRetentionRows(dailyRows) });
+ }
+ });
+
+ return { appDaily, appErrors, appTotals };
+}
+
+function numericValue(row, key) {
+ const value = row?.[key];
+ if (isBlank(value)) {
+ return null;
+ }
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric : null;
+}
+
+// 空值表示观察窗尚未成熟或数据未接入,必须保持 null;只有明确的数值 0 才参与求和。
+// 比例始终用汇总后的留存人数 / 基数重算,不能平均各 App、各日百分比。
+export function aggregateActiveRetentionRows(rows) {
+ const out = {};
+ ACTIVE_RETENTION_WINDOWS.forEach(({ baseKey, key, usersKey }) => {
+ let base = null;
+ let users = null;
+ rows.forEach((row) => {
+ const rowBase = numericValue(row, baseKey);
+ const rowUsers = numericValue(row, usersKey);
+ if (rowBase !== null) {
+ base = (base ?? 0) + rowBase;
+ }
+ if (rowUsers !== null) {
+ users = (users ?? 0) + rowUsers;
+ }
+ });
+ out[baseKey] = base;
+ out[usersKey] = users;
+ out[key] = users !== null && base ? users / base : null;
+ });
+ return out;
+}
diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js
index c188285..2213fbb 100644
--- a/databi/src/social/useSocialBiData.js
+++ b/databi/src/social/useSocialBiData.js
@@ -24,6 +24,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
const [refreshToken, setRefreshToken] = useState(0);
const requestSeq = useRef(0);
const requirementsSeq = useRef(0);
+ const requirementsAbort = useRef(null);
useEffect(() => {
let ignore = false;
@@ -48,6 +49,16 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
};
}, []);
+ useEffect(
+ () => () => {
+ // 页面离开后主动取消尚未完成的宽表查询,让取消信号沿 admin request context
+ // 传到 statistics-service/MySQL,避免不可见请求继续占用数据库连接。
+ requirementsSeq.current++;
+ requirementsAbort.current?.abort();
+ },
+ []
+ );
+
// range 只依赖日期相关筛选:切换粒度/区域/App 多选不应触发接口重拉(那些是纯前端派生)。
const { customEnd, customStart, preset } = filters;
const range = useMemo(
@@ -162,15 +173,21 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ);
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
+ // 筛选、分组或刷新变化时,旧请求的数据已不可能再被使用;seq 防止旧响应写回,
+ // AbortController 则真正终止旧 HTTP/DB 查询,二者分别解决正确性和资源占用。
+ requirementsAbort.current?.abort();
+ requirementsAbort.current = null;
setIsRequirementsLoading(true);
setRequirementsError("");
setRequirements(null);
if (!requestScopes.length) {
- const emptyResult = { access: overview?.access || null, apps: [] };
+ const emptyResult = { access: master?.access || null, apps: [] };
setRequirements(emptyResult);
setIsRequirementsLoading(false);
return Promise.resolve(emptyResult);
}
+ const controller = new AbortController();
+ requirementsAbort.current = controller;
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
...scope,
@@ -178,6 +195,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
newUserType,
payerType,
section,
+ signal: controller.signal,
startMs,
statTz: SOCIAL_BI_TZ,
userRole
@@ -191,6 +209,9 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
return data;
})
.catch((error) => {
+ if (error?.name === "AbortError") {
+ return null;
+ }
if (sequence === requirementsSeq.current) {
setRequirements(null);
setRequirementsError(error?.message || "数据需求加载失败");
@@ -198,12 +219,15 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
return null;
})
.finally(() => {
+ if (requirementsAbort.current === controller) {
+ requirementsAbort.current = null;
+ }
if (sequence === requirementsSeq.current) {
setIsRequirementsLoading(false);
}
});
},
- [appCodes, filters.regions, isMasterSettled, overview?.access, range]
+ [appCodes, filters.regions, isMasterSettled, master?.access, range]
);
const refresh = useCallback(() => {
diff --git a/databi/src/social/views/RetentionView.jsx b/databi/src/social/views/RetentionView.jsx
index b4c9bba..352df39 100644
--- a/databi/src/social/views/RetentionView.jsx
+++ b/databi/src/social/views/RetentionView.jsx
@@ -1,11 +1,12 @@
-// RetentionView(留存质量):整体 D1/D7/D30 概要卡、新增×次日留存组合图、
-// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。
+// RetentionView(留存质量):支持“全体活跃 cohort”与“新增注册 cohort”两套明确分离的留存口径。
+// 整体次留只消费 SocialRequirements retention;新增次留继续消费 Overview 的注册 cohort。
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { SocialAppIdentity, 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 { ACTIVE_RETENTION_WINDOWS, aggregateActiveRetentionRows, normalizeActiveRetentionRequirements } from "../activeRetention.js";
import { ALL, bucketDay } from "../state.js";
import "./retention-view.css";
@@ -17,27 +18,83 @@ 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_COLORS = [CHART_ACCENT, CHART_ACCENT_2, "#7c3aed"];
-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 RETENTION_MODES = [
+ { key: "active", label: "整体次留" },
+ { key: "new", label: "新增次留" }
];
-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 ACTIVE_RETENTION_SERIES = ACTIVE_RETENTION_WINDOWS.map((item, index) => ({
+ ...item,
+ color: RETENTION_COLORS[index]
+}));
+
+const NEW_RETENTION_SERIES = [
+ {
+ baseKey: "d1_retention_base_users",
+ color: RETENTION_COLORS[0],
+ key: "d1_retention_rate",
+ label: "次日留存",
+ shortLabel: "D1",
+ usersKey: "d1_retention_users"
+ },
+ {
+ baseKey: "d7_retention_base_users",
+ color: RETENTION_COLORS[1],
+ key: "d7_retention_rate",
+ label: "7日留存",
+ shortLabel: "D7",
+ usersKey: "d7_retention_users"
+ },
+ {
+ baseKey: "d30_retention_base_users",
+ color: RETENTION_COLORS[2],
+ key: "d30_retention_rate",
+ label: "30日留存",
+ shortLabel: "D30",
+ usersKey: "d30_retention_users"
+ }
];
+const ACTIVE_TABLE_COLUMNS = [
+ {
+ key: "active_d1_active_base_users",
+ label: "活跃用户基数",
+ tooltip: "基准日进入 App 的全体 canonical 活跃用户",
+ type: "count"
+ },
+ {
+ key: "active_d1_active_users",
+ label: "次留人数",
+ tooltip: "基准日活跃且次日继续活跃的 canonical 用户",
+ type: "count"
+ },
+ {
+ heat: true,
+ key: "active_d1_active_rate",
+ label: "整体次留",
+ tooltip: "次留人数 / 活跃用户基数",
+ type: "ratio"
+ },
+ { heat: true, key: "active_d7_active_rate", label: "整体7日留存", tooltip: "基准日活跃用户在 D+7 仍活跃的比例", type: "ratio" },
+ { heat: true, key: "active_d30_active_rate", label: "整体30日留存", tooltip: "基准日活跃用户在 D+30 仍活跃的比例", type: "ratio" }
+];
+
+const NEW_TABLE_COLUMNS = ["new_users", "active_users", "d1_retention_rate", "d7_retention_rate", "d30_retention_rate", "paid_conversion_rate"].map(
+ (key) => ({
+ heat: key === "d1_retention_rate" || key === "d7_retention_rate" || key === "d30_retention_rate",
+ key,
+ label: metricLabel(key),
+ tooltip: metricTooltip(key),
+ type: "metric"
+ })
+);
+
const GRANULARITY_LABELS = { day: "日", month: "月", week: "周" };
// 把日粒度行按 bucketDay 分桶后逐桶聚合(比例按分子分母重算),桶 key 升序。
-function bucketAggregates(rows, granularity) {
+function bucketAggregates(rows, granularity, aggregateRows) {
const map = new Map();
rows.forEach((row) => {
const bucket = bucketDay(row.stat_day, granularity);
@@ -49,7 +106,7 @@ function bucketAggregates(rows, granularity) {
}
});
const buckets = [...map.keys()].sort();
- return { aggregates: buckets.map((key) => aggregateMetricMaps(map.get(key))), buckets };
+ return { aggregates: buckets.map((key) => aggregateRows(map.get(key))), buckets };
}
function categoryAxis(buckets) {
@@ -62,13 +119,27 @@ function categoryAxis(buckets) {
};
}
+function chartRatio(value) {
+ return isBlank(value) ? null : Number(value) * 100;
+}
+
+function formatTableMetric(column, value) {
+ if (column.type === "ratio") {
+ return formatRatioPercent(value);
+ }
+ if (column.type === "count") {
+ return formatCount(value);
+ }
+ return formatMetric(column.key, value);
+}
+
// 留存热力单元格:值越高蓝底越深(上限 0.85),深度超过 0.35 换白字保证对比度;null 无底色显示 "--"。
-function renderMetricCell(key, value) {
- if (!HEAT_KEYS.has(key)) {
- return
{formatMetric(key, value)} | ;
+function renderMetricCell(column, value) {
+ if (!column.heat) {
+ return
{formatTableMetric(column, value)} | ;
}
if (isBlank(value)) {
- return
-- | ;
+ return
-- | ;
}
const depth = Math.min(0.85, Number(value) * 2);
const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` };
@@ -76,54 +147,125 @@ function renderMetricCell(key, value) {
style.color = "#ffffff";
}
return (
-
+ |
{formatRatioPercent(value)}
|
);
}
+function RetentionModeSwitch({ mode, onChange }) {
+ return (
+
+
留存口径
+
+ {RETENTION_MODES.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function summaryNote({ base, isActiveMode, partial, regionScoped, users }) {
+ const parts = [
+ partial
+ ? "成功返回的 Social App 子集"
+ : isActiveMode
+ ? regionScoped
+ ? "所选区域 Social App 汇总"
+ : "已接入 Social App 汇总"
+ : regionScoped
+ ? "所选区域汇总"
+ : "全部所选 App 汇总"
+ ];
+ if (!isBlank(base)) {
+ parts.push(`${isActiveMode ? "活跃" : "注册"}基数 ${formatCount(base)} 人`);
+ }
+ if (!isBlank(users)) {
+ parts.push(`留存 ${formatCount(users)} 人`);
+ }
+ return parts.join(" · ");
+}
+
export function RetentionView() {
- const { derived, filters, isLoading } = useSocialBi();
+ const {
+ derived,
+ filters,
+ isLoading,
+ isRequirementsLoading,
+ loadRequirements,
+ refreshToken,
+ requirements,
+ requirementsError
+ } = useSocialBi();
const { appDaily, appTotals, regionDaily, regionScoped, regionTotals } = derived;
+ const [retentionMode, setRetentionMode] = useState("active");
const [trendApp, setTrendApp] = useState(ALL);
const [tableDim, setTableDim] = useState("app");
+ const isActiveMode = retentionMode === "active";
+ // 整体次留是独立的 canonical 用户日查询,只在该口径可见时加载;切回新增次留不为不可见数据重复请求。
+ useEffect(() => {
+ if (!isActiveMode) {
+ return;
+ }
+ loadRequirements({ newUserType: "all", payerType: "all", section: "retention", userRole: "all" });
+ }, [isActiveMode, loadRequirements, refreshToken]);
+
+ const activeRetention = useMemo(() => normalizeActiveRetentionRequirements(requirements), [requirements]);
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;
+ const newRetentionDailyRows = regionScoped ? regionDaily : appDaily;
+ const modeAppTotals = isActiveMode ? activeRetention.appTotals : appTotals;
+ const dailyRows = isActiveMode ? activeRetention.appDaily : newRetentionDailyRows;
+ const retentionSeries = isActiveMode ? ACTIVE_RETENTION_SERIES : NEW_RETENTION_SERIES;
+
+ // 筛选或口径变化后所选 App 可能不在结果里,回退到汇总(不在渲染期直接改 state)。
+ const activeTrendApp = trendApp !== ALL && modeAppTotals.some((row) => row.app_code === trendApp) ? trendApp : ALL;
- // 1) 概要卡:选了具体区域时 App 级合计改用区域行聚合,与顶栏语义一致。
const summary = useMemo(() => {
- const rows = regionScoped ? regionTotals : appTotals;
- return rows.length ? aggregateMetricMaps(rows) : null;
- }, [appTotals, regionScoped, regionTotals]);
+ const rows = isActiveMode ? activeRetention.appTotals : regionScoped ? regionTotals : appTotals;
+ if (!rows.length) {
+ return null;
+ }
+ return isActiveMode ? aggregateActiveRetentionRows(rows) : aggregateMetricMaps(rows);
+ }, [activeRetention.appTotals, appTotals, isActiveMode, regionScoped, regionTotals]);
- // 2) 新增(柱/左轴)× 次日留存率(线/右轴),按全局粒度分桶;null 让线断开。
+ // 组合图在整体模式下是“活跃基数 + 全体用户次留”;新增模式保持“新增人数 + 注册 cohort 次留”。
const comboOption = useMemo(() => {
- const { aggregates, buckets } = bucketAggregates(dailyRows, filters.granularity);
+ const aggregateRows = isActiveMode ? aggregateActiveRetentionRows : aggregateMetricMaps;
+ const { aggregates, buckets } = bucketAggregates(dailyRows, filters.granularity, aggregateRows);
if (!buckets.length) {
return null;
}
+ const barKey = isActiveMode ? "active_d1_active_base_users" : "new_users";
+ const lineKey = isActiveMode ? "active_d1_active_rate" : "d1_retention_rate";
return {
grid: { ...CHART_GRID },
legend: { ...CHART_LEGEND },
series: [
{
barMaxWidth: 26,
- data: aggregates.map((row) => metricChartValue("new_users", row.new_users)),
+ data: aggregates.map((row) => metricChartValue(barKey, row[barKey])),
itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_ACCENT },
- name: metricLabel("new_users"),
+ name: isActiveMode ? "活跃用户基数" : metricLabel("new_users"),
tooltip: { valueFormatter: (value) => formatCount(value) },
type: "bar"
},
{
connectNulls: false,
- data: aggregates.map((row) => metricChartValue("d1_retention_rate", row.d1_retention_rate)),
+ data: aggregates.map((row) => chartRatio(row[lineKey])),
itemStyle: { color: CHART_ACCENT_2 },
lineStyle: { color: CHART_ACCENT_2, width: 2.5 },
- name: metricLabel("d1_retention_rate"),
+ name: isActiveMode ? "整体次日留存" : "新增次日留存",
symbol: "circle",
symbolSize: 6,
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
@@ -146,24 +288,25 @@ export function RetentionView() {
}
]
};
- }, [dailyRows, filters.granularity]);
+ }, [dailyRows, filters.granularity, isActiveMode]);
- // 3) D1/D7/D30 留存趋势:汇总或单 App 的日行分桶聚合(区域筛选时用区域日行)。
+ // D1/D7/D30 趋势始终跟随当前口径;周/月桶均先合并人数和基数,再重算比例。
const trendOption = useMemo(() => {
const rows = activeTrendApp === ALL ? dailyRows : dailyRows.filter((row) => row.app_code === activeTrendApp);
- const { aggregates, buckets } = bucketAggregates(rows, filters.granularity);
+ const aggregateRows = isActiveMode ? aggregateActiveRetentionRows : aggregateMetricMaps;
+ const { aggregates, buckets } = bucketAggregates(rows, filters.granularity, aggregateRows);
if (!buckets.length) {
return null;
}
return {
grid: { ...CHART_GRID },
legend: { ...CHART_LEGEND },
- series: RETENTION_SERIES.map(({ color, key }) => ({
+ series: retentionSeries.map(({ color, key, label }) => ({
connectNulls: false,
- data: aggregates.map((row) => metricChartValue(key, row[key])),
+ data: aggregates.map((row) => chartRatio(row[key])),
itemStyle: { color },
lineStyle: { color, width: 2.5 },
- name: metricLabel(key),
+ name: `${isActiveMode ? "整体" : "新增"}${label}`,
symbol: "circle",
symbolSize: 5,
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
@@ -177,10 +320,13 @@ export function RetentionView() {
type: "value"
}
};
- }, [activeTrendApp, dailyRows, filters.granularity]);
+ }, [activeTrendApp, dailyRows, filters.granularity, isActiveMode, retentionSeries]);
- // 4) 热力对比表:按 App / 按区域;区域筛选时"按 App"改为区域行按 App 聚合,保持口径一致。
+ // SocialRequirements 当前提供 App 合计并跟随顶栏区域筛选,不伪造不存在的区域拆分;新增模式保留原区域对比。
const tableSource = useMemo(() => {
+ if (isActiveMode) {
+ return activeRetention.appTotals;
+ }
if (tableDim === "region") {
return regionTotals;
}
@@ -198,28 +344,40 @@ export function RetentionView() {
app_name: rows[0].app_name,
...aggregateMetricMaps(rows)
}));
- }, [appTotals, regionScoped, regionTotals, tableDim]);
+ }, [activeRetention.appTotals, appTotals, isActiveMode, regionScoped, regionTotals, tableDim]);
+ const tableColumns = isActiveMode ? ACTIVE_TABLE_COLUMNS : NEW_TABLE_COLUMNS;
const tableRows = useMemo(() => {
+ const sortKey = isActiveMode ? "active_d1_active_base_users" : "new_users";
return tableSource
.map((row) => ({
- key: tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code,
+ key: !isActiveMode && tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code,
row
}))
- .sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0));
- }, [tableDim, tableSource]);
+ .sort((a, b) => (Number(b.row[sortKey]) || 0) - (Number(a.row[sortKey]) || 0));
+ }, [isActiveMode, tableDim, tableSource]);
const tableTotal = useMemo(() => {
- return tableSource.length ? aggregateMetricMaps(tableSource) : null;
- }, [tableSource]);
+ if (!tableSource.length) {
+ return null;
+ }
+ return isActiveMode ? aggregateActiveRetentionRows(tableSource) : aggregateMetricMaps(tableSource);
+ }, [isActiveMode, tableSource]);
- const hasRows = appTotals.length > 0 || appDaily.length > 0 || regionTotals.length > 0;
+ const hasRows = modeAppTotals.length > 0 || dailyRows.length > 0;
+ const partialErrors = isActiveMode ? activeRetention.appErrors : [];
+ // 首次进入时 master/Overview 仍在加载,requirements 请求尚未启动;此阶段也保持整页骨架,
+ // 避免在请求链衔接的一个渲染周期内错误闪出“暂无整体留存数据”。
+ const modeLoading = isActiveMode ? isRequirementsLoading || (isLoading && !requirements) : isLoading;
+ const modeError = isActiveMode ? requirementsError || (!hasRows ? partialErrors.join(";") : "") : "";
+ const modeSwitch =
;
- if (isLoading && !hasRows) {
+ if (modeLoading && !hasRows) {
return (
+ {modeSwitch}
- {RETENTION_SERIES.map(({ key }) => (
+ {retentionSeries.map(({ key }) => (
@@ -239,12 +397,27 @@ export function RetentionView() {
);
}
+ if (modeError && !hasRows) {
+ return (
+
+ {modeSwitch}
+
+
+ {modeError}
+ 可切换到“新增次留”查看 Overview 注册 cohort 数据。
+
+
+
+ );
+ }
+
if (!hasRows) {
return (
+ {modeSwitch}
- 暂无留存数据
+ 暂无{isActiveMode ? "整体" : "新增"}留存数据
当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。
@@ -254,42 +427,59 @@ export function RetentionView() {
return (
+ {modeSwitch}
+
+ {partialErrors.length ? (
+
+ 部分 App 整体留存加载失败,以下仅展示成功返回的 App,不作为完整汇总。
+ {partialErrors.map((message) => (
+ {message}
+ ))}
+
+ ) : null}
+
- {RETENTION_SERIES.map(({ key }) => {
- const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
- return (
-
-
- 整体{metricLabel(key)}
-
- {formatRatioPercent(summary?.[key])}
-
- {regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
- {isBlank(baseUsers) ? "" : ` · 回看基数 ${formatCount(baseUsers)} 人`}
-
-
- );
- })}
+ {retentionSeries.map(({ baseKey, key, label, usersKey }) => (
+
+
+ {isActiveMode ? "整体" : "新增"}{label}
+
+ {formatRatioPercent(summary?.[key])}
+
+ {summaryNote({
+ base: summary?.[baseKey],
+ isActiveMode,
+ partial: partialErrors.length > 0,
+ regionScoped,
+ users: summary?.[usersKey]
+ })}
+
+
+ ))}
- 新增与次日留存
- 柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按{granularityLabel}分桶
+ {isActiveMode ? "活跃基数与次日留存" : "新增与次日留存"}
+
+ {isActiveMode
+ ? `柱=基准日全体活跃用户(左轴)· 线=该批用户次日继续活跃率(右轴)· 按${granularityLabel}分桶`
+ : `柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按${granularityLabel}分桶`}
+
{comboOption ? (
) : (
暂无按日数据
- 当前筛选没有返回按日序列,无法绘制新增与留存趋势。
+ 当前筛选没有返回按日序列,无法绘制留存趋势。
)}
-
留存趋势
+
{isActiveMode ? "整体用户留存趋势" : "新增用户留存趋势"}
D1 / D7 / D30 · 按{granularityLabel}分桶
@@ -302,7 +492,7 @@ export function RetentionView() {
>
汇总
- {appTotals.map((row) => (
+ {modeAppTotals.map((row) => (