数据报表子结构查看
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 { EChart } from "../charts/EChart.jsx";
|
||||||
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
|
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
|
||||||
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.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 }
|
{ 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 = [
|
const revenueColumns = [
|
||||||
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
||||||
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
|
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
|
||||||
@ -170,9 +172,40 @@ function ReportMetricCard({ card, loading, onOpenTrend }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CountryReport({ loading, rows }) {
|
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 (
|
return (
|
||||||
<ReportPanel>
|
<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>
|
</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 sortableDefault = defaultSort || firstSortableColumn(columns);
|
||||||
const [sortState, setSortState] = useState(sortableDefault);
|
const [sortState, setSortState] = useState(sortableDefault);
|
||||||
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
|
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>
|
<tbody>
|
||||||
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
|
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
|
||||||
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
|
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
|
||||||
{!loading && sortedRows.map((row, index) => (
|
{!loading && sortedRows.map((row, index) => {
|
||||||
<tr className={reportRowClass(row)} key={reportRowKey(row, index)}>
|
const key = reportRowKey(row, index);
|
||||||
<td>{reportRowIndex(row, index)}</td>
|
const rowProps = reportTableRowProps(row);
|
||||||
{columns.map((column) => (
|
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>
|
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@ -328,7 +406,18 @@ function CountryCell({ row }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DateCell({ 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 }) {
|
function FlowRateCell({ rate, value }) {
|
||||||
@ -497,6 +586,28 @@ function reportRowClass(row) {
|
|||||||
return "";
|
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) {
|
function reportRowIndex(row, index) {
|
||||||
if (row.row_type === "total") {
|
if (row.row_type === "total") {
|
||||||
return "总";
|
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) {
|
for (const day of dayKeys) {
|
||||||
const dayCountries = dailyRowsByDay.get(day) || [];
|
const dayCountries = dailyRowsByDay.get(day) || [];
|
||||||
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.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), {
|
rows.push(createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
||||||
|
children: childRows,
|
||||||
country: "总计",
|
country: "总计",
|
||||||
date_label: day,
|
date_label: day,
|
||||||
row_type: "date_total",
|
row_type: "date_total",
|
||||||
stat_day: day
|
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;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,10 +163,11 @@ test("builds report rows with range total, daily totals, and daily country rows"
|
|||||||
recharge_usd_minor: 300
|
recharge_usd_minor: 300
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
|
}, { 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[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
|
||||||
expect(model.reportCountryRows[1]).toEqual(expect.objectContaining({ country: "总计", date_label: "2026-06-05" }));
|
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_stock_coin: 800,
|
||||||
coin_seller_transfer_coin: 500,
|
coin_seller_transfer_coin: 500,
|
||||||
country: "巴西",
|
country: "巴西",
|
||||||
|
|||||||
@ -494,6 +494,16 @@
|
|||||||
font-weight: 760;
|
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-total:hover td,
|
||||||
.report-row-date-total:hover td {
|
.report-row-date-total:hover td {
|
||||||
background: #e9f5ff;
|
background: #e9f5ff;
|
||||||
@ -549,10 +559,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.report-date-cell {
|
.report-date-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 180px;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
color: #53647b;
|
color: #53647b;
|
||||||
font-weight: 700;
|
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 {
|
.report-pair-cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 118px;
|
min-width: 118px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user