This commit is contained in:
zhx 2026-07-16 14:21:33 +08:00
parent 00b6af42dd
commit 31804f5e34
10 changed files with 801 additions and 147 deletions

View File

@ -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&regions=lalu:9,lalu:11");
fetchSocialBiMaster.mockResolvedValue({

View File

@ -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";

View File

@ -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);
});

View 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;
}

View File

@ -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(() => {

View File

@ -1,11 +1,12 @@
// RetentionView D1/D7/D30 ×
// / AppApp/ 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>
);

View File

@ -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;
}

View File

@ -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");
}
}

View File

@ -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();
});

View File

@ -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;
}