数据报表子结构查看

This commit is contained in:
zhx 2026-06-29 11:27:16 +08:00
parent 3e8d4b4aea
commit 0e87bb24af
5 changed files with 278 additions and 20 deletions

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { Fragment, useCallback, useMemo, useState } from "react";
import { EChart } from "../charts/EChart.jsx";
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
@ -46,6 +46,8 @@ const countryColumns = [
{ key: "recharge_conversion_rate", label: "充值转化率", render: (row) => formatOptionalPercent(row.recharge_conversion_rate), sortable: false }
];
const countryDetailColumns = countryColumns.filter((column) => column.key !== "date_label");
const revenueColumns = [
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
@ -170,9 +172,40 @@ function ReportMetricCard({ card, loading, onOpenTrend }) {
}
function CountryReport({ loading, rows }) {
const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
const toggleRow = useCallback((rowId) => {
setExpandedRowIds((current) => {
const next = new Set(current);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
}, []);
const tableRows = useMemo(() => rows.map((row) => {
const children = Array.isArray(row.children) ? row.children : [];
if (row.row_type !== "date_total" || !children.length) {
return row;
}
return {
...row,
expanded: expandedRowIds.has(row.row_id),
onToggle: () => toggleRow(row.row_id)
};
}), [expandedRowIds, rows, toggleRow]);
return (
<ReportPanel>
<ReportTable columns={countryColumns} loading={loading} preserveOrder rows={rows} wide />
<ReportTable
columns={countryColumns}
loading={loading}
preserveOrder
renderExpandedRow={(row) => <CountryDetailTable dateLabel={row.date_label} rows={row.children || []} />}
rows={tableRows}
wide
/>
</ReportPanel>
);
}
@ -257,7 +290,7 @@ function ReportMetricTable({ loading, rows }) {
);
}
function ReportTable({ columns, defaultSort, loading, preserveOrder = false, rows, wide = false }) {
function ReportTable({ columns, defaultSort, loading, preserveOrder = false, renderExpandedRow, rows, wide = false }) {
const sortableDefault = defaultSort || firstSortableColumn(columns);
const [sortState, setSortState] = useState(sortableDefault);
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
@ -304,10 +337,55 @@ function ReportTable({ columns, defaultSort, loading, preserveOrder = false, row
<tbody>
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
{!loading && sortedRows.map((row, index) => (
<tr className={reportRowClass(row)} key={reportRowKey(row, index)}>
<td>{reportRowIndex(row, index)}</td>
{columns.map((column) => (
{!loading && sortedRows.map((row, index) => {
const key = reportRowKey(row, index);
const rowProps = reportTableRowProps(row);
const { className: rowClassName, ...interactiveRowProps } = rowProps;
return (
<Fragment key={key}>
<tr
{...interactiveRowProps}
className={[reportRowClass(row), rowClassName].filter(Boolean).join(" ")}
>
<td>{reportRowIndex(row, index)}</td>
{columns.map((column) => (
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
))}
</tr>
{row.expanded && renderExpandedRow ? (
<tr className="report-row-expanded-detail">
<td colSpan={columns.length + 1}>{renderExpandedRow(row)}</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}
function CountryDetailTable({ dateLabel, rows }) {
if (!rows.length) {
return <div className="report-subtable-empty">当前无数据</div>;
}
return (
<div className="report-subtable-wrap">
<table aria-label={`${dateLabel || "日期"} 国家明细`} className="report-table report-subtable">
<thead>
<tr>
<th>#</th>
{countryDetailColumns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={reportRowKey(row, index)}>
<td>{index + 1}</td>
{countryDetailColumns.map((column) => (
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
))}
</tr>
@ -328,7 +406,18 @@ function CountryCell({ row }) {
}
function DateCell({ row }) {
return <span className="report-date-cell">{row.date_label || "--"}</span>;
const childCount = Array.isArray(row.children) ? row.children.length : 0;
return (
<span className={["report-date-cell", childCount ? "report-date-cell--expandable" : ""].filter(Boolean).join(" ")}>
{childCount ? (
<span className={["report-expand-caret", row.expanded ? "is-open" : ""].filter(Boolean).join(" ")} aria-hidden="true">
</span>
) : null}
<span>{row.date_label || "--"}</span>
{childCount ? <small>{childCount} </small> : null}
</span>
);
}
function FlowRateCell({ rate, value }) {
@ -497,6 +586,28 @@ function reportRowClass(row) {
return "";
}
function reportTableRowProps(row) {
if (!row.onToggle) {
return {};
}
const action = row.expanded ? "收起" : "展开";
const label = `${action} ${row.date_label || "日期"} 国家明细`;
return {
"aria-expanded": row.expanded,
"aria-label": label,
className: "report-row-expandable",
role: "button",
tabIndex: 0,
onClick: row.onToggle,
onKeyDown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
row.onToggle();
}
}
};
}
function reportRowIndex(row, index) {
if (row.row_type === "total") {
return "总";

View File

@ -0,0 +1,64 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { createDashboardModel } from "../data/createDashboardModel.js";
import { ReportOverview } from "./ReportOverview.jsx";
test("shows daily totals first and expands daily country details on demand", async () => {
const user = userEvent.setup();
const model = createDashboardModel({
country_breakdown: [
{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 30000 },
{ country: "沙特", country_id: 966, active_users: 12, recharge_usd_minor: 12000 }
],
daily_country_breakdown: [
{
active_users: 10,
country: "巴西",
country_id: 86,
recharge_usd_minor: 10000,
stat_day: "2026-06-05"
},
{
active_users: 5,
country: "沙特",
country_id: 966,
recharge_usd_minor: 5000,
stat_day: "2026-06-05"
},
{
active_users: 20,
country: "巴西",
country_id: 86,
recharge_usd_minor: 20000,
stat_day: "2026-06-06"
}
],
daily_series: [
{ active_users: 15, recharge_usd_minor: 15000, stat_day: "2026-06-05" },
{ active_users: 20, recharge_usd_minor: 20000, stat_day: "2026-06-06" }
],
recharge_usd_minor: 35000
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
render(<ReportOverview model={model} />);
expect(screen.queryByText("巴西")).not.toBeInTheDocument();
expect(screen.queryByText("沙特")).not.toBeInTheDocument();
const june5Row = screen.getByRole("button", { name: "展开 2026-06-05 国家明细" });
expect(june5Row).toHaveAttribute("aria-expanded", "false");
await user.click(june5Row);
expect(june5Row).toHaveAttribute("aria-expanded", "true");
const detailRows = within(screen.getByRole("table", { name: "2026-06-05 国家明细" })).getAllByRole("row");
expect(detailRows).toHaveLength(3);
expect(screen.getByText("巴西")).toBeInTheDocument();
expect(screen.getByText("沙特")).toBeInTheDocument();
await user.click(june5Row);
expect(june5Row).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("巴西")).not.toBeInTheDocument();
});

View File

@ -287,22 +287,18 @@ function buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, daily
for (const day of dayKeys) {
const dayCountries = dailyRowsByDay.get(day) || [];
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.get(day) || {}) };
const childRows = dayCountries.map((row) => createReportRow(row, {
date_label: day,
row_type: "country",
stat_day: day
}));
rows.push(createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
children: childRows,
country: "总计",
date_label: day,
row_type: "date_total",
stat_day: day
}));
if (dayCountries.length) {
rows.push(...dayCountries.map((row) => createReportRow(row, {
date_label: day,
row_type: "country",
stat_day: day
})));
}
}
if (!dailyCountryBreakdown.length) {
rows.push(...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" })));
}
return rows;
}

View File

@ -163,10 +163,11 @@ test("builds report rows with range total, daily totals, and daily country rows"
recharge_usd_minor: 300
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
expect(model.reportCountryRows.map((row) => row.row_type)).toEqual(["total", "date_total", "country", "date_total", "country"]);
expect(model.reportCountryRows.map((row) => row.row_type)).toEqual(["total", "date_total", "date_total"]);
expect(model.reportCountryRows[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
expect(model.reportCountryRows[1]).toEqual(expect.objectContaining({ country: "总计", date_label: "2026-06-05" }));
expect(model.reportCountryRows[2]).toEqual(expect.objectContaining({
expect(model.reportCountryRows[1].children).toHaveLength(1);
expect(model.reportCountryRows[1].children[0]).toEqual(expect.objectContaining({
coin_seller_stock_coin: 800,
coin_seller_transfer_coin: 500,
country: "巴西",

View File

@ -494,6 +494,16 @@
font-weight: 760;
}
.report-row-expandable {
cursor: pointer;
}
.report-row-expandable:focus-visible td {
background: #e9f5ff;
outline: 2px solid #1688d9;
outline-offset: -2px;
}
.report-row-total:hover td,
.report-row-date-total:hover td {
background: #e9f5ff;
@ -549,10 +559,86 @@
}
.report-date-cell {
display: inline-flex;
max-width: 180px;
min-width: 0;
align-items: center;
gap: 6px;
color: #53647b;
font-weight: 700;
}
.report-date-cell > span:not(.report-expand-caret) {
overflow: hidden;
text-overflow: ellipsis;
}
.report-date-cell small {
color: #8a98aa;
font-size: 11px;
font-weight: 700;
}
.report-expand-caret {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
color: #7b8a9d;
font-size: 17px;
line-height: 1;
transition: transform 160ms ease;
}
.report-expand-caret.is-open {
transform: rotate(90deg);
}
.report-row-expanded-detail > td:first-child {
width: auto;
padding: 10px 12px 14px 74px;
background: #fbfdff;
text-align: left;
}
.report-row-expanded-detail:hover > td {
background: #fbfdff;
}
.report-subtable-wrap {
min-width: 0;
overflow: auto;
border: 1px solid #e2ebf5;
border-radius: 8px;
background: #ffffff;
}
.report-subtable {
min-width: 2620px;
}
.report-subtable th {
position: static;
background: #f8fbff;
}
.report-subtable th:first-child,
.report-subtable td:first-child {
width: 48px;
}
.report-subtable-empty {
display: grid;
min-height: 72px;
place-items: center;
border: 1px dashed #d8e5f0;
border-radius: 8px;
background: #ffffff;
color: #8a98aa;
}
.report-pair-cell {
display: inline-flex;
min-width: 118px;