From 0e87bb24afd1df0c1ed8940876b73aa0e45a39be Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 29 Jun 2026 11:27:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=8A=A5=E8=A1=A8=E5=AD=90?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- databi/src/components/ReportOverview.jsx | 127 ++++++++++++++++-- databi/src/components/ReportOverview.test.jsx | 64 +++++++++ databi/src/data/createDashboardModel.js | 16 +-- databi/src/data/createDashboardModel.test.js | 5 +- databi/src/styles/report.css | 86 ++++++++++++ 5 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 databi/src/components/ReportOverview.test.jsx 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) => ( + + ))} + + + + {rows.map((row, index) => ( + + + {countryDetailColumns.map((column) => ( ))} @@ -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;
#{column.label}
{index + 1}{column.render ? column.render(row) : row[column.key]}