diff --git a/databi/index.html b/databi/index.html index 44560e2..c1b279a 100644 --- a/databi/index.html +++ b/databi/index.html @@ -3,7 +3,7 @@ - HYApp 数据大屏 + HYApp 数据中心
diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index 59d728c..dbfb1e5 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -7,6 +7,7 @@ import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx"; import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx"; import { MetricCard } from "./components/MetricCard.jsx"; import { MetricTrendModal } from "./components/MetricTrendModal.jsx"; +import { ReportOverview } from "./components/ReportOverview.jsx"; import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx"; import { SelfGameScreen } from "./components/SelfGameScreen.jsx"; import { createDashboardModel } from "./data/createDashboardModel.js"; @@ -26,6 +27,7 @@ export function DatabiApp() { const [countryId, setCountryId] = useState(0); const [regionId, setRegionId] = useState("all"); const [appCode, setAppCode] = useState(() => getCurrentAppCode()); + const [viewMode, setViewMode] = useState("report"); const [activeScreen, setActiveScreen] = useState("overview"); const [gameId, setGameId] = useState("all"); const [filterOptions, setFilterOptions] = useState(initialFilterOptions); @@ -192,13 +194,18 @@ export function DatabiApp() { const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]); const showSkeleton = loading && !overview; + const isReportView = viewMode === "report"; const openLuckyGiftPools = useCallback(() => { setPoolModalOpen(true); }, []); return ( -
-
+
+
{activeScreen === "selfGames" ? ( + ) : isReportView ? ( + ) : ( - <> -
- {model.kpis.map((item) => ( - setTrendModalItem(item)} /> - ))} -
- -
- {model.businessKpis.map((item) => ( - - ))} -
- -
- {model.robotGiftKpis.map((item) => ( - setTrendModalItem(item)} /> - ))} -
- -
- - - -
- + )}
- {activeScreen === "overview" ? ( + {activeScreen === "overview" && !isReportView ? ( <> - {poolModalOpen ? setPoolModalOpen(false)} /> : null} - {trendModalItem ? setTrendModalItem(null)} /> : null} ) : null} + {activeScreen === "overview" && poolModalOpen ? setPoolModalOpen(false)} /> : null} + {activeScreen === "overview" && trendModalItem ? setTrendModalItem(null)} /> : null}
); } +function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools }) { + return ( + <> +
+ {model.kpis.map((item) => ( + onMetricTrend(item)} /> + ))} +
+ +
+ {model.businessKpis.map((item) => ( + + ))} +
+ +
+ {model.robotGiftKpis.map((item) => ( + onMetricTrend(item)} /> + ))} +
+ +
+ + + +
+ + ); +} + function resolveSelectedAppCode(options, current) { const normalized = String(current || "").trim().toLowerCase(); if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) { @@ -317,23 +344,26 @@ function countryBelongsToRegion(country, regionId) { } function enrichCountryBreakdown(data, countries) { - if (!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) { + if ((!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) && + (!Array.isArray(data?.daily_country_breakdown) || !data.daily_country_breakdown.length)) { return data; } const countryById = new Map(countries.map((country) => [Number(country.id), country])); + const enrichRows = (rows) => rows.map((row) => { + const country = countryById.get(Number(row.country_id ?? row.countryId)); + if (!country) { + return row; + } + return { + ...row, + country: row.country || country.name || country.label, + country_code: row.country_code || country.countryCode, + flag: row.flag || country.flag + }; + }); return { ...data, - country_breakdown: data.country_breakdown.map((row) => { - const country = countryById.get(Number(row.country_id ?? row.countryId)); - if (!country) { - return row; - } - return { - ...row, - country: row.country || country.name || country.label, - country_code: row.country_code || country.countryCode, - flag: row.flag || country.flag - }; - }) + country_breakdown: Array.isArray(data.country_breakdown) ? enrichRows(data.country_breakdown) : data.country_breakdown, + daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown }; } diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 2e6927b..65858bc 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -42,7 +42,8 @@ test("keeps current data visible during scheduled refresh", async () => { await flushEffects(); expect(screen.getAllByText("$1").length).toBeGreaterThan(0); - expect(document.querySelector(".metric-card.is-loading")).toBeNull(); + expect(screen.getByLabelText("数据报表")).toBeTruthy(); + expect(document.querySelector(".report-metric-card.is-loading")).toBeNull(); await act(async () => { vi.advanceTimersByTime(60_000); @@ -50,7 +51,7 @@ test("keeps current data visible during scheduled refresh", async () => { expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3); expect(screen.getAllByText("$1").length).toBeGreaterThan(0); - expect(document.querySelector(".metric-card.is-loading")).toBeNull(); + expect(document.querySelector(".report-metric-card.is-loading")).toBeNull(); }); test("queries China natural day statistics when display timezone is Beijing", async () => { @@ -69,6 +70,35 @@ test("queries China natural day statistics when display timezone is Beijing", as })); }); +test("renders coin seller stock and outbound coin columns in report view", async () => { + fetchStatisticsOverview.mockResolvedValue({ + coin_seller_stock_coin: 400_000, + coin_seller_transfer_coin: 160_000, + salary_transfer_coin: 88_000, + country_breakdown: [ + { + coin_seller_stock_coin: 400_000, + coin_seller_transfer_coin: 160_000, + country: "巴西", + country_id: 86, + salary_transfer_coin: 88_000 + } + ], + updated_at_ms: 1 + }); + + render(); + + await flushEffects(); + + expect(screen.getByRole("columnheader", { name: "币商充值金币" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "币商出货金币" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "工资兑换金币" })).toBeTruthy(); + expect(screen.getAllByText("400,000").length).toBeGreaterThan(0); + expect(screen.getAllByText("160,000").length).toBeGreaterThan(0); + expect(screen.getAllByText("88,000").length).toBeGreaterThan(0); +}); + test("loads self game statistics from the big screen switch", async () => { fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 }); fetchSelfGameStatisticsOverview.mockResolvedValue({ @@ -103,6 +133,9 @@ test("renders real-room robot gift cards in a separate row", async () => { render(); await flushEffects(); + await act(async () => { + screen.getByRole("tab", { name: "大屏 BI" }).click(); + }); const businessSection = screen.getByLabelText("业务指标"); const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标"); diff --git a/databi/src/charts/options/createReportRevenueOption.js b/databi/src/charts/options/createReportRevenueOption.js new file mode 100644 index 0000000..d6797e5 --- /dev/null +++ b/databi/src/charts/options/createReportRevenueOption.js @@ -0,0 +1,72 @@ +import { compactMoneyAxis, moneyMajor } from "../../utils/format.js"; +import { niceAxis } from "./createMetricTrendOption.js"; + +export function createReportRevenueOption(series) { + const labels = series.map((item) => item.label); + const channelValues = series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller)); + const totalValues = series.map((item) => moneyMajor(item.lineTotal ?? item.total)); + const channelAxis = niceAxis(channelValues); + const totalAxis = niceAxis(totalValues); + + return { + animationDuration: 520, + color: ["#1688d9", "#18a389", "#6377e8", "#172033"], + grid: { bottom: 30, left: 54, right: 54, top: 42 }, + legend: [ + { data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 18, textStyle: { color: "#5f6f86", fontSize: 12 }, top: 0 }, + { data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#172033", fontSize: 12 }, top: 0 } + ], + tooltip: { + backgroundColor: "#ffffff", + borderColor: "#d8e2ed", + extraCssText: "box-shadow: 0 14px 34px rgba(15, 23, 42, .14);", + textStyle: { color: "#172033" }, + trigger: "axis" + }, + xAxis: { + axisLabel: { color: "#6b7a90", margin: 10 }, + axisLine: { lineStyle: { color: "#dbe5ef" } }, + axisTick: { show: false }, + boundaryGap: true, + data: labels, + type: "category" + }, + yAxis: [ + { + axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis }, + axisTick: { show: false }, + interval: channelAxis.interval, + max: channelAxis.max, + min: 0, + splitLine: { lineStyle: { color: "rgba(104, 123, 148, 0.16)", type: "dashed" } }, + type: "value" + }, + { + axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis }, + axisTick: { show: false }, + interval: totalAxis.interval, + max: totalAxis.max, + min: 0, + splitLine: { show: false }, + type: "value" + } + ], + series: [ + { barGap: "-100%", barWidth: 26, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" }, + { barWidth: 26, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" }, + { barWidth: 26, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" }, + { + data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)), + lineStyle: { color: "#172033", shadowBlur: 8, shadowColor: "rgba(23, 32, 51, .14)", width: 3 }, + name: "总计 (USD)", + smooth: 0.42, + symbol: "circle", + symbolSize: 7, + itemStyle: { borderColor: "#ffffff", borderWidth: 2, color: "#172033" }, + type: "line", + yAxisIndex: 1, + z: 4 + } + ] + }; +} diff --git a/databi/src/components/DatabiHeader.jsx b/databi/src/components/DatabiHeader.jsx index 4286d35..a061c65 100644 --- a/databi/src/components/DatabiHeader.jsx +++ b/databi/src/components/DatabiHeader.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined"; import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; import PublicOutlined from "@mui/icons-material/PublicOutlined"; +import StorageOutlined from "@mui/icons-material/StorageOutlined"; import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js"; const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }]; @@ -33,11 +34,13 @@ export function DatabiHeader({ onRangeChange, onRegionChange, onTimeZoneChange, + onViewModeChange, range, regionId, regionOptions = defaultRegionOptions, timeZone, - timeZoneOptions = TIME_ZONE_OPTIONS + timeZoneOptions = TIME_ZONE_OPTIONS, + viewMode = "report" }) { const [openControl, setOpenControl] = useState(""); const headerRef = useRef(null); @@ -77,10 +80,22 @@ export function DatabiHeader({ return (
-
-