diff --git a/databi/src/components/ReportOverview.jsx b/databi/src/components/ReportOverview.jsx
index e773451..2550208 100644
--- a/databi/src/components/ReportOverview.jsx
+++ b/databi/src/components/ReportOverview.jsx
@@ -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 (
-
+ }
+ rows={tableRows}
+ wide
+ />
);
}
@@ -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
{loading ? : null}
{!loading && !rows.length ? : null}
- {!loading && sortedRows.map((row, index) => (
-
- | {reportRowIndex(row, index)} |
- {columns.map((column) => (
+ {!loading && sortedRows.map((row, index) => {
+ const key = reportRowKey(row, index);
+ const rowProps = reportTableRowProps(row);
+ const { className: rowClassName, ...interactiveRowProps } = rowProps;
+ return (
+
+
+ | {reportRowIndex(row, index)} |
+ {columns.map((column) => (
+ {column.render ? column.render(row) : row[column.key]} |
+ ))}
+
+ {row.expanded && renderExpandedRow ? (
+
+ | {renderExpandedRow(row)} |
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ );
+}
+
+function CountryDetailTable({ dateLabel, rows }) {
+ if (!rows.length) {
+ return 当前无数据
;
+ }
+ return (
+
+
+
+
+ | # |
+ {countryDetailColumns.map((column) => (
+ {column.label} |
+ ))}
+
+
+
+ {rows.map((row, index) => (
+
+ | {index + 1} |
+ {countryDetailColumns.map((column) => (
{column.render ? column.render(row) : row[column.key]} |
))}
@@ -328,7 +406,18 @@ function CountryCell({ row }) {
}
function DateCell({ row }) {
- return {row.date_label || "--"};
+ const childCount = Array.isArray(row.children) ? row.children.length : 0;
+ return (
+
+ {childCount ? (
+
+ ›
+
+ ) : null}
+ {row.date_label || "--"}
+ {childCount ? {childCount} 国 : null}
+
+ );
}
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 "总";
diff --git a/databi/src/components/ReportOverview.test.jsx b/databi/src/components/ReportOverview.test.jsx
new file mode 100644
index 0000000..bbb3569
--- /dev/null
+++ b/databi/src/components/ReportOverview.test.jsx
@@ -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();
+
+ 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();
+});
diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js
index 295c44f..f6184b2 100644
--- a/databi/src/data/createDashboardModel.js
+++ b/databi/src/data/createDashboardModel.js
@@ -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;
}
diff --git a/databi/src/data/createDashboardModel.test.js b/databi/src/data/createDashboardModel.test.js
index 181d70e..ade9cb2 100644
--- a/databi/src/data/createDashboardModel.test.js
+++ b/databi/src/data/createDashboardModel.test.js
@@ -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: "巴西",
diff --git a/databi/src/styles/report.css b/databi/src/styles/report.css
index 508e0bc..fff67e8 100644
--- a/databi/src/styles/report.css
+++ b/databi/src/styles/report.css
@@ -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;