数据报表子结构查看
This commit is contained in:
parent
3e8d4b4aea
commit
0e87bb24af
@ -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 "总";
|
||||
|
||||
64
databi/src/components/ReportOverview.test.jsx
Normal file
64
databi/src/components/ReportOverview.test.jsx
Normal 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();
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: "巴西",
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user