细节
This commit is contained in:
parent
00b6af42dd
commit
31804f5e34
@ -33,7 +33,12 @@ vi.mock("./api.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./charts/EChart.jsx", () => ({
|
||||
EChart: () => <div data-testid="databi-chart" />
|
||||
EChart: ({ option }) => (
|
||||
<div
|
||||
data-series={JSON.stringify((option?.series || []).map(({ data, name }) => ({ 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(<DatabiApp />);
|
||||
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(<DatabiApp />);
|
||||
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({
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
117
databi/src/social/activeRetention.js
Normal file
117
databi/src/social/activeRetention.js
Normal file
@ -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;
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 <td key={key}>{formatMetric(key, value)}</td>;
|
||||
function renderMetricCell(column, value) {
|
||||
if (!column.heat) {
|
||||
return <td key={column.key}>{formatTableMetric(column, value)}</td>;
|
||||
}
|
||||
if (isBlank(value)) {
|
||||
return <td key={key}>--</td>;
|
||||
return <td key={column.key}>--</td>;
|
||||
}
|
||||
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 (
|
||||
<td key={key} style={style}>
|
||||
<td key={column.key} style={style}>
|
||||
{formatRatioPercent(value)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionModeSwitch({ mode, onChange }) {
|
||||
return (
|
||||
<div className="sbi-rt-modebar">
|
||||
<span className="sbi-rt-mode-label">留存口径</span>
|
||||
<div className="sbi-seg sbi-rt-mode-seg" role="radiogroup" aria-label="留存口径">
|
||||
{RETENTION_MODES.map((item) => (
|
||||
<button
|
||||
aria-checked={mode === item.key}
|
||||
className={mode === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => onChange(item.key)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = <RetentionModeSwitch mode={retentionMode} onChange={setRetentionMode} />;
|
||||
|
||||
if (isLoading && !hasRows) {
|
||||
if (modeLoading && !hasRows) {
|
||||
return (
|
||||
<div className="sbi-rt-view">
|
||||
{modeSwitch}
|
||||
<div className="sbi-rt-summary">
|
||||
{RETENTION_SERIES.map(({ key }) => (
|
||||
{retentionSeries.map(({ key }) => (
|
||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||||
<span className="sbi-skeleton sbi-rt-skeleton-line" />
|
||||
<span className="sbi-skeleton sbi-rt-skeleton-value" />
|
||||
@ -239,12 +397,27 @@ export function RetentionView() {
|
||||
);
|
||||
}
|
||||
|
||||
if (modeError && !hasRows) {
|
||||
return (
|
||||
<div className="sbi-rt-view">
|
||||
{modeSwitch}
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-empty" role="alert">
|
||||
<strong>{modeError}</strong>
|
||||
<span>可切换到“新增次留”查看 Overview 注册 cohort 数据。</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasRows) {
|
||||
return (
|
||||
<div className="sbi-rt-view">
|
||||
{modeSwitch}
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无留存数据</strong>
|
||||
<strong>暂无{isActiveMode ? "整体" : "新增"}留存数据</strong>
|
||||
<span>当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。</span>
|
||||
</div>
|
||||
</section>
|
||||
@ -254,42 +427,59 @@ export function RetentionView() {
|
||||
|
||||
return (
|
||||
<div className="sbi-rt-view">
|
||||
{modeSwitch}
|
||||
|
||||
{partialErrors.length ? (
|
||||
<div className="sbi-rt-warning" role="alert">
|
||||
<strong>部分 App 整体留存加载失败,以下仅展示成功返回的 App,不作为完整汇总。</strong>
|
||||
{partialErrors.map((message) => (
|
||||
<span key={message}>{message}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="sbi-rt-summary">
|
||||
{RETENTION_SERIES.map(({ key }) => {
|
||||
const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
|
||||
return (
|
||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||||
<span className="sbi-rt-summary-label sbi-metric-hint" title={metricTooltip(key)}>
|
||||
整体{metricLabel(key)}
|
||||
</span>
|
||||
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
||||
<span className="sbi-rt-summary-note">
|
||||
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
|
||||
{isBlank(baseUsers) ? "" : ` · 回看基数 ${formatCount(baseUsers)} 人`}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{retentionSeries.map(({ baseKey, key, label, usersKey }) => (
|
||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||||
<span className="sbi-rt-summary-label sbi-metric-hint" title={isActiveMode ? `${label}:基准日全体活跃用户在对应观察日仍活跃的比例` : metricTooltip(key)}>
|
||||
{isActiveMode ? "整体" : "新增"}{label}
|
||||
</span>
|
||||
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
||||
<span className="sbi-rt-summary-note">
|
||||
{summaryNote({
|
||||
base: summary?.[baseKey],
|
||||
isActiveMode,
|
||||
partial: partialErrors.length > 0,
|
||||
regionScoped,
|
||||
users: summary?.[usersKey]
|
||||
})}
|
||||
</span>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>新增与次日留存</strong>
|
||||
<small>柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按{granularityLabel}分桶</small>
|
||||
<strong>{isActiveMode ? "活跃基数与次日留存" : "新增与次日留存"}</strong>
|
||||
<small>
|
||||
{isActiveMode
|
||||
? `柱=基准日全体活跃用户(左轴)· 线=该批用户次日继续活跃率(右轴)· 按${granularityLabel}分桶`
|
||||
: `柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按${granularityLabel}分桶`}
|
||||
</small>
|
||||
</div>
|
||||
{comboOption ? (
|
||||
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无按日数据</strong>
|
||||
<span>当前筛选没有返回按日序列,无法绘制新增与留存趋势。</span>
|
||||
<span>当前筛选没有返回按日序列,无法绘制留存趋势。</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>留存趋势</strong>
|
||||
<strong>{isActiveMode ? "整体用户留存趋势" : "新增用户留存趋势"}</strong>
|
||||
<small>D1 / D7 / D30 · 按{granularityLabel}分桶</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="留存趋势维度">
|
||||
@ -302,7 +492,7 @@ export function RetentionView() {
|
||||
>
|
||||
汇总
|
||||
</button>
|
||||
{appTotals.map((row) => (
|
||||
{modeAppTotals.map((row) => (
|
||||
<button
|
||||
aria-checked={activeTrendApp === row.app_code}
|
||||
className={activeTrendApp === row.app_code ? "is-active" : ""}
|
||||
@ -329,30 +519,32 @@ export function RetentionView() {
|
||||
|
||||
<section className="sbi-card sbi-rt-table-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>留存对比</strong>
|
||||
<small>D1/D7/D30 底色越深留存越高</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
|
||||
<button
|
||||
aria-checked={tableDim === "app"}
|
||||
className={tableDim === "app" ? "is-active" : ""}
|
||||
onClick={() => setTableDim("app")}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
按 App
|
||||
</button>
|
||||
<button
|
||||
aria-checked={tableDim === "region"}
|
||||
className={tableDim === "region" ? "is-active" : ""}
|
||||
onClick={() => setTableDim("region")}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
<strong>{isActiveMode ? "整体留存对比" : "新增留存对比"}</strong>
|
||||
<small>{isActiveMode ? "全体活跃用户 cohort · 按 App 对比并跟随顶部区域筛选" : "D1/D7/D30 底色越深留存越高"}</small>
|
||||
{!isActiveMode ? (
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
|
||||
<button
|
||||
aria-checked={tableDim === "app"}
|
||||
className={tableDim === "app" ? "is-active" : ""}
|
||||
onClick={() => setTableDim("app")}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
按 App
|
||||
</button>
|
||||
<button
|
||||
aria-checked={tableDim === "region"}
|
||||
className={tableDim === "region" ? "is-active" : ""}
|
||||
onClick={() => setTableDim("region")}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{tableRows.length ? (
|
||||
<div className="sbi-table-scroll">
|
||||
@ -360,10 +552,10 @@ export function RetentionView() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">对象</th>
|
||||
{TABLE_METRIC_KEYS.map((key) => (
|
||||
<th key={key}>
|
||||
<span className="sbi-metric-hint" title={metricTooltip(key)}>
|
||||
{metricLabel(key)}
|
||||
{tableColumns.map((column) => (
|
||||
<th key={column.key}>
|
||||
<span className="sbi-metric-hint" title={column.tooltip}>
|
||||
{column.label}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
@ -372,15 +564,15 @@ export function RetentionView() {
|
||||
<tbody>
|
||||
<tr className="is-total">
|
||||
<td className="is-left">合计</td>
|
||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))}
|
||||
{tableColumns.map((column) => renderMetricCell(column, tableTotal?.[column.key]))}
|
||||
</tr>
|
||||
{tableRows.map((item) => (
|
||||
<tr key={item.key}>
|
||||
<td className="is-left">
|
||||
<SocialAppIdentity appCode={item.row.app_code} appName={item.row.app_name} />
|
||||
{tableDim === "region" ? <span> · {item.row.region_name}</span> : null}
|
||||
{!isActiveMode && tableDim === "region" ? <span> · {item.row.region_name}</span> : null}
|
||||
</td>
|
||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
|
||||
{tableColumns.map((column) => renderMetricCell(column, item.row[column.key]))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -389,14 +581,15 @@ export function RetentionView() {
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无对比数据</strong>
|
||||
<span>{tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
|
||||
<span>{!isActiveMode && tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="sbi-rt-footnote">
|
||||
留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 =
|
||||
各观察日留存人数之和 / 各观察日 cohort 基数之和。cohort 日无注册或数据未接入时显示 "--"。
|
||||
{isActiveMode
|
||||
? "整体留存按 cohort 基准日计算:某日的 N 日留存 = 该日全体 canonical 活跃用户中,在 D+N 仍活跃的人数 / 基准日活跃用户数;未到观察日的窗口显示“--”,不计入合计。"
|
||||
: "新增留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 = 各观察日留存人数之和 / 各观察日注册 cohort 基数之和。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<section className="finance-panel finance-list">
|
||||
@ -54,6 +68,7 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
|
||||
<TableCell className="finance-time-cell">申请时间</TableCell>
|
||||
<TableCell>审批人</TableCell>
|
||||
<TableCell className="finance-time-cell">审批时间</TableCell>
|
||||
<TableCell>审核原因</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@ -73,15 +88,27 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
|
||||
<TableCell align="right">$ {formatAmount(item.withdrawAmount)}</TableCell>
|
||||
<TableCell>{item.withdrawMethod || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<span className="finance-address" title={item.withdrawAddress}>
|
||||
{item.withdrawAddress || "-"}
|
||||
</span>
|
||||
{item.withdrawAddress ? (
|
||||
<span className="finance-address-wrap">
|
||||
<span className="finance-address" title={item.withdrawAddress}>{item.withdrawAddress}</span>
|
||||
<Tooltip title="复制提现地址">
|
||||
<IconButton
|
||||
aria-label="复制提现地址"
|
||||
size="small"
|
||||
onClick={() => copyWithdrawalAddress(item.withdrawAddress)}
|
||||
>
|
||||
<ContentCopyOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
||||
<TableCell>
|
||||
<Stacked primary={item.approverName || "-"} secondary={item.approverUserId || ""} />
|
||||
</TableCell>
|
||||
<TableCell className="finance-time-cell">{formatTime(item.approvedAtMs)}</TableCell>
|
||||
<TableCell>{item.auditRemark || "-"}</TableCell>
|
||||
<TableCell align="right">
|
||||
{item.status === "pending" && canAudit ? (
|
||||
<span className="finance-row-actions">
|
||||
@ -100,7 +127,7 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell align="center" colSpan={9}>
|
||||
<TableCell align="center" colSpan={10}>
|
||||
当前无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -134,3 +161,27 @@ function Stacked({ primary, secondary }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<FinanceWithdrawalApplicationList
|
||||
{...baseProps}
|
||||
applications={{
|
||||
...baseProps.applications,
|
||||
items: [
|
||||
{
|
||||
appCode: "lalu",
|
||||
approvedAtMs: 1760007200000,
|
||||
approverName: "财务一",
|
||||
approverUserId: 21,
|
||||
createdAtMs: 1760000000000,
|
||||
id: 7,
|
||||
userId: "10001",
|
||||
withdrawAddress: "TRX-address",
|
||||
withdrawAmount: 12.5,
|
||||
withdrawMethod: "USDT-TRC20"
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
}}
|
||||
/>
|
||||
function renderList(props) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<FinanceWithdrawalApplicationList {...props} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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(<FinanceWithdrawalApplicationList {...baseProps} />);
|
||||
const { container } = renderList(baseProps);
|
||||
|
||||
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
|
||||
expect(container.querySelector("tbody td")?.colSpan).toBe(10);
|
||||
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user