From 31804f5e343f4d1cc53fe405c1ea09a4531643dc Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 14:21:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- databi/src/DatabiApp.test.jsx | 217 +++++++++- databi/src/api.js | 9 +- databi/src/api.test.js | 3 + databi/src/social/activeRetention.js | 117 +++++ databi/src/social/useSocialBiData.js | 28 +- databi/src/social/views/RetentionView.jsx | 409 +++++++++++++----- databi/src/social/views/retention-view.css | 36 ++ .../FinanceWithdrawalApplicationList.jsx | 59 ++- .../FinanceWithdrawalApplicationList.test.jsx | 60 +-- finance/src/styles/index.css | 10 +- 10 files changed, 801 insertions(+), 147 deletions(-) create mode 100644 databi/src/social/activeRetention.js 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: () =>
+ EChart: ({ option }) => ( +
({ 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) => ( - + {isActiveMode ? "整体留存对比" : "新增留存对比"} + {isActiveMode ? "全体活跃用户 cohort · 按 App 对比并跟随顶部区域筛选" : "D1/D7/D30 底色越深留存越高"} + {!isActiveMode ? ( +
+
+ + +
-
+ ) : null}
{tableRows.length ? (
@@ -360,10 +552,10 @@ export function RetentionView() { 对象 - {TABLE_METRIC_KEYS.map((key) => ( - - - {metricLabel(key)} + {tableColumns.map((column) => ( + + + {column.label} ))} @@ -372,15 +564,15 @@ export function RetentionView() { 合计 - {TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))} + {tableColumns.map((column) => renderMetricCell(column, tableTotal?.[column.key]))} {tableRows.map((item) => ( - {tableDim === "region" ? · {item.row.region_name} : null} + {!isActiveMode && tableDim === "region" ? · {item.row.region_name} : null} - {TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))} + {tableColumns.map((column) => renderMetricCell(column, item.row[column.key]))} ))} @@ -389,14 +581,15 @@ export function RetentionView() { ) : (
暂无对比数据 - {tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"} + {!isActiveMode && tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}
)}

- 留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 = - 各观察日留存人数之和 / 各观察日 cohort 基数之和。cohort 日无注册或数据未接入时显示 "--"。 + {isActiveMode + ? "整体留存按 cohort 基准日计算:某日的 N 日留存 = 该日全体 canonical 活跃用户中,在 D+N 仍活跃的人数 / 基准日活跃用户数;未到观察日的窗口显示“--”,不计入合计。" + : "新增留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 = 各观察日留存人数之和 / 各观察日注册 cohort 基数之和。"}

); diff --git a/databi/src/social/views/retention-view.css b/databi/src/social/views/retention-view.css index 598261a..2b731d9 100644 --- a/databi/src/social/views/retention-view.css +++ b/databi/src/social/views/retention-view.css @@ -6,6 +6,38 @@ gap: 16px; } +/* ---------- 留存口径切换 ---------- */ + +.sbi-rt-modebar { + display: flex; + align-items: center; + gap: 9px; + min-height: 32px; +} + +.sbi-rt-mode-label { + color: var(--sbi-text-2); + font-size: 12.5px; + font-weight: 700; +} + +.sbi-rt-mode-seg > button { + min-width: 76px; + transition: background-color 120ms ease, box-shadow 120ms ease, color 120ms ease; +} + +.sbi-rt-warning { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 14px; + border: 1px solid #f3ddc0; + border-radius: 10px; + background: var(--sbi-warn-bg); + color: var(--sbi-warn-text); + font-size: 12.5px; +} + /* ---------- 概要卡 ---------- */ .sbi-rt-summary { @@ -96,6 +128,10 @@ } @media (max-width: 960px) { + .sbi-rt-modebar { + justify-content: flex-start; + } + .sbi-rt-summary { grid-template-columns: 1fr; } diff --git a/finance/src/components/FinanceWithdrawalApplicationList.jsx b/finance/src/components/FinanceWithdrawalApplicationList.jsx index ffb2037..68432d3 100644 --- a/finance/src/components/FinanceWithdrawalApplicationList.jsx +++ b/finance/src/components/FinanceWithdrawalApplicationList.jsx @@ -1,5 +1,7 @@ +import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; import MenuItem from "@mui/material/MenuItem"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -8,15 +10,27 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { formatAmount, formatTime, statusLabel } from "../format.js"; export function FinanceWithdrawalApplicationList({ applications, canAudit, error, filters, loading, onAudit, onFiltersChange, onReload, options }) { + const { showToast } = useToast(); const items = applications.items || []; const page = Number(applications.page || filters.page || 1); const pageSize = Number(applications.pageSize || 50); const total = Number(applications.total || 0); const hasNextPage = page * pageSize < total; + const copyWithdrawalAddress = async (address) => { + try { + // 始终复制接口返回的完整地址;即使表格布局后续调整,也不能复制缩写后的展示值。 + await copyText(address); + showToast("提现地址已复制", "success"); + } catch { + showToast("复制失败,请手动选择地址复制", "error"); + } + }; return (
@@ -54,6 +68,7 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error 申请时间 审批人 审批时间 + 审核原因 操作 @@ -73,15 +88,27 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error $ {formatAmount(item.withdrawAmount)} {item.withdrawMethod || "-"} - - {item.withdrawAddress || "-"} - + {item.withdrawAddress ? ( + + {item.withdrawAddress} + + copyWithdrawalAddress(item.withdrawAddress)} + > + + + + + ) : "-"} {formatTime(item.createdAtMs)} {formatTime(item.approvedAtMs)} + {item.auditRemark || "-"} {item.status === "pending" && canAudit ? ( @@ -100,7 +127,7 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error )) ) : ( - + 当前无数据 @@ -134,3 +161,27 @@ function Stacked({ primary, secondary }) { ); } + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // 财务后台可能运行在非安全上下文,Clipboard API 被拒绝时继续走兼容复制路径。 + } + } + + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + const copied = document.execCommand("copy"); + input.remove(); + if (!copied) { + throw new Error("copy failed"); + } +} diff --git a/finance/src/components/FinanceWithdrawalApplicationList.test.jsx b/finance/src/components/FinanceWithdrawalApplicationList.test.jsx index 27a8405..313e3e4 100644 --- a/finance/src/components/FinanceWithdrawalApplicationList.test.jsx +++ b/finance/src/components/FinanceWithdrawalApplicationList.test.jsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; +import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx"; const baseProps = { @@ -21,30 +22,37 @@ const baseProps = { options: { apps: [] } }; -test("renders withdrawal application table with requested finance columns", () => { - render( - +function renderList(props) { + return render( + + + ); +} + +test("renders withdrawal application table with requested finance columns", () => { + renderList({ + ...baseProps, + applications: { + ...baseProps.applications, + items: [ + { + appCode: "lalu", + approvedAtMs: 1760007200000, + approverName: "财务一", + approverUserId: 21, + auditRemark: "地址不符合要求", + createdAtMs: 1760000000000, + id: 7, + userId: "10001", + withdrawAddress: "TRX-address", + withdrawAmount: 12.5, + withdrawMethod: "USDT-TRC20" + } + ], + total: 1 + } + }); expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "用户ID" })).toBeInTheDocument(); @@ -54,6 +62,7 @@ test("renders withdrawal application table with requested finance columns", () = expect(screen.getByRole("columnheader", { name: "申请时间" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "审批时间" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "审核原因" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "操作" })).toBeInTheDocument(); expect(screen.getByText("lalu")).toBeInTheDocument(); expect(screen.getByText("10001")).toBeInTheDocument(); @@ -62,11 +71,12 @@ test("renders withdrawal application table with requested finance columns", () = expect(screen.getByText("TRX-address")).toBeInTheDocument(); expect(screen.getByText("财务一")).toBeInTheDocument(); expect(screen.getByText("21")).toBeInTheDocument(); + expect(screen.getByText("地址不符合要求")).toBeInTheDocument(); }); test("keeps empty withdrawal table colspan aligned with visible columns", () => { - const { container } = render(); + const { container } = renderList(baseProps); - expect(container.querySelector("tbody td")?.colSpan).toBe(9); + expect(container.querySelector("tbody td")?.colSpan).toBe(10); expect(screen.getByText("当前无数据")).toBeInTheDocument(); }); diff --git a/finance/src/styles/index.css b/finance/src/styles/index.css index 8536b0f..50e8e3b 100644 --- a/finance/src/styles/index.css +++ b/finance/src/styles/index.css @@ -462,11 +462,15 @@ a { min-width: 0; } +.finance-address-wrap { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + .finance-address { display: inline-block; - max-width: 240px; - overflow: hidden; - text-overflow: ellipsis; vertical-align: bottom; white-space: nowrap; }