细节
This commit is contained in:
parent
00b6af42dd
commit
31804f5e34
@ -33,7 +33,12 @@ vi.mock("./api.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./charts/EChart.jsx", () => ({
|
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(() => {
|
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);
|
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 () => {
|
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");
|
window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11");
|
||||||
fetchSocialBiMaster.mockResolvedValue({
|
fetchSocialBiMaster.mockResolvedValue({
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, regionI
|
|||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
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 = {};
|
const query = {};
|
||||||
if (statTz) {
|
if (statTz) {
|
||||||
query.stat_tz = statTz;
|
query.stat_tz = statTz;
|
||||||
@ -213,7 +213,7 @@ export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType,
|
|||||||
if (newUserType) {
|
if (newUserType) {
|
||||||
query.new_user_type = 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 }) {
|
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 });
|
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 headers = requestHeaders(appCode);
|
||||||
const init = {
|
const init = {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers,
|
headers,
|
||||||
method: method || "GET"
|
method: method || "GET",
|
||||||
|
signal
|
||||||
};
|
};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
headers["Content-Type"] = "application/json";
|
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 () => {
|
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 } })));
|
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ section: "revenue", total: { recharge_users: 2 } })));
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
const data = await fetchSocialBiRequirements({
|
const data = await fetchSocialBiRequirements({
|
||||||
appCodes: ["lalu", "huwaa"],
|
appCodes: ["lalu", "huwaa"],
|
||||||
@ -106,6 +107,7 @@ test("loads social BI requirements with section role and payer filters", async (
|
|||||||
payerType: "paid",
|
payerType: "paid",
|
||||||
regionIds: [9, 11],
|
regionIds: [9, 11],
|
||||||
section: "revenue",
|
section: "revenue",
|
||||||
|
signal: controller.signal,
|
||||||
startMs: 10,
|
startMs: 10,
|
||||||
statTz: "Asia/Shanghai",
|
statTz: "Asia/Shanghai",
|
||||||
userRole: "host"
|
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("payer_type")).toBe("paid");
|
||||||
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
|
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
|
||||||
expect(requestURL.searchParams.get("stat_tz")).toBe("Asia/Shanghai");
|
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);
|
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 [refreshToken, setRefreshToken] = useState(0);
|
||||||
const requestSeq = useRef(0);
|
const requestSeq = useRef(0);
|
||||||
const requirementsSeq = useRef(0);
|
const requirementsSeq = useRef(0);
|
||||||
|
const requirementsAbort = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
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 多选不应触发接口重拉(那些是纯前端派生)。
|
// range 只依赖日期相关筛选:切换粒度/区域/App 多选不应触发接口重拉(那些是纯前端派生)。
|
||||||
const { customEnd, customStart, preset } = filters;
|
const { customEnd, customStart, preset } = filters;
|
||||||
const range = useMemo(
|
const range = useMemo(
|
||||||
@ -162,15 +173,21 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
|||||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||||
const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ);
|
const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ);
|
||||||
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
|
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
|
||||||
|
// 筛选、分组或刷新变化时,旧请求的数据已不可能再被使用;seq 防止旧响应写回,
|
||||||
|
// AbortController 则真正终止旧 HTTP/DB 查询,二者分别解决正确性和资源占用。
|
||||||
|
requirementsAbort.current?.abort();
|
||||||
|
requirementsAbort.current = null;
|
||||||
setIsRequirementsLoading(true);
|
setIsRequirementsLoading(true);
|
||||||
setRequirementsError("");
|
setRequirementsError("");
|
||||||
setRequirements(null);
|
setRequirements(null);
|
||||||
if (!requestScopes.length) {
|
if (!requestScopes.length) {
|
||||||
const emptyResult = { access: overview?.access || null, apps: [] };
|
const emptyResult = { access: master?.access || null, apps: [] };
|
||||||
setRequirements(emptyResult);
|
setRequirements(emptyResult);
|
||||||
setIsRequirementsLoading(false);
|
setIsRequirementsLoading(false);
|
||||||
return Promise.resolve(emptyResult);
|
return Promise.resolve(emptyResult);
|
||||||
}
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
requirementsAbort.current = controller;
|
||||||
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
|
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
|
||||||
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
|
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
|
||||||
...scope,
|
...scope,
|
||||||
@ -178,6 +195,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
|||||||
newUserType,
|
newUserType,
|
||||||
payerType,
|
payerType,
|
||||||
section,
|
section,
|
||||||
|
signal: controller.signal,
|
||||||
startMs,
|
startMs,
|
||||||
statTz: SOCIAL_BI_TZ,
|
statTz: SOCIAL_BI_TZ,
|
||||||
userRole
|
userRole
|
||||||
@ -191,6 +209,9 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
|||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (sequence === requirementsSeq.current) {
|
if (sequence === requirementsSeq.current) {
|
||||||
setRequirements(null);
|
setRequirements(null);
|
||||||
setRequirementsError(error?.message || "数据需求加载失败");
|
setRequirementsError(error?.message || "数据需求加载失败");
|
||||||
@ -198,12 +219,15 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (requirementsAbort.current === controller) {
|
||||||
|
requirementsAbort.current = null;
|
||||||
|
}
|
||||||
if (sequence === requirementsSeq.current) {
|
if (sequence === requirementsSeq.current) {
|
||||||
setIsRequirementsLoading(false);
|
setIsRequirementsLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[appCodes, filters.regions, isMasterSettled, overview?.access, range]
|
[appCodes, filters.regions, isMasterSettled, master?.access, range]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
// RetentionView(留存质量):整体 D1/D7/D30 概要卡、新增×次日留存组合图、
|
// RetentionView(留存质量):支持“全体活跃 cohort”与“新增注册 cohort”两套明确分离的留存口径。
|
||||||
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。
|
// 整体次留只消费 SocialRequirements retention;新增次留继续消费 Overview 的注册 cohort。
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
|
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
|
||||||
import { EChart } from "../../charts/EChart.jsx";
|
import { EChart } from "../../charts/EChart.jsx";
|
||||||
import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
||||||
import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.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 { ALL, bucketDay } from "../state.js";
|
||||||
import "./retention-view.css";
|
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_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_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 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 = [
|
const RETENTION_MODES = [
|
||||||
{ color: CHART_ACCENT, key: "d1_retention_rate" },
|
{ key: "active", label: "整体次留" },
|
||||||
{ color: CHART_ACCENT_2, key: "d7_retention_rate" },
|
{ key: "new", label: "新增次留" }
|
||||||
{ color: "#7c3aed", key: "d30_retention_rate" }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const HEAT_KEYS = new Set(["d1_retention_rate", "d7_retention_rate", "d30_retention_rate"]);
|
const ACTIVE_RETENTION_SERIES = ACTIVE_RETENTION_WINDOWS.map((item, index) => ({
|
||||||
const TABLE_METRIC_KEYS = [
|
...item,
|
||||||
"new_users",
|
color: RETENTION_COLORS[index]
|
||||||
"active_users",
|
}));
|
||||||
"d1_retention_rate",
|
|
||||||
"d7_retention_rate",
|
const NEW_RETENTION_SERIES = [
|
||||||
"d30_retention_rate",
|
{
|
||||||
"paid_conversion_rate"
|
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: "周" };
|
const GRANULARITY_LABELS = { day: "日", month: "月", week: "周" };
|
||||||
|
|
||||||
// 把日粒度行按 bucketDay 分桶后逐桶聚合(比例按分子分母重算),桶 key 升序。
|
// 把日粒度行按 bucketDay 分桶后逐桶聚合(比例按分子分母重算),桶 key 升序。
|
||||||
function bucketAggregates(rows, granularity) {
|
function bucketAggregates(rows, granularity, aggregateRows) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const bucket = bucketDay(row.stat_day, granularity);
|
const bucket = bucketDay(row.stat_day, granularity);
|
||||||
@ -49,7 +106,7 @@ function bucketAggregates(rows, granularity) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const buckets = [...map.keys()].sort();
|
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) {
|
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 无底色显示 "--"。
|
// 留存热力单元格:值越高蓝底越深(上限 0.85),深度超过 0.35 换白字保证对比度;null 无底色显示 "--"。
|
||||||
function renderMetricCell(key, value) {
|
function renderMetricCell(column, value) {
|
||||||
if (!HEAT_KEYS.has(key)) {
|
if (!column.heat) {
|
||||||
return <td key={key}>{formatMetric(key, value)}</td>;
|
return <td key={column.key}>{formatTableMetric(column, value)}</td>;
|
||||||
}
|
}
|
||||||
if (isBlank(value)) {
|
if (isBlank(value)) {
|
||||||
return <td key={key}>--</td>;
|
return <td key={column.key}>--</td>;
|
||||||
}
|
}
|
||||||
const depth = Math.min(0.85, Number(value) * 2);
|
const depth = Math.min(0.85, Number(value) * 2);
|
||||||
const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` };
|
const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` };
|
||||||
@ -76,54 +147,125 @@ function renderMetricCell(key, value) {
|
|||||||
style.color = "#ffffff";
|
style.color = "#ffffff";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<td key={key} style={style}>
|
<td key={column.key} style={style}>
|
||||||
{formatRatioPercent(value)}
|
{formatRatioPercent(value)}
|
||||||
</td>
|
</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() {
|
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 { appDaily, appTotals, regionDaily, regionScoped, regionTotals } = derived;
|
||||||
|
const [retentionMode, setRetentionMode] = useState("active");
|
||||||
const [trendApp, setTrendApp] = useState(ALL);
|
const [trendApp, setTrendApp] = useState(ALL);
|
||||||
const [tableDim, setTableDim] = useState("app");
|
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] || "日";
|
const granularityLabel = GRANULARITY_LABELS[filters.granularity] || "日";
|
||||||
// 筛选变化后所选 App 可能不在结果里,回退到汇总(不直接改 state,渲染期兜底即可)。
|
const newRetentionDailyRows = regionScoped ? regionDaily : appDaily;
|
||||||
const activeTrendApp = trendApp !== ALL && appTotals.some((row) => row.app_code === trendApp) ? trendApp : ALL;
|
const modeAppTotals = isActiveMode ? activeRetention.appTotals : appTotals;
|
||||||
// 选了具体区域时,全视图(概要/图表/对比表)统一切到区域行口径,避免同屏两套数字。
|
const dailyRows = isActiveMode ? activeRetention.appDaily : newRetentionDailyRows;
|
||||||
const dailyRows = regionScoped ? regionDaily : appDaily;
|
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 summary = useMemo(() => {
|
||||||
const rows = regionScoped ? regionTotals : appTotals;
|
const rows = isActiveMode ? activeRetention.appTotals : regionScoped ? regionTotals : appTotals;
|
||||||
return rows.length ? aggregateMetricMaps(rows) : null;
|
if (!rows.length) {
|
||||||
}, [appTotals, regionScoped, regionTotals]);
|
return null;
|
||||||
|
}
|
||||||
|
return isActiveMode ? aggregateActiveRetentionRows(rows) : aggregateMetricMaps(rows);
|
||||||
|
}, [activeRetention.appTotals, appTotals, isActiveMode, regionScoped, regionTotals]);
|
||||||
|
|
||||||
// 2) 新增(柱/左轴)× 次日留存率(线/右轴),按全局粒度分桶;null 让线断开。
|
// 组合图在整体模式下是“活跃基数 + 全体用户次留”;新增模式保持“新增人数 + 注册 cohort 次留”。
|
||||||
const comboOption = useMemo(() => {
|
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) {
|
if (!buckets.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const barKey = isActiveMode ? "active_d1_active_base_users" : "new_users";
|
||||||
|
const lineKey = isActiveMode ? "active_d1_active_rate" : "d1_retention_rate";
|
||||||
return {
|
return {
|
||||||
grid: { ...CHART_GRID },
|
grid: { ...CHART_GRID },
|
||||||
legend: { ...CHART_LEGEND },
|
legend: { ...CHART_LEGEND },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
barMaxWidth: 26,
|
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 },
|
itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_ACCENT },
|
||||||
name: metricLabel("new_users"),
|
name: isActiveMode ? "活跃用户基数" : metricLabel("new_users"),
|
||||||
tooltip: { valueFormatter: (value) => formatCount(value) },
|
tooltip: { valueFormatter: (value) => formatCount(value) },
|
||||||
type: "bar"
|
type: "bar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectNulls: false,
|
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 },
|
itemStyle: { color: CHART_ACCENT_2 },
|
||||||
lineStyle: { color: CHART_ACCENT_2, width: 2.5 },
|
lineStyle: { color: CHART_ACCENT_2, width: 2.5 },
|
||||||
name: metricLabel("d1_retention_rate"),
|
name: isActiveMode ? "整体次日留存" : "新增次日留存",
|
||||||
symbol: "circle",
|
symbol: "circle",
|
||||||
symbolSize: 6,
|
symbolSize: 6,
|
||||||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
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 trendOption = useMemo(() => {
|
||||||
const rows = activeTrendApp === ALL ? dailyRows : dailyRows.filter((row) => row.app_code === activeTrendApp);
|
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) {
|
if (!buckets.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
grid: { ...CHART_GRID },
|
grid: { ...CHART_GRID },
|
||||||
legend: { ...CHART_LEGEND },
|
legend: { ...CHART_LEGEND },
|
||||||
series: RETENTION_SERIES.map(({ color, key }) => ({
|
series: retentionSeries.map(({ color, key, label }) => ({
|
||||||
connectNulls: false,
|
connectNulls: false,
|
||||||
data: aggregates.map((row) => metricChartValue(key, row[key])),
|
data: aggregates.map((row) => chartRatio(row[key])),
|
||||||
itemStyle: { color },
|
itemStyle: { color },
|
||||||
lineStyle: { color, width: 2.5 },
|
lineStyle: { color, width: 2.5 },
|
||||||
name: metricLabel(key),
|
name: `${isActiveMode ? "整体" : "新增"}${label}`,
|
||||||
symbol: "circle",
|
symbol: "circle",
|
||||||
symbolSize: 5,
|
symbolSize: 5,
|
||||||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
||||||
@ -177,10 +320,13 @@ export function RetentionView() {
|
|||||||
type: "value"
|
type: "value"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [activeTrendApp, dailyRows, filters.granularity]);
|
}, [activeTrendApp, dailyRows, filters.granularity, isActiveMode, retentionSeries]);
|
||||||
|
|
||||||
// 4) 热力对比表:按 App / 按区域;区域筛选时"按 App"改为区域行按 App 聚合,保持口径一致。
|
// SocialRequirements 当前提供 App 合计并跟随顶栏区域筛选,不伪造不存在的区域拆分;新增模式保留原区域对比。
|
||||||
const tableSource = useMemo(() => {
|
const tableSource = useMemo(() => {
|
||||||
|
if (isActiveMode) {
|
||||||
|
return activeRetention.appTotals;
|
||||||
|
}
|
||||||
if (tableDim === "region") {
|
if (tableDim === "region") {
|
||||||
return regionTotals;
|
return regionTotals;
|
||||||
}
|
}
|
||||||
@ -198,28 +344,40 @@ export function RetentionView() {
|
|||||||
app_name: rows[0].app_name,
|
app_name: rows[0].app_name,
|
||||||
...aggregateMetricMaps(rows)
|
...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 tableRows = useMemo(() => {
|
||||||
|
const sortKey = isActiveMode ? "active_d1_active_base_users" : "new_users";
|
||||||
return tableSource
|
return tableSource
|
||||||
.map((row) => ({
|
.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
|
row
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0));
|
.sort((a, b) => (Number(b.row[sortKey]) || 0) - (Number(a.row[sortKey]) || 0));
|
||||||
}, [tableDim, tableSource]);
|
}, [isActiveMode, tableDim, tableSource]);
|
||||||
|
|
||||||
const tableTotal = useMemo(() => {
|
const tableTotal = useMemo(() => {
|
||||||
return tableSource.length ? aggregateMetricMaps(tableSource) : null;
|
if (!tableSource.length) {
|
||||||
}, [tableSource]);
|
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 (
|
return (
|
||||||
<div className="sbi-rt-view">
|
<div className="sbi-rt-view">
|
||||||
|
{modeSwitch}
|
||||||
<div className="sbi-rt-summary">
|
<div className="sbi-rt-summary">
|
||||||
{RETENTION_SERIES.map(({ key }) => (
|
{retentionSeries.map(({ key }) => (
|
||||||
<section className="sbi-card sbi-rt-summary-card" key={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-line" />
|
||||||
<span className="sbi-skeleton sbi-rt-skeleton-value" />
|
<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) {
|
if (!hasRows) {
|
||||||
return (
|
return (
|
||||||
<div className="sbi-rt-view">
|
<div className="sbi-rt-view">
|
||||||
|
{modeSwitch}
|
||||||
<section className="sbi-card">
|
<section className="sbi-card">
|
||||||
<div className="sbi-empty">
|
<div className="sbi-empty">
|
||||||
<strong>暂无留存数据</strong>
|
<strong>暂无{isActiveMode ? "整体" : "新增"}留存数据</strong>
|
||||||
<span>当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。</span>
|
<span>当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -254,42 +427,59 @@ export function RetentionView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sbi-rt-view">
|
<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">
|
<div className="sbi-rt-summary">
|
||||||
{RETENTION_SERIES.map(({ key }) => {
|
{retentionSeries.map(({ baseKey, key, label, usersKey }) => (
|
||||||
const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
|
|
||||||
return (
|
|
||||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||||||
<span className="sbi-rt-summary-label sbi-metric-hint" title={metricTooltip(key)}>
|
<span className="sbi-rt-summary-label sbi-metric-hint" title={isActiveMode ? `${label}:基准日全体活跃用户在对应观察日仍活跃的比例` : metricTooltip(key)}>
|
||||||
整体{metricLabel(key)}
|
{isActiveMode ? "整体" : "新增"}{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
||||||
<span className="sbi-rt-summary-note">
|
<span className="sbi-rt-summary-note">
|
||||||
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
|
{summaryNote({
|
||||||
{isBlank(baseUsers) ? "" : ` · 回看基数 ${formatCount(baseUsers)} 人`}
|
base: summary?.[baseKey],
|
||||||
|
isActiveMode,
|
||||||
|
partial: partialErrors.length > 0,
|
||||||
|
regionScoped,
|
||||||
|
users: summary?.[usersKey]
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="sbi-card">
|
<section className="sbi-card">
|
||||||
<div className="sbi-card-header">
|
<div className="sbi-card-header">
|
||||||
<strong>新增与次日留存</strong>
|
<strong>{isActiveMode ? "活跃基数与次日留存" : "新增与次日留存"}</strong>
|
||||||
<small>柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按{granularityLabel}分桶</small>
|
<small>
|
||||||
|
{isActiveMode
|
||||||
|
? `柱=基准日全体活跃用户(左轴)· 线=该批用户次日继续活跃率(右轴)· 按${granularityLabel}分桶`
|
||||||
|
: `柱=当日新增(左轴)· 线=前一日注册用户的当日留存率(右轴)· 按${granularityLabel}分桶`}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{comboOption ? (
|
{comboOption ? (
|
||||||
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
||||||
) : (
|
) : (
|
||||||
<div className="sbi-empty">
|
<div className="sbi-empty">
|
||||||
<strong>暂无按日数据</strong>
|
<strong>暂无按日数据</strong>
|
||||||
<span>当前筛选没有返回按日序列,无法绘制新增与留存趋势。</span>
|
<span>当前筛选没有返回按日序列,无法绘制留存趋势。</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sbi-card">
|
<section className="sbi-card">
|
||||||
<div className="sbi-card-header">
|
<div className="sbi-card-header">
|
||||||
<strong>留存趋势</strong>
|
<strong>{isActiveMode ? "整体用户留存趋势" : "新增用户留存趋势"}</strong>
|
||||||
<small>D1 / D7 / D30 · 按{granularityLabel}分桶</small>
|
<small>D1 / D7 / D30 · 按{granularityLabel}分桶</small>
|
||||||
<div className="sbi-card-toolbar">
|
<div className="sbi-card-toolbar">
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="留存趋势维度">
|
<div className="sbi-seg" role="radiogroup" aria-label="留存趋势维度">
|
||||||
@ -302,7 +492,7 @@ export function RetentionView() {
|
|||||||
>
|
>
|
||||||
汇总
|
汇总
|
||||||
</button>
|
</button>
|
||||||
{appTotals.map((row) => (
|
{modeAppTotals.map((row) => (
|
||||||
<button
|
<button
|
||||||
aria-checked={activeTrendApp === row.app_code}
|
aria-checked={activeTrendApp === row.app_code}
|
||||||
className={activeTrendApp === row.app_code ? "is-active" : ""}
|
className={activeTrendApp === row.app_code ? "is-active" : ""}
|
||||||
@ -329,8 +519,9 @@ export function RetentionView() {
|
|||||||
|
|
||||||
<section className="sbi-card sbi-rt-table-card">
|
<section className="sbi-card sbi-rt-table-card">
|
||||||
<div className="sbi-card-header">
|
<div className="sbi-card-header">
|
||||||
<strong>留存对比</strong>
|
<strong>{isActiveMode ? "整体留存对比" : "新增留存对比"}</strong>
|
||||||
<small>D1/D7/D30 底色越深留存越高</small>
|
<small>{isActiveMode ? "全体活跃用户 cohort · 按 App 对比并跟随顶部区域筛选" : "D1/D7/D30 底色越深留存越高"}</small>
|
||||||
|
{!isActiveMode ? (
|
||||||
<div className="sbi-card-toolbar">
|
<div className="sbi-card-toolbar">
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
|
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
|
||||||
<button
|
<button
|
||||||
@ -353,6 +544,7 @@ export function RetentionView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{tableRows.length ? (
|
{tableRows.length ? (
|
||||||
<div className="sbi-table-scroll">
|
<div className="sbi-table-scroll">
|
||||||
@ -360,10 +552,10 @@ export function RetentionView() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="is-left">对象</th>
|
<th className="is-left">对象</th>
|
||||||
{TABLE_METRIC_KEYS.map((key) => (
|
{tableColumns.map((column) => (
|
||||||
<th key={key}>
|
<th key={column.key}>
|
||||||
<span className="sbi-metric-hint" title={metricTooltip(key)}>
|
<span className="sbi-metric-hint" title={column.tooltip}>
|
||||||
{metricLabel(key)}
|
{column.label}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@ -372,15 +564,15 @@ export function RetentionView() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr className="is-total">
|
<tr className="is-total">
|
||||||
<td className="is-left">合计</td>
|
<td className="is-left">合计</td>
|
||||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))}
|
{tableColumns.map((column) => renderMetricCell(column, tableTotal?.[column.key]))}
|
||||||
</tr>
|
</tr>
|
||||||
{tableRows.map((item) => (
|
{tableRows.map((item) => (
|
||||||
<tr key={item.key}>
|
<tr key={item.key}>
|
||||||
<td className="is-left">
|
<td className="is-left">
|
||||||
<SocialAppIdentity appCode={item.row.app_code} appName={item.row.app_name} />
|
<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>
|
</td>
|
||||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
|
{tableColumns.map((column) => renderMetricCell(column, item.row[column.key]))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -389,14 +581,15 @@ export function RetentionView() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="sbi-empty">
|
<div className="sbi-empty">
|
||||||
<strong>暂无对比数据</strong>
|
<strong>暂无对比数据</strong>
|
||||||
<span>{tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
|
<span>{!isActiveMode && tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p className="sbi-rt-footnote">
|
<p className="sbi-rt-footnote">
|
||||||
留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 =
|
{isActiveMode
|
||||||
各观察日留存人数之和 / 各观察日 cohort 基数之和。cohort 日无注册或数据未接入时显示 "--"。
|
? "整体留存按 cohort 基准日计算:某日的 N 日留存 = 该日全体 canonical 活跃用户中,在 D+N 仍活跃的人数 / 基准日活跃用户数;未到观察日的窗口显示“--”,不计入合计。"
|
||||||
|
: "新增留存按观察日回看计算:某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例;区间合计 = 各观察日留存人数之和 / 各观察日注册 cohort 基数之和。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,38 @@
|
|||||||
gap: 16px;
|
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 {
|
.sbi-rt-summary {
|
||||||
@ -96,6 +128,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
.sbi-rt-modebar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.sbi-rt-summary {
|
.sbi-rt-summary {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import Table from "@mui/material/Table";
|
import Table from "@mui/material/Table";
|
||||||
import TableBody from "@mui/material/TableBody";
|
import TableBody from "@mui/material/TableBody";
|
||||||
@ -8,15 +10,27 @@ import TableContainer from "@mui/material/TableContainer";
|
|||||||
import TableHead from "@mui/material/TableHead";
|
import TableHead from "@mui/material/TableHead";
|
||||||
import TableRow from "@mui/material/TableRow";
|
import TableRow from "@mui/material/TableRow";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { formatAmount, formatTime, statusLabel } from "../format.js";
|
import { formatAmount, formatTime, statusLabel } from "../format.js";
|
||||||
|
|
||||||
export function FinanceWithdrawalApplicationList({ applications, canAudit, error, filters, loading, onAudit, onFiltersChange, onReload, options }) {
|
export function FinanceWithdrawalApplicationList({ applications, canAudit, error, filters, loading, onAudit, onFiltersChange, onReload, options }) {
|
||||||
|
const { showToast } = useToast();
|
||||||
const items = applications.items || [];
|
const items = applications.items || [];
|
||||||
const page = Number(applications.page || filters.page || 1);
|
const page = Number(applications.page || filters.page || 1);
|
||||||
const pageSize = Number(applications.pageSize || 50);
|
const pageSize = Number(applications.pageSize || 50);
|
||||||
const total = Number(applications.total || 0);
|
const total = Number(applications.total || 0);
|
||||||
const hasNextPage = page * pageSize < total;
|
const hasNextPage = page * pageSize < total;
|
||||||
|
const copyWithdrawalAddress = async (address) => {
|
||||||
|
try {
|
||||||
|
// 始终复制接口返回的完整地址;即使表格布局后续调整,也不能复制缩写后的展示值。
|
||||||
|
await copyText(address);
|
||||||
|
showToast("提现地址已复制", "success");
|
||||||
|
} catch {
|
||||||
|
showToast("复制失败,请手动选择地址复制", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="finance-panel finance-list">
|
<section className="finance-panel finance-list">
|
||||||
@ -54,6 +68,7 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
|
|||||||
<TableCell className="finance-time-cell">申请时间</TableCell>
|
<TableCell className="finance-time-cell">申请时间</TableCell>
|
||||||
<TableCell>审批人</TableCell>
|
<TableCell>审批人</TableCell>
|
||||||
<TableCell className="finance-time-cell">审批时间</TableCell>
|
<TableCell className="finance-time-cell">审批时间</TableCell>
|
||||||
|
<TableCell>审核原因</TableCell>
|
||||||
<TableCell align="right">操作</TableCell>
|
<TableCell align="right">操作</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@ -73,15 +88,27 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
|
|||||||
<TableCell align="right">$ {formatAmount(item.withdrawAmount)}</TableCell>
|
<TableCell align="right">$ {formatAmount(item.withdrawAmount)}</TableCell>
|
||||||
<TableCell>{item.withdrawMethod || "-"}</TableCell>
|
<TableCell>{item.withdrawMethod || "-"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="finance-address" title={item.withdrawAddress}>
|
{item.withdrawAddress ? (
|
||||||
{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>
|
</span>
|
||||||
|
) : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stacked primary={item.approverName || "-"} secondary={item.approverUserId || ""} />
|
<Stacked primary={item.approverName || "-"} secondary={item.approverUserId || ""} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="finance-time-cell">{formatTime(item.approvedAtMs)}</TableCell>
|
<TableCell className="finance-time-cell">{formatTime(item.approvedAtMs)}</TableCell>
|
||||||
|
<TableCell>{item.auditRemark || "-"}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{item.status === "pending" && canAudit ? (
|
{item.status === "pending" && canAudit ? (
|
||||||
<span className="finance-row-actions">
|
<span className="finance-row-actions">
|
||||||
@ -100,7 +127,7 @@ export function FinanceWithdrawalApplicationList({ applications, canAudit, error
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell align="center" colSpan={9}>
|
<TableCell align="center" colSpan={10}>
|
||||||
当前无数据
|
当前无数据
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -134,3 +161,27 @@ function Stacked({ primary, secondary }) {
|
|||||||
</span>
|
</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 { render, screen } from "@testing-library/react";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx";
|
import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx";
|
||||||
|
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
@ -21,11 +22,18 @@ const baseProps = {
|
|||||||
options: { apps: [] }
|
options: { apps: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function renderList(props) {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceWithdrawalApplicationList {...props} />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test("renders withdrawal application table with requested finance columns", () => {
|
test("renders withdrawal application table with requested finance columns", () => {
|
||||||
render(
|
renderList({
|
||||||
<FinanceWithdrawalApplicationList
|
...baseProps,
|
||||||
{...baseProps}
|
applications: {
|
||||||
applications={{
|
|
||||||
...baseProps.applications,
|
...baseProps.applications,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -33,6 +41,7 @@ test("renders withdrawal application table with requested finance columns", () =
|
|||||||
approvedAtMs: 1760007200000,
|
approvedAtMs: 1760007200000,
|
||||||
approverName: "财务一",
|
approverName: "财务一",
|
||||||
approverUserId: 21,
|
approverUserId: 21,
|
||||||
|
auditRemark: "地址不符合要求",
|
||||||
createdAtMs: 1760000000000,
|
createdAtMs: 1760000000000,
|
||||||
id: 7,
|
id: 7,
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
@ -42,9 +51,8 @@ test("renders withdrawal application table with requested finance columns", () =
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
total: 1
|
total: 1
|
||||||
}}
|
}
|
||||||
/>
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "用户ID" })).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.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("lalu")).toBeInTheDocument();
|
||||||
expect(screen.getByText("10001")).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("TRX-address")).toBeInTheDocument();
|
||||||
expect(screen.getByText("财务一")).toBeInTheDocument();
|
expect(screen.getByText("财务一")).toBeInTheDocument();
|
||||||
expect(screen.getByText("21")).toBeInTheDocument();
|
expect(screen.getByText("21")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("地址不符合要求")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps empty withdrawal table colspan aligned with visible columns", () => {
|
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();
|
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -462,11 +462,15 @@ a {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finance-address-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.finance-address {
|
.finance-address {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 240px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user