diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index d859e0b..689f037 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -254,6 +254,14 @@ export function DatabiApp() { ) : isReportView ? ( formatOptionalCoin(row.gift_coin_spent), sortable: false }, { key: "coin_total", label: "金币数量(总和)", render: (row) => formatOptionalCoin(row.coin_total), sortable: false }, { key: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false }, - { key: "platform_grant_coin", label: "平台发放金币", render: (row) => formatOptionalCoin(row.platform_grant_coin), sortable: false }, + { key: "platform_grant_coin", label: "平台发放金币", render: (row, context) => , sortable: false }, { key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false }, { key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false }, { key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false }, @@ -88,7 +89,7 @@ const metricColumns = [ { key: "delta", label: "变化", render: (row) => , sortable: false } ]; -export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) { +export function ReportOverview({ grantDrilldownQuery, loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) { const [activeTab, setActiveTab] = useState("country"); const summaryCards = useMemo(() => createSummaryCards(model), [model]); @@ -116,7 +117,7 @@ export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLu ))}
- {activeTab === "country" ? : null} + {activeTab === "country" ? : null} {activeTab === "revenue" ? : null} {activeTab === "gift" ? : null} {activeTab === "lucky" ? : null} @@ -171,8 +172,11 @@ function ReportMetricCard({ card, loading, onOpenTrend }) { ); } -function CountryReport({ loading, rows }) { +function CountryReport({ grantDrilldownQuery, loading, rows }) { const [expandedRowIds, setExpandedRowIds] = useState(() => new Set()); + const [grantCountryModal, setGrantCountryModal] = useState(null); + const [grantUsersModal, setGrantUsersModal] = useState(null); + const [grantRecordsModal, setGrantRecordsModal] = useState(null); const toggleRow = useCallback((rowId) => { setExpandedRowIds((current) => { const next = new Set(current); @@ -195,17 +199,66 @@ function CountryReport({ loading, rows }) { onToggle: () => toggleRow(row.row_id) }; }), [expandedRowIds, rows, toggleRow]); + const openGrantUsers = useCallback((row) => { + setGrantCountryModal(null); + setGrantRecordsModal(null); + setGrantUsersModal({ + countryLabel: row.country || "国家", + query: buildPlatformGrantQuery(grantDrilldownQuery, row) + }); + }, [grantDrilldownQuery]); + const openGrantCountries = useCallback((row) => { + const countryRows = platformGrantCountryRows(row, rows); + if (!countryRows.length) { + return; + } + setGrantRecordsModal(null); + setGrantUsersModal(null); + setGrantCountryModal({ + rows: countryRows, + title: row.row_type === "date_total" ? `${row.date_label} 平台发放金币` : "平台发放金币" + }); + }, [rows]); + const grantContext = useMemo(() => ({ + onOpenPlatformGrantCountries: openGrantCountries, + onOpenPlatformGrantUsers: openGrantUsers + }), [openGrantCountries, openGrantUsers]); return ( } + renderExpandedRow={(row) => } rows={tableRows} wide /> + {grantCountryModal ? ( + setGrantCountryModal(null)} + onOpenUsers={openGrantUsers} + /> + ) : null} + {grantUsersModal ? ( + setGrantUsersModal(null)} + onOpenRecords={(userRow) => setGrantRecordsModal({ + countryLabel: grantUsersModal.countryLabel, + query: { ...grantUsersModal.query, userId: userRow.user_id }, + user: userRow + })} + /> + ) : null} + {grantRecordsModal ? ( + setGrantRecordsModal(null)} + /> + ) : null} ); } @@ -290,7 +343,7 @@ function ReportMetricTable({ loading, rows }) { ); } -function ReportTable({ columns, defaultSort, loading, preserveOrder = false, renderExpandedRow, rows, wide = false }) { +function ReportTable({ columns, context, 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]); @@ -360,7 +413,7 @@ function ReportTable({ columns, defaultSort, loading, preserveOrder = false, ren ].filter(Boolean).join(" ")} key={column.key} > - {column.render ? column.render(row) : row[column.key]} + {column.render ? column.render(row, context) : row[column.key]} ))} @@ -378,7 +431,7 @@ function ReportTable({ columns, defaultSort, loading, preserveOrder = false, ren ); } -function CountryDetailTable({ dateLabel, rows }) { +function CountryDetailTable({ context, dateLabel, rows }) { if (!rows.length) { return
当前无数据
; } @@ -398,7 +451,7 @@ function CountryDetailTable({ dateLabel, rows }) { {index + 1} {countryDetailColumns.map((column) => ( - {column.render ? column.render(row) : row[column.key]} + {column.render ? column.render(row, context) : row[column.key]} ))} ))} @@ -408,6 +461,245 @@ function CountryDetailTable({ dateLabel, rows }) { ); } +function PlatformGrantCoinCell({ context, row }) { + const value = Number(row.platform_grant_coin || 0); + const clickable = value > 0 && (row.row_type === "country" ? context?.onOpenPlatformGrantUsers : context?.onOpenPlatformGrantCountries); + if (!clickable) { + return formatOptionalCoin(row.platform_grant_coin); + } + const label = row.row_type === "country" + ? `查看 ${row.country || "国家"} 平台发放金币用户明细` + : `查看 ${row.date_label || "全部"} 平台发放金币国家明细`; + return ( + + ); +} + +function PlatformGrantCountryModal({ modal, onClose, onOpenUsers }) { + return ( + +
+ + + + + + + + + {modal.rows.length ? modal.rows.map((row) => ( + + + + + )) : ( + + )} + +
国家平台发放金币
+ + {formatOptionalCoin(row.platform_grant_coin)}
当前无数据
+
+
+ ); +} + +function PlatformGrantUsersModal({ modal, onClose, onOpenRecords }) { + const pageSize = 20; + const [page, setPage] = useState(1); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + fetchPlatformGrantUsers({ ...modal.query, page, pageSize }) + .then((nextData) => { + if (active) { + setData(nextData || {}); + } + }) + .catch((err) => { + if (active) { + setError(err.message || "请求失败"); + setData(null); + } + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [modal.query, page]); + const items = Array.isArray(data?.items) ? data.items : []; + const total = Number(data?.total || 0); + + return ( + +
+ + + + + + + + + + + {loading ? : null} + {!loading && error ? : null} + {!loading && !error && !items.length ? : null} + {!loading && !error ? items.map((item) => ( + + + + + + + )) : null} + +
用户平台发放金币记录数最近发放时间
{error}
当前无数据
+ + {formatOptionalCoin(item.total_coin)}{formatOptionalCoin(item.record_count)}{formatDateTime(item.last_granted_at_ms)}
+
+ +
+ ); +} + +function PlatformGrantRecordsModal({ modal, onClose }) { + const pageSize = 20; + const [page, setPage] = useState(1); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + fetchPlatformGrantRecords({ ...modal.query, page, pageSize }) + .then((nextData) => { + if (active) { + setData(nextData || {}); + } + }) + .catch((err) => { + if (active) { + setError(err.message || "请求失败"); + setData(null); + } + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [modal.query, page]); + const items = Array.isArray(data?.items) ? data.items : []; + const total = Number(data?.total || 0); + + return ( + +
+ + + + + + + + + + + {loading ? : null} + {!loading && error ? : null} + {!loading && !error && !items.length ? : null} + {!loading && !error ? items.map((item) => ( + + + + + + + )) : null} + +
来源金币发放时间事件 ID
{error}
当前无数据
{platformGrantSourceLabel(item.event_type)}{formatOptionalCoin(item.amount)}{formatDateTime(item.occurred_at_ms)}{item.event_id || "--"}
+
+ +
+ ); +} + +function ReportDialog({ children, onClose, title }) { + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +function ReportPagination({ onPageChange, page, pageSize, total }) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + return ( +
+ 共 {formatCoin(total)} 条 +
+ + {page} / {totalPages} + +
+
+ ); +} + function CountryCell({ row }) { return ( @@ -575,6 +867,108 @@ function compareValues(leftValue, rightValue, type) { return leftNumber - rightNumber; } +function buildPlatformGrantQuery(baseQuery = {}, row = {}) { + const countryId = Number(row.country_id ?? row.countryId ?? baseQuery.countryId ?? 0); + const regionId = row.region_id ?? row.regionId ?? baseQuery.regionId; + return { + appCode: baseQuery.appCode, + countryId, + endMs: baseQuery.endMs, + regionId, + startMs: baseQuery.startMs, + statDay: row.stat_day || row.statDay || "", + statTz: baseQuery.statTz + }; +} + +function platformGrantCountryRows(row, rows) { + const sourceRows = row.row_type === "date_total" && Array.isArray(row.children) && row.children.length + ? row.children + : collectPlatformGrantCountryRows(rows); + return aggregatePlatformGrantCountryRows(sourceRows) + .filter((item) => Number(item.platform_grant_coin || 0) > 0) + .sort((left, right) => Number(right.platform_grant_coin || 0) - Number(left.platform_grant_coin || 0)); +} + +function collectPlatformGrantCountryRows(rows) { + const out = []; + rows.forEach((row) => { + if (row.row_type === "country") { + out.push(row); + } + if (Array.isArray(row.children)) { + out.push(...collectPlatformGrantCountryRows(row.children)); + } + }); + return out; +} + +function aggregatePlatformGrantCountryRows(rows) { + const grouped = new Map(); + rows.forEach((row) => { + const key = [row.country_id ?? row.country, row.region_id ?? ""].join(":"); + const current = grouped.get(key); + if (!current) { + grouped.set(key, { ...row, platform_grant_coin: Number(row.platform_grant_coin || 0) }); + return; + } + const nextStatDay = current.stat_day && row.stat_day && current.stat_day === row.stat_day ? current.stat_day : ""; + grouped.set(key, { + ...current, + platform_grant_coin: Number(current.platform_grant_coin || 0) + Number(row.platform_grant_coin || 0), + stat_day: nextStatDay + }); + }); + return Array.from(grouped.values()); +} + +function platformGrantUserLabel(item = {}) { + return item.user?.display_user_id || item.display_user_id || item.user?.username || item.username || item.nickname || item.user_id || "用户"; +} + +function platformGrantUserMeta(item = {}) { + return [item.user?.username || item.username || item.nickname, item.user_id] + .filter((value, index, values) => value && values.indexOf(value) === index) + .join(" · ") || "--"; +} + +function platformGrantSourceLabel(value) { + switch (String(value || "").trim()) { + case "WalletLuckyGiftRewardCredited": + return "幸运礼物返奖"; + case "WalletTaskRewardCredited": + return "任务奖励"; + case "WalletWheelRewardCredited": + return "转盘奖励"; + case "WalletRoomTurnoverRewardCredited": + return "房间流水奖励"; + case "WalletInviteActivityRewardCredited": + return "邀请活动奖励"; + case "WalletAgencyOpeningRewardCredited": + return "代理开通奖励"; + case "WalletPlatformGrantCoinCredited": + return "平台发放"; + default: + return value || "--"; + } +} + +function formatDateTime(value) { + const ms = Number(value || 0); + if (!ms) { + return "--"; + } + return new Date(ms).toLocaleString("zh-CN", { + day: "2-digit", + hour: "2-digit", + hour12: false, + minute: "2-digit", + month: "2-digit", + second: "2-digit", + year: "numeric" + }); +} + function formatSignedPercent(value) { if (value === undefined || value === null || Number.isNaN(Number(value))) { return "--"; diff --git a/databi/src/components/ReportOverview.test.jsx b/databi/src/components/ReportOverview.test.jsx index c8498bc..79f7ef1 100644 --- a/databi/src/components/ReportOverview.test.jsx +++ b/databi/src/components/ReportOverview.test.jsx @@ -1,9 +1,20 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { expect, test } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; +import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js"; import { createDashboardModel } from "../data/createDashboardModel.js"; import { ReportOverview } from "./ReportOverview.jsx"; +vi.mock("../api.js", () => ({ + fetchPlatformGrantRecords: vi.fn(), + fetchPlatformGrantUsers: vi.fn() +})); + +beforeEach(() => { + vi.mocked(fetchPlatformGrantRecords).mockReset(); + vi.mocked(fetchPlatformGrantUsers).mockReset(); +}); + test("shows daily totals first and expands daily country details on demand", async () => { const user = userEvent.setup(); const model = createDashboardModel({ @@ -67,3 +78,84 @@ test("shows daily totals first and expands daily country details on demand", asy expect(june5Row).toHaveAttribute("aria-expanded", "false"); expect(screen.queryByText("巴西")).not.toBeInTheDocument(); }); + +test("drills platform grant coin from total to country users and user records", async () => { + const user = userEvent.setup(); + vi.mocked(fetchPlatformGrantUsers).mockResolvedValue({ + items: [{ + display_user_id: "163001", + last_granted_at_ms: Date.UTC(2026, 5, 5, 10, 20, 30), + record_count: 2, + total_coin: 55, + user_id: "7001", + username: "Ana" + }], + page: 1, + pageSize: 20, + total: 1 + }); + vi.mocked(fetchPlatformGrantRecords).mockResolvedValue({ + items: [ + { amount: 33, event_id: "grant:task:7001", event_type: "WalletTaskRewardCredited", occurred_at_ms: Date.UTC(2026, 5, 5, 10, 20, 30), user_id: "7001" }, + { amount: 22, event_id: "grant:lucky:7001", event_type: "WalletLuckyGiftRewardCredited", occurred_at_ms: Date.UTC(2026, 5, 5, 9, 20, 30), user_id: "7001" } + ], + page: 1, + pageSize: 20, + total: 2 + }); + const model = createDashboardModel({ + country_breakdown: [ + { country: "巴西", country_id: 86, platform_grant_coin: 55, region_id: 10 }, + { country: "沙特", country_id: 966, platform_grant_coin: 12, region_id: 20 } + ], + daily_country_breakdown: [ + { country: "巴西", country_id: 86, platform_grant_coin: 33, region_id: 10, stat_day: "2026-06-05" }, + { country: "巴西", country_id: 86, platform_grant_coin: 22, region_id: 10, stat_day: "2026-06-06" }, + { country: "沙特", country_id: 966, platform_grant_coin: 12, region_id: 20, stat_day: "2026-06-06" } + ], + daily_series: [ + { platform_grant_coin: 33, stat_day: "2026-06-05" }, + { platform_grant_coin: 34, stat_day: "2026-06-06" } + ], + platform_grant_coin: 67 + }, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } }); + + render( + + ); + + await user.click(screen.getByRole("button", { name: "查看 全部 平台发放金币国家明细" })); + expect(screen.getByRole("dialog", { name: "平台发放金币" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "查看 巴西 平台发放金币用户明细" })); + await waitFor(() => expect(fetchPlatformGrantUsers).toHaveBeenCalledWith(expect.objectContaining({ + appCode: "lalu", + countryId: 86, + page: 1, + pageSize: 20, + regionId: 10, + statTz: "Asia/Shanghai" + }))); + expect(await screen.findByRole("button", { name: "查看 163001 平台发放金币来源明细" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "查看 163001 平台发放金币来源明细" })); + await waitFor(() => expect(fetchPlatformGrantRecords).toHaveBeenCalledWith(expect.objectContaining({ + countryId: 86, + page: 1, + pageSize: 20, + regionId: 10, + userId: "7001" + }))); + expect(await screen.findByText("任务奖励")).toBeInTheDocument(); + expect(screen.getByText("幸运礼物返奖")).toBeInTheDocument(); +}); diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js index d85f5b2..4da8b8a 100644 --- a/databi/src/data/createDashboardModel.js +++ b/databi/src/data/createDashboardModel.js @@ -438,6 +438,7 @@ function normalizeCountryRow(item, index, totalRecharge) { ? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate) : ratio(rechargeUsers, activeUsers), recharge_users: rechargeUsers, + region_id: numberValue(item.region_id ?? item.regionId), salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"), salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"), share: recharge / totalRecharge, diff --git a/databi/src/styles/report.css b/databi/src/styles/report.css index cc56491..b7937dc 100644 --- a/databi/src/styles/report.css +++ b/databi/src/styles/report.css @@ -611,6 +611,39 @@ font-style: normal; } +.report-link-button { + display: inline-flex; + max-width: 100%; + min-width: 0; + align-items: center; + justify-content: flex-end; + padding: 0; + border: 0; + background: transparent; + color: #126fb2; + cursor: pointer; + font: inherit; + font-weight: 780; + text-align: inherit; +} + +.report-link-button:hover { + color: #075d99; + text-decoration: underline; + text-underline-offset: 3px; +} + +.report-link-button:focus-visible { + border-radius: 4px; + outline: 2px solid #1688d9; + outline-offset: 2px; +} + +.report-link-button--left { + justify-content: flex-start; + text-align: left; +} + .report-country-cell { display: inline-flex; max-width: 220px; @@ -711,6 +744,194 @@ color: #8a98aa; } +.report-modal-backdrop { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 28px; + background: rgba(15, 23, 42, 0.28); +} + +.report-modal { + display: grid; + width: min(960px, 100%); + max-height: min(760px, calc(100vh - 56px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid #d8e5f0; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 20px 56px rgba(15, 23, 42, 0.22); +} + +.report-modal-head { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid #edf2f7; +} + +.report-modal-head h2 { + min-width: 0; + margin: 0; + overflow: hidden; + color: #172033; + font-size: 16px; + font-weight: 780; + text-overflow: ellipsis; + white-space: nowrap; +} + +.report-modal-close, +.report-pagination button { + height: 30px; + padding: 0 12px; + border: 1px solid #c7d7e7; + border-radius: 8px; + background: #ffffff; + color: #126fb2; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 760; +} + +.report-modal-close:hover, +.report-pagination button:hover:not(:disabled) { + border-color: #1688d9; + background: #eef7ff; +} + +.report-pagination button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.report-modal-body { + display: grid; + min-height: 0; + gap: 12px; + padding: 14px 16px 16px; + overflow: auto; +} + +.report-modal-table-wrap { + min-width: 0; + overflow: auto; + border: 1px solid #e4ebf3; + border-radius: 8px; +} + +.report-modal-table { + width: 100%; + min-width: 640px; + border-collapse: collapse; + background: #ffffff; +} + +.report-modal-table th, +.report-modal-table td { + height: 38px; + padding: 0 12px; + border-bottom: 1px solid #edf2f7; + color: #34445a; + font-size: 13px; + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.report-modal-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f8fafc; + color: #65748a; + font-size: 12px; + font-weight: 760; +} + +.report-modal-table th:first-child, +.report-modal-table td:first-child { + text-align: left; +} + +.report-modal-table tbody tr:hover td { + background: #f7fbff; +} + +.report-user-cell { + display: inline-flex; + max-width: 260px; + min-width: 0; + align-items: center; + gap: 8px; +} + +.report-user-cell img { + width: 24px; + height: 24px; + flex: 0 0 auto; + border-radius: 50%; + object-fit: cover; +} + +.report-user-cell > span { + display: grid; + min-width: 0; + gap: 1px; +} + +.report-user-cell b, +.report-user-cell small, +.report-event-id { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.report-user-cell b { + color: #172033; + font-weight: 780; +} + +.report-user-cell small { + color: #8a98aa; + font-size: 11px; + font-weight: 700; +} + +.report-event-id { + display: inline-block; + max-width: 240px; + vertical-align: bottom; +} + +.report-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #65748a; + font-size: 13px; +} + +.report-pagination > div { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.report-pagination b { + color: #34445a; + font-weight: 760; +} + .report-pair-cell { display: inline-flex; min-width: 118px;