diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index e8b1c0a..a0df9ce 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { fetchCountryStatisticsBreakdown, fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js"; +import { fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js"; import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx"; import { DatabiHeader } from "./components/DatabiHeader.jsx"; import { FunnelPanel } from "./components/FunnelPanel.jsx"; 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 { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx"; import { createDashboardModel } from "./data/createDashboardModel.js"; import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js"; @@ -30,6 +31,7 @@ export function DatabiApp() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [poolModalOpen, setPoolModalOpen] = useState(false); + const [trendModalItem, setTrendModalItem] = useState(null); const overviewRequestIdRef = useRef(0); const overviewRef = useRef(null); const scopedCountries = useMemo(() => { @@ -88,26 +90,17 @@ export function DatabiApp() { try { const startMs = rangeStartMs(range, timeZone); const endMs = rangeEndMs(range, timeZone); + const seriesRange = sameRange(range, todayRange(timeZone)) ? lastDaysRange(7, timeZone) : range; const data = await fetchStatisticsOverview({ appCode, countryId, endMs, regionId, + seriesEndMs: rangeEndMs(seriesRange, timeZone), + seriesStartMs: rangeStartMs(seriesRange, timeZone), startMs }); - let nextOverview = data || {}; - if (Number(countryId) === 0 && scopedCountries.length && !hasCountryBreakdown(nextOverview)) { - const countryBreakdown = await fetchCountryStatisticsBreakdown({ - appCode, - countries: scopedCountries, - endMs, - regionId, - startMs - }); - if (countryBreakdown.length) { - nextOverview = { ...nextOverview, country_breakdown: countryBreakdown }; - } - } + const nextOverview = enrichCountryBreakdown(data || {}, scopedCountries); if (requestId === overviewRequestIdRef.current) { setOverview(nextOverview); } @@ -172,7 +165,7 @@ export function DatabiApp() {
{model.kpis.map((item) => ( - + setTrendModalItem(item)} /> ))}
@@ -196,6 +189,7 @@ export function DatabiApp() { {poolModalOpen ? setPoolModalOpen(false)} /> : null} + {trendModalItem ? setTrendModalItem(null)} /> : null} ); } @@ -208,10 +202,6 @@ function resolveSelectedAppCode(options, current) { return String(options[0]?.code || options[0]?.value || normalized || "lalu").trim().toLowerCase(); } -function hasCountryBreakdown(data) { - return Array.isArray(data?.country_breakdown) && data.country_breakdown.length > 0; -} - function resolveRangePresetValue(range, timeZone) { const presets = [ { range: todayRange(timeZone), value: "today" }, @@ -249,3 +239,25 @@ function countryBelongsToRegion(country, regionId) { } return String(country.regionId || "") === String(regionId); } + +function enrichCountryBreakdown(data, countries) { + if (!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) { + return data; + } + const countryById = new Map(countries.map((country) => [Number(country.id), country])); + 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 + }; + }) + }; +} diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 6300f21..0854504 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -1,10 +1,9 @@ import { act, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { DatabiApp } from "./DatabiApp.jsx"; -import { fetchCountryStatisticsBreakdown, fetchFilterOptions, fetchStatisticsOverview } from "./api.js"; +import { fetchFilterOptions, fetchStatisticsOverview } from "./api.js"; vi.mock("./api.js", () => ({ - fetchCountryStatisticsBreakdown: vi.fn(), fetchFilterOptions: vi.fn(), fetchStatisticsOverview: vi.fn(), getCurrentAppCode: vi.fn(() => "lalu"), @@ -23,7 +22,6 @@ beforeEach(() => { countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }], regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }] }); - fetchCountryStatisticsBreakdown.mockResolvedValue([]); }); afterEach(() => { @@ -41,7 +39,7 @@ test("keeps current data visible during scheduled refresh", async () => { render(); await flushEffects(); - expect(screen.getByText("$1")).toBeInTheDocument(); + expect(screen.getAllByText("$1").length).toBeGreaterThan(0); expect(document.querySelector(".metric-card.is-loading")).toBeNull(); await act(async () => { @@ -49,7 +47,7 @@ test("keeps current data visible during scheduled refresh", async () => { }); expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3); - expect(screen.getByText("$1")).toBeInTheDocument(); + expect(screen.getAllByText("$1").length).toBeGreaterThan(0); expect(document.querySelector(".metric-card.is-loading")).toBeNull(); }); diff --git a/databi/src/api.js b/databi/src/api.js index a3947a3..e40b24d 100644 --- a/databi/src/api.js +++ b/databi/src/api.js @@ -16,7 +16,7 @@ export function setCurrentAppCode(value) { return appCode; } -export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, startMs }) { +export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs }) { const query = { app_code: normalizeAppCode(appCode) || "lalu" }; if (startMs) { query.start_ms = String(startMs); @@ -24,6 +24,12 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio if (endMs) { query.end_ms = String(endMs); } + if (seriesStartMs) { + query.series_start_ms = String(seriesStartMs); + } + if (seriesEndMs) { + query.series_end_ms = String(seriesEndMs); + } if (countryId) { query.country_id = String(countryId); } @@ -34,23 +40,6 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio return fetchDatabiData("/v1/statistics/overview", { appCode, query }); } -export async function fetchCountryStatisticsBreakdown({ appCode, countries = [], endMs, regionId, startMs }) { - return mapWithConcurrency( - countries.filter((country) => Number(country.id) > 0), - 8, - async (country) => { - const data = await fetchStatisticsOverview({ - appCode, - countryId: country.id, - endMs, - regionId, - startMs - }); - return normalizeCountryStatisticsRow(data, country); - } - ); -} - export async function fetchFilterOptions({ appCode } = {}) { const [apps, regions, countries] = await Promise.all([ fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }), @@ -228,30 +217,3 @@ function normalizeCountryOption(country, countryRegionIds) { value: id }; } - -function normalizeCountryStatisticsRow(data, country) { - const source = data || {}; - const row = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown[0] : source; - return { - ...row, - country: stripCountryFlagPrefix(row.country || row.country_name || country.name || country.label), - country_code: row.country_code || row.countryCode || country.countryCode, - country_id: row.country_id || row.countryId || country.id - }; -} - -async function mapWithConcurrency(items, limit, callback) { - const results = []; - let index = 0; - - async function worker() { - while (index < items.length) { - const currentIndex = index; - index += 1; - results[currentIndex] = await callback(items[currentIndex], currentIndex); - } - } - - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); - return results.filter(Boolean); -} diff --git a/databi/src/charts/options/createGeoOption.js b/databi/src/charts/options/createGeoOption.js index 382d241..5ecf92a 100644 --- a/databi/src/charts/options/createGeoOption.js +++ b/databi/src/charts/options/createGeoOption.js @@ -3,8 +3,10 @@ import { formatMoney } from "../../utils/format.js"; export function createGeoOption(items) { const points = items .filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2) - .map((item) => [...item.geo_point, item.recharge_usd_minor, item.country]); + .filter((item) => Number(item.recharge_usd_minor) > 0) + .map((item) => [...item.geo_point, Number(item.recharge_usd_minor) || 0, item.country]); const max = Math.max(...points.map((item) => item[2]), 1); + const min = Math.min(...points.map((item) => item[2]), max); return { animationDuration: 650, geo: { @@ -27,7 +29,11 @@ export function createGeoOption(items) { { coordinateSystem: "geo", data: points, - itemStyle: { color: "rgba(40, 228, 245, 0.18)", shadowBlur: 26, shadowColor: "#23e4ff" }, + itemStyle: { + color: ({ value }) => colorByValue(value[2], min, max, 0.2), + shadowBlur: 28, + shadowColor: "rgba(39, 228, 245, 0.6)" + }, silent: true, symbolSize: (value) => 14 + (value[2] / max) * 32, type: "scatter", @@ -36,7 +42,13 @@ export function createGeoOption(items) { { coordinateSystem: "geo", data: points, - itemStyle: { borderColor: "#9ffcff", borderWidth: 1.5, color: "#28e4f5", shadowBlur: 22, shadowColor: "#23e4ff" }, + itemStyle: { + borderColor: "rgba(232, 255, 255, 0.9)", + borderWidth: 1.5, + color: ({ value }) => colorByValue(value[2], min, max, 1), + shadowBlur: 24, + shadowColor: "rgba(39, 228, 245, 0.74)" + }, rippleEffect: { brushType: "stroke", number: 3, scale: 3.6 }, symbolSize: (value) => 6 + (value[2] / max) * 15, type: "effectScatter", @@ -51,3 +63,22 @@ export function createGeoOption(items) { } }; } + +function colorByValue(value, min, max, alpha = 1) { + const strength = normalizeStrength(value, min, max); + const low = [66, 129, 255]; + const mid = [39, 228, 245]; + const high = [36, 215, 159]; + const from = strength < 0.55 ? low : mid; + const to = strength < 0.55 ? mid : high; + const local = strength < 0.55 ? strength / 0.55 : (strength - 0.55) / 0.45; + const [red, green, blue] = from.map((channel, index) => Math.round(channel + (to[index] - channel) * local)); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function normalizeStrength(value, min, max) { + if (max <= min) { + return 1; + } + return Math.min(Math.max((Math.sqrt(value) - Math.sqrt(min)) / (Math.sqrt(max) - Math.sqrt(min)), 0), 1); +} diff --git a/databi/src/charts/options/createMetricTrendOption.js b/databi/src/charts/options/createMetricTrendOption.js new file mode 100644 index 0000000..61062cc --- /dev/null +++ b/databi/src/charts/options/createMetricTrendOption.js @@ -0,0 +1,62 @@ +import { compactMoneyAxis, formatCoin } from "../../utils/format.js"; + +export function createMetricTrendOption(item) { + const lines = Array.isArray(item?.trend) ? item.trend.filter((line) => Array.isArray(line.data) && line.data.length) : []; + const labels = lines[0]?.data.map((point) => point.label) || []; + const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0)); + const axis = niceAxis(values); + return { + animationDuration: 650, + color: ["#27e4f5", "#24d79f", "#4f8cff"], + grid: { bottom: 32, left: 58, right: 24, top: 42 }, + legend: { icon: "roundRect", itemGap: 18, itemHeight: 8, itemWidth: 14, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 0 }, + tooltip: { + backgroundColor: "#08243a", + borderColor: "#1a5f82", + formatter: (params) => { + const rows = Array.isArray(params) ? params : [params]; + return rows.map((row, index) => `${index === 0 ? `${row.axisValue}
` : ""}${row.marker}${row.seriesName}: ${formatTrendValue(row.data, item.trendFormat)}`).join("
"); + }, + textStyle: { color: "#e9f6ff" }, + trigger: "axis" + }, + xAxis: { axisLabel: { color: "#90a8c4", margin: 10 }, axisLine: { lineStyle: { color: "#1a3a56" } }, axisTick: { show: false }, boundaryGap: false, data: labels, type: "category" }, + yAxis: { axisLabel: { color: "#90a8c4", formatter: (value) => formatTrendValue(value, item.trendFormat, true) }, axisTick: { show: false }, interval: axis.interval, max: axis.max, min: 0, splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" }, + series: lines.map((line) => ({ + areaStyle: { color: "rgba(39, 228, 245, 0.08)" }, + data: line.data.map((point) => point.value), + lineStyle: { width: 3 }, + name: line.name, + smooth: 0.38, + symbol: "circle", + symbolSize: 7, + type: "line" + })) + }; +} + +export function niceAxis(values) { + const maxValue = Math.max(...values, 0); + if (maxValue <= 0) { + return { interval: 1, max: 4 }; + } + const rawStep = maxValue / 4; + const magnitude = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / magnitude; + const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude; + return { + interval: step, + max: step * Math.ceil(maxValue / step) + }; +} + +function formatTrendValue(value, format, axis = false) { + const numeric = Number(value) || 0; + if (format === "money") { + return axis ? compactMoneyAxis(numeric) : `$${numeric.toLocaleString("en-US", { maximumFractionDigits: 2 })}`; + } + if (format === "coin") { + return axis ? compactMoneyAxis(numeric) : formatCoin(numeric); + } + return axis ? compactMoneyAxis(numeric) : numeric.toLocaleString("en-US"); +} diff --git a/databi/src/charts/options/createRevenueOption.js b/databi/src/charts/options/createRevenueOption.js index 2fd46c4..572cf32 100644 --- a/databi/src/charts/options/createRevenueOption.js +++ b/databi/src/charts/options/createRevenueOption.js @@ -1,9 +1,12 @@ import { compactMoneyAxis, moneyMajor } from "../../utils/format.js"; +import { niceAxis } from "./createMetricTrendOption.js"; export function createRevenueOption(series) { const labels = series.map((item) => item.label); - const leftMax = Math.max(2_000_000, ...series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller))); - const rightMax = Math.max(8_000_000, ...series.map((item) => moneyMajor(item.lineTotal ?? item.total))); + const leftValues = series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller)); + const rightValues = series.map((item) => moneyMajor(item.lineTotal ?? item.total)); + const leftAxis = niceAxis(leftValues); + const rightAxis = niceAxis(rightValues); return { animationDuration: 650, color: ["#1bd8f2", "#20c9aa", "#4f8cff", "#f2f6ff"], @@ -15,8 +18,8 @@ export function createRevenueOption(series) { tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", extraCssText: "box-shadow: 0 10px 26px rgba(0,0,0,.32);", textStyle: { color: "#e9f6ff" }, trigger: "axis" }, xAxis: { axisLabel: { color: "#90a8c4", margin: 10 }, axisLine: { lineStyle: { color: "#1a3a56" } }, axisTick: { show: false }, boundaryGap: true, data: labels, type: "category" }, yAxis: [ - { axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: leftMax <= 2_000_000 ? 500_000 : undefined, max: roundAxisMax(leftMax), splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" }, - { axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: rightMax <= 8_000_000 ? 2_000_000 : undefined, max: roundAxisMax(rightMax), splitLine: { show: false }, type: "value" } + { axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: leftAxis.interval, max: leftAxis.max, min: 0, splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" }, + { axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: rightAxis.interval, max: rightAxis.max, min: 0, splitLine: { show: false }, type: "value" } ], series: [ { barGap: "-100%", barWidth: 28, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" }, @@ -37,14 +40,3 @@ export function createRevenueOption(series) { ] }; } - -function roundAxisMax(value) { - if (value <= 2_000_000) { - return 2_000_000; - } - if (value <= 8_000_000) { - return 8_000_000; - } - const step = value > 10_000_000 ? 5_000_000 : 1_000_000; - return Math.ceil(value / step) * step; -} diff --git a/databi/src/components/CountryPerformanceTable.jsx b/databi/src/components/CountryPerformanceTable.jsx index 4e290da..f0f2e1f 100644 --- a/databi/src/components/CountryPerformanceTable.jsx +++ b/databi/src/components/CountryPerformanceTable.jsx @@ -205,7 +205,7 @@ function GiftTable({ items, loading, onSort, sortState }) { function GameTable({ items, loading, onSort, sortState }) { const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1; const gameSortAccessors = useMemo(() => ({ - game_id: (item) => item.game_id, + game_id: (item) => item.game_label || item.game_id, profit_rate: (item) => item.profit_rate, share: (item) => item.turnover_coin / total, turnover_coin: (item) => item.turnover_coin @@ -228,7 +228,7 @@ function GameTable({ items, loading, onSort, sortState }) { {!loading && sortedItems.map((item, index) => ( {index + 1} - {item.game_id} + {item.game_label || item.game_id} {formatCoin(item.turnover_coin)} {formatPercent(item.profit_rate)} {formatPercent(item.turnover_coin / total)} diff --git a/databi/src/components/FunnelPanel.jsx b/databi/src/components/FunnelPanel.jsx index 5b272d2..1403eb9 100644 --- a/databi/src/components/FunnelPanel.jsx +++ b/databi/src/components/FunnelPanel.jsx @@ -8,7 +8,7 @@ export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
{loading - ? Array.from({ length: 4 }).map((_, index) => ) + ? Array.from({ length: 3 }).map((_, index) => ) : funnel.map((item, index) => (
diff --git a/databi/src/components/GameEconomyPanel.jsx b/databi/src/components/GameEconomyPanel.jsx index aa93140..4c3139b 100644 --- a/databi/src/components/GameEconomyPanel.jsx +++ b/databi/src/components/GameEconomyPanel.jsx @@ -24,7 +24,7 @@ function GameRankTable({ items }) { {items.map((item, index) => (
{index + 1} - {item.game_id || "-"} + {item.game_label || item.game_id || "-"}
diff --git a/databi/src/components/GlobalOverviewPanel.jsx b/databi/src/components/GlobalOverviewPanel.jsx index 99063ed..0708186 100644 --- a/databi/src/components/GlobalOverviewPanel.jsx +++ b/databi/src/components/GlobalOverviewPanel.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { EChart } from "../charts/EChart.jsx"; import { createGeoOption } from "../charts/options/createGeoOption.js"; import { formatMoney, moneyMajor } from "../utils/format.js"; @@ -6,9 +6,12 @@ import { GlobeMap } from "./GlobeMap.jsx"; import { Panel } from "./Panel.jsx"; import { RankList } from "./RankList.jsx"; -export function GlobalOverviewPanel({ countryBreakdown, loading = false, topCountries }) { +const MODAL_TRANSITION_MS = 180; + +export function GlobalOverviewPanel({ countryBreakdown, loading = false }) { const [globeOpen, setGlobeOpen] = useState(false); - const rankedCountries = useMemo(() => (topCountries.length ? topCountries : countryBreakdown.slice(0, 4)), [countryBreakdown, topCountries]); + const rechargeCountries = useMemo(() => countryBreakdown.filter(hasRecharge), [countryBreakdown]); + const rankedCountries = useMemo(() => rechargeCountries.slice(0, 4), [rechargeCountries]); return ( <> @@ -25,14 +28,14 @@ export function GlobalOverviewPanel({ countryBreakdown, loading = false, topCoun
-
)} - {globeOpen ? setGlobeOpen(false)} /> : null} + {globeOpen ? setGlobeOpen(false)} /> : null} ); } @@ -55,25 +58,51 @@ function GlobalOverviewSkeleton() { } function CountryGlobeDialog({ items, onClose }) { + const [closing, setClosing] = useState(false); + const closeTimerRef = useRef(null); + const requestClose = useCallback(() => { + if (closing) { + return; + } + setClosing(true); + closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS); + }, [closing, onClose]); + + useEffect(() => () => { + if (closeTimerRef.current) { + window.clearTimeout(closeTimerRef.current); + } + }, []); + useEffect(() => { const closeOnEscape = (event) => { if (event.key === "Escape") { - onClose(); + requestClose(); } }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [onClose]); + }, [requestClose]); return ( -
(event.target === event.currentTarget ? onClose() : null)}> +
{ + if (event.target === event.currentTarget) { + requestClose(); + } + }} + >

全部国家

{items.length} 个国家 / 按充值规模展示
- +
@@ -90,3 +119,7 @@ function formatRankMoney(value) { } return formatMoney(value); } + +function hasRecharge(item) { + return Number(item?.recharge_usd_minor) > 0; +} diff --git a/databi/src/components/GlobeMap.jsx b/databi/src/components/GlobeMap.jsx index e5a8a16..55d150a 100644 --- a/databi/src/components/GlobeMap.jsx +++ b/databi/src/components/GlobeMap.jsx @@ -16,6 +16,20 @@ export function GlobeMap({ items }) { const [rotation, setRotation] = useState(INITIAL_ROTATION); const [size, setSize] = useState({ height: 0, width: 0 }); const points = useMemo(() => normalizePoints(items), [items]); + const featuredPoint = points[0] || null; + const featuredTooltip = useMemo(() => pointTooltip(featuredPoint, size, rotation), [featuredPoint, rotation, size]); + const visibleTooltip = dragging ? null : hovered || featuredTooltip; + + useEffect(() => { + if (!featuredPoint?.point) { + return; + } + setHovered(null); + setRotation({ + lat: clamp(featuredPoint.point[1], -MAX_LATITUDE, MAX_LATITUDE), + lon: normalizeLongitude(featuredPoint.point[0]) + }); + }, [featuredPoint]); useEffect(() => { const element = wrapRef.current; @@ -95,12 +109,12 @@ export function GlobeMap({ items }) { role="img" > - {hovered ? ( -
- {hovered.item.country} - 充值 {formatMoney(hovered.item.value)} - 活跃 {formatNumber(hovered.item.activeUsers)} - 付费 {formatNumber(hovered.item.paidUsers)} + {visibleTooltip ? ( +
+ {visibleTooltip.item.country} + 充值 {formatMoney(visibleTooltip.item.value)} + 活跃 {formatNumber(visibleTooltip.item.activeUsers)} + 付费 {formatNumber(visibleTooltip.item.paidUsers)}
) : null}
@@ -237,34 +251,35 @@ function drawRoutes(context, points, rotation, cx, cy, radius) { function drawPoints(context, points, rotation, cx, cy, radius) { const max = Math.max(...points.map((item) => item.value), 1); + const min = Math.min(...points.map((item) => item.value), max); for (const item of points) { const point = project(item.point, rotation, cx, cy, radius); if (!point) { continue; } - const strength = Math.sqrt(item.value / max); + const strength = normalizeStrength(item.value, min, max); const halo = 10 + strength * 24; const core = 3.2 + strength * 5.4; const glow = context.createRadialGradient(point.x, point.y, 1, point.x, point.y, halo); - glow.addColorStop(0, "rgba(129, 252, 255, 0.86)"); - glow.addColorStop(0.2, "rgba(39, 228, 245, 0.42)"); - glow.addColorStop(1, "rgba(39, 228, 245, 0)"); + glow.addColorStop(0, colorForStrength(strength, 0.92)); + glow.addColorStop(0.25, colorForStrength(strength, 0.42)); + glow.addColorStop(1, colorForStrength(strength, 0)); context.fillStyle = glow; context.beginPath(); context.arc(point.x, point.y, halo, 0, Math.PI * 2); context.fill(); - context.strokeStyle = "rgba(126, 252, 255, 0.42)"; + context.strokeStyle = colorForStrength(strength, 0.62); context.lineWidth = 1; context.beginPath(); context.arc(point.x, point.y, core * 2.2, 0, Math.PI * 2); context.stroke(); - context.fillStyle = "#35f0f3"; + context.fillStyle = colorForStrength(strength, 1); context.shadowBlur = 16; - context.shadowColor = "rgba(53, 240, 243, 0.78)"; + context.shadowColor = colorForStrength(strength, 0.82); context.beginPath(); context.arc(point.x, point.y, core, 0, Math.PI * 2); context.fill(); @@ -397,6 +412,7 @@ function project(coordinate, rotation, cx, cy, radius) { function normalizePoints(items = []) { return items .filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2) + .filter((item) => Number(item.recharge_usd_minor) > 0) .map((item) => ({ activeUsers: Number(item.active_users) || 0, country: item.country, @@ -407,6 +423,24 @@ function normalizePoints(items = []) { .sort((left, right) => right.value - left.value); } +function pointTooltip(item, size, rotation) { + if (!item || !size.width || !size.height) { + return null; + } + const cx = size.width / 2; + const cy = size.height / 2; + const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47)); + const projected = project(item.point, rotation, cx, cy, radius); + if (!projected) { + return null; + } + return { + item, + x: clamp(projected.x + 14, 8, size.width - 136), + y: clamp(projected.y - 76, 8, size.height - 96) + }; +} + function findHoveredPoint(event, element, size, rotation, points) { if (!element || !size.width || !size.height || !points.length) { return null; @@ -442,6 +476,24 @@ function findHoveredPoint(event, element, size, rotation, points) { }; } +function colorForStrength(strength, alpha = 1) { + const low = [66, 129, 255]; + const mid = [39, 228, 245]; + const high = [36, 215, 159]; + const from = strength < 0.55 ? low : mid; + const to = strength < 0.55 ? mid : high; + const local = strength < 0.55 ? strength / 0.55 : (strength - 0.55) / 0.45; + const [red, green, blue] = from.map((channel, index) => Math.round(channel + (to[index] - channel) * local)); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function normalizeStrength(value, min, max) { + if (max <= min) { + return 1; + } + return clamp((Math.sqrt(value) - Math.sqrt(min)) / (Math.sqrt(max) - Math.sqrt(min)), 0, 1); +} + function extractRings(features = []) { const rings = []; diff --git a/databi/src/components/LuckyGiftPoolModal.jsx b/databi/src/components/LuckyGiftPoolModal.jsx index d46649a..c4a1104 100644 --- a/databi/src/components/LuckyGiftPoolModal.jsx +++ b/databi/src/components/LuckyGiftPoolModal.jsx @@ -1,21 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import { formatCoin, formatPercent } from "../utils/format.js"; +const MODAL_TRANSITION_MS = 180; + export function LuckyGiftPoolModal({ items, onClose }) { + const [closing, setClosing] = useState(false); + const closeTimerRef = useRef(null); const totals = items.reduce((summary, item) => ({ payout: summary.payout + item.payout, profit: summary.profit + item.profit, turnover: summary.turnover + item.turnover }), { payout: 0, profit: 0, turnover: 0 }); + const requestClose = useCallback(() => { + if (closing) { + return; + } + setClosing(true); + closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS); + }, [closing, onClose]); + + useEffect(() => () => { + if (closeTimerRef.current) { + window.clearTimeout(closeTimerRef.current); + } + }, []); + + useEffect(() => { + const closeOnEscape = (event) => { + if (event.key === "Escape") { + requestClose(); + } + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [requestClose]); + return ( -
-
+
{ + if (event.target === event.currentTarget) { + requestClose(); + } + }} + > +

幸运礼物奖池明细

按 pool_id 汇总流水、返奖和利润
- +
diff --git a/databi/src/components/MetricCard.jsx b/databi/src/components/MetricCard.jsx index e98c3d5..96af163 100644 --- a/databi/src/components/MetricCard.jsx +++ b/databi/src/components/MetricCard.jsx @@ -27,19 +27,77 @@ export function MetricCard({ item, loading = false, onClick }) { } } : undefined} > - {Icon ?
: null}
+ {Icon ? : null} {item.label} {UnitIcon ? : null} {item.unit ? {item.unit} : null}
- {item.value} -
- {item.caption} - {item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"} -
+ {Array.isArray(item.subMetrics) && item.subMetrics.length ? : } +
); } + +function MetricMainValue({ item }) { + return ( + <> + {item.value} + + + ); +} + +function MetricSubValues({ items }) { + return ( +
+ {items.map((subItem) => ( +
+ {subItem.label} + {subItem.value} + +
+ ))} +
+ ); +} + +function MetricDelta({ compact = false, item }) { + return ( +
+ {item.caption} + {item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"} +
+ ); +} + +function MetricSparkline({ series }) { + const lines = Array.isArray(series) ? series.filter((item) => Array.isArray(item.data) && item.data.length > 1) : []; + if (!lines.length) { + return ; + } + const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0)); + const min = Math.min(...values); + const max = Math.max(...values); + return ( + + ); +} + +function sparklinePoints(points, min, max) { + const width = 100; + const height = 24; + const count = points.length; + return points.map((point, index) => { + const x = count <= 1 ? width : (index / (count - 1)) * width; + const normalized = max <= min ? 0.5 : ((Number(point.value) || 0) - min) / (max - min); + const y = height - normalized * 18 - 3; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }).join(" "); +} diff --git a/databi/src/components/MetricTrendModal.jsx b/databi/src/components/MetricTrendModal.jsx new file mode 100644 index 0000000..8265371 --- /dev/null +++ b/databi/src/components/MetricTrendModal.jsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { EChart } from "../charts/EChart.jsx"; +import { createMetricTrendOption } from "../charts/options/createMetricTrendOption.js"; + +const MODAL_TRANSITION_MS = 180; + +export function MetricTrendModal({ item, onClose }) { + const [closing, setClosing] = useState(false); + const closeTimerRef = useRef(null); + const requestClose = useCallback(() => { + if (closing) { + return; + } + setClosing(true); + closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS); + }, [closing, onClose]); + + useEffect(() => () => { + if (closeTimerRef.current) { + window.clearTimeout(closeTimerRef.current); + } + }, []); + + useEffect(() => { + const closeOnEscape = (event) => { + if (event.key === "Escape") { + requestClose(); + } + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [requestClose]); + + return ( +
{ + if (event.target === event.currentTarget) { + requestClose(); + } + }} + > +
+
+
+

{item.label}

+ 每日折线图 +
+ +
+
+ {hasTrend(item) ? :
暂无趋势数据
} +
+
+
+ ); +} + +function hasTrend(item) { + return Array.isArray(item?.trend) && item.trend.some((line) => Array.isArray(line.data) && line.data.length); +} diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js index badcbc9..3b0a2f9 100644 --- a/databi/src/data/createDashboardModel.js +++ b/databi/src/data/createDashboardModel.js @@ -8,7 +8,9 @@ export function createDashboardModel(overview, { appCode, countryId, range, time const source = overview || {}; const comparisonCaption = isTodayRange(range, timeZone) ? "与昨日相比" : "近 7 日"; const recharge = effectiveRechargeUSDMinor(source); + const userRecharge = effectiveUserRechargeUSDMinor(source); const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"); + const newPaidUsers = readNumber(source, "new_paid_users", "newPaidUsers"); const activeUsers = readNumber(source, "active_users", "activeUsers"); const paidUsers = readNumber(source, "paid_users", "paidUsers"); const rechargeUsers = readNumber(source, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"); @@ -25,25 +27,25 @@ export function createDashboardModel(overview, { appCode, countryId, range, time const gameTurnover = readNumber(source, "game_turnover", "gameTurnover"); const gameProfit = readNumber(source, "game_profit", "gameProfit"); const countryBreakdown = normalizeCountries(source, countryId); - const revenueSeries = normalizeRevenueSeries(source); + const dailySeries = normalizeDailySeries(source); + const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source); const payoutDistribution = normalizeDistribution(source); const gameRanking = normalizeGameRanking(source); return { appCode, businessKpis: [ - coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption), - coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption), - coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), - coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), - coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), - coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption), - coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption) + coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }), + coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商转账金币", "coin") }), + coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin") }), + coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin") }), + coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }), + coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_turnover", "游戏流水", "coin") }), + coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") }) ], countryBreakdown, funnel: [ - { name: "访客", rate: 1, value: newUsers }, - { name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers }, + { name: "活跃用户", rate: 1, value: activeUsers }, { name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers }, { name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers } ], @@ -56,12 +58,37 @@ export function createDashboardModel(overview, { appCode, countryId, range, time gameRanking, giftRanking: normalizeGiftRanking(source), kpis: [ - { caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) }, - { caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) }, - { caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) }, - { caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) }, - { caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) }, - { caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) } + { caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", trend: metricTrend(dailySeries, "recharge_usd_minor", "总充值", "money"), trendFormat: "money", value: formatMoneyFull(recharge) }, + { + icon: UserPlusIcon, + label: "用户充值 / 新用户充值", + subMetrics: [ + { caption: comparisonCaption, delta: readDelta(source, "user_recharge_delta_rate", "userRechargeDeltaRate", "recharge_delta_rate", "rechargeDeltaRate"), label: "用户充值", value: formatMoneyFull(userRecharge) }, + { caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), label: "新用户充值", value: formatMoneyFull(newUserRecharge) } + ], + trend: [ + ...metricTrend(dailySeries, "user_recharge_usd_minor", "用户充值", "money"), + ...metricTrend(dailySeries, "new_user_recharge_usd_minor", "新用户充值", "money") + ], + trendFormat: "money" + }, + { caption: comparisonCaption, delta: readDelta(source, "new_users_delta_rate", "newUsersDeltaRate"), icon: UserPlusIcon, label: "新增用户数", trend: metricTrend(dailySeries, "new_users", "新增用户数", "number"), trendFormat: "number", value: formatNumber(newUsers) }, + { caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", trend: metricTrend(dailySeries, "active_users", "活跃用户", "number"), trendFormat: "number", value: formatNumber(activeUsers) }, + { + icon: CrownIcon, + label: "付费用户 / 新增付费用户", + subMetrics: [ + { caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), label: "付费用户", value: formatNumber(paidUsers) }, + { caption: comparisonCaption, delta: readDelta(source, "new_paid_users_delta_rate", "newPaidUsersDeltaRate"), label: "新增付费用户", value: formatNumber(newPaidUsers) } + ], + trend: [ + ...metricTrend(dailySeries, "paid_users", "付费用户", "number"), + ...metricTrend(dailySeries, "new_paid_users", "新增付费用户", "number") + ], + trendFormat: "number" + }, + { caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", trend: metricTrend(dailySeries, "arpu_usd_minor", "ARPU", "money"), trendFormat: "money", value: formatMoney(arpu) }, + { caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", trend: metricTrend(dailySeries, "arppu_usd_minor", "ARPPU", "money"), trendFormat: "money", value: formatMoney(arppu) } ], luckyMetrics: [ { caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) }, @@ -140,10 +167,10 @@ function normalizeRevenueSeries(source) { } if (Array.isArray(source.daily_series) && source.daily_series.length) { return source.daily_series.map((item) => { - const coinSeller = 0; + const coinSeller = numberValue(item.coin_seller ?? item.coin_seller_recharge_usd_minor ?? item.coinSellerRechargeUsdMinor); const google = numberValue(item.google ?? item.google_recharge_usd_minor ?? item.googleRechargeUsdMinor); const mifapay = numberValue(item.mifapay ?? item.mifa_pay ?? item.mifapay_recharge_usd_minor ?? item.mifaPayRechargeUsdMinor); - const channelTotal = google + mifapay; + const channelTotal = google + mifapay + coinSeller; const total = Math.max(numberValue(item.total ?? item.recharge_usd_minor), channelTotal); return { coin_seller: coinSeller, @@ -155,7 +182,7 @@ function normalizeRevenueSeries(source) { }; }); } - const coinSeller = 0; + const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"); const google = numberValue(source.google_recharge_usd_minor ?? source.googleRechargeUsdMinor); const mifapay = numberValue(source.mifapay_recharge_usd_minor ?? source.mifaPayRechargeUsdMinor); const total = effectiveRechargeUSDMinor(source); @@ -192,7 +219,8 @@ function normalizeCountries(source, countryId) { function normalizeCountryRow(item, index, totalRecharge) { const code = item.country_code || item.countryCode || item.iso_code || item.isoCode; - const country = stripCountryFlagPrefix(item.country || item.country_name || code || `国家 ${index + 1}`); + const countryId = numberValue(item.country_id ?? item.countryId); + const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家")); const meta = resolveCountryMeta({ code, name: country }); const recharge = effectiveRechargeUSDMinor(item); return { @@ -220,10 +248,73 @@ function normalizeCountryRow(item, index, totalRecharge) { function effectiveRechargeUSDMinor(source) { const direct = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor"); const channelTotal = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor") + - readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor"); + readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor") + + readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"); return Math.max(direct, channelTotal); } +function effectiveUserRechargeUSDMinor(source) { + const google = readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor"); + const mifapay = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor"); + const channelUserRecharge = google + mifapay; + const direct = readNumber(source, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor"); + const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"); + return Math.max(channelUserRecharge, Math.max(0, direct - coinSeller)); +} + +function normalizeDailySeries(source) { + if (!Array.isArray(source?.daily_series) || !source.daily_series.length) { + return []; + } + return source.daily_series.map((item) => { + const google = readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google"); + const mifapay = readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay"); + const coinSeller = readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor", "coin_seller"); + const channelTotal = google + mifapay + coinSeller; + const recharge = Math.max(effectiveRechargeUSDMinor(item), channelTotal); + const userRecharge = Math.max(google + mifapay, Math.max(0, readNumber(item, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor") - coinSeller)); + const gameTurnover = readNumber(item, "game_turnover", "gameTurnover"); + const gameProfit = readNumber(item, "game_profit", "gameProfit"); + const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover"); + const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout"); + return { + active_users: readNumber(item, "active_users", "activeUsers"), + arppu_usd_minor: readNumber(item, "arppu_usd_minor", "arppuUsdMinor"), + arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"), + coin_seller: coinSeller, + coin_seller_recharge_usd_minor: coinSeller, + coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"), + game_profit: gameProfit, + game_turnover: gameTurnover, + gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"), + google, + label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"), + lineTotal: Math.max(readNumber(item, "line_total", "lineTotal", "trend_total", "trendTotal", "total"), recharge, channelTotal), + lucky_gift_payout: luckyGiftPayout, + lucky_gift_profit: readNumber(item, "lucky_gift_profit", "luckyGiftProfit") || (luckyGiftTurnover - luckyGiftPayout), + lucky_gift_turnover: luckyGiftTurnover, + mifapay, + new_paid_users: readNumber(item, "new_paid_users", "newPaidUsers"), + new_user_recharge_usd_minor: readNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"), + new_users: readNumber(item, "new_users", "newUsers"), + paid_users: readNumber(item, "paid_users", "paidUsers"), + recharge_usd_minor: recharge, + recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"), + stat_day: item.stat_day || item.statDay || item.day || item.label || "", + total: recharge, + user_recharge_usd_minor: userRecharge + }; + }); +} + +function metricTrend(series, key, name, format = "number") { + const data = series.map((item) => ({ + label: item.label || item.stat_day || "-", + value: format === "money" ? numberValue(item[key]) / 100 : numberValue(item[key]) + })); + return data.length ? [{ data, name }] : []; +} + function normalizeGiftRanking(source) { if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) { return source.gift_ranking.map((item) => ({ @@ -284,6 +375,8 @@ function normalizeGameRanking(source) { if (Array.isArray(source.game_ranking) && source.game_ranking.length) { return source.game_ranking.map((item) => ({ game_id: item.game_id || item.gameId || "-", + game_label: gameDisplayLabel(item), + game_name: item.game_name || item.gameName || item.name || "", platform_code: item.platform_code || item.platformCode || "", profit_rate: numberValue(item.profit_rate ?? item.profitRate), turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin) @@ -292,6 +385,15 @@ function normalizeGameRanking(source) { return []; } +function gameDisplayLabel(item) { + const id = String(item.game_id || item.gameId || "").trim(); + const name = String(item.game_name || item.gameName || item.name || "").trim(); + if (name && id && name !== id) { + return `${name}(${id})`; + } + return name || id || "-"; +} + function localizeSeriesLabel(label) { const match = String(label).match(/^May\s+(\d{1,2})$/i); if (match) { diff --git a/databi/src/data/createDashboardModel.test.js b/databi/src/data/createDashboardModel.test.js index 878fff6..66a6f51 100644 --- a/databi/src/data/createDashboardModel.test.js +++ b/databi/src/data/createDashboardModel.test.js @@ -22,7 +22,7 @@ test("uses lucky gift pool breakdown to aggregate business metrics", () => { expect(model.businessKpis.filter((item) => item.detailKey === "luckyGiftPools")).toHaveLength(3); }); -test("keeps coin seller transfer out of USD recharge and exposes coin metric", () => { +test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => { const model = createDashboardModel({ coin_seller_recharge_usd_minor: 200, coin_seller_transfer_coin: 160_000, @@ -47,10 +47,24 @@ test("keeps coin seller transfer out of USD recharge and exposes coin metric", ( recharge_usd_minor: 150 }, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } }); - expect(metricValue(model, "总充值")).toBe("$2"); + expect(metricValue(model, "总充值")).toBe("$4"); expect(metricValue(model, "币商转账金币")).toBe("160,000"); - expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 0, google: 150, lineTotal: 150, total: 150 })); - expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 150 })); + expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 })); + expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 350 })); +}); + +test("exposes new user KPI and removes visitor stage from funnel", () => { + const model = createDashboardModel({ + active_users: 80, + new_users: 12, + new_users_delta_rate: 0.24, + paid_users: 8, + recharge_users: 7 + }, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } }); + + expect(metricValue(model, "新增用户数")).toBe("12"); + expect(model.kpis.map((item) => item.label)).toContain("新增用户数"); + expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]); }); function metricValue(model, label) { diff --git a/databi/src/styles/cards.css b/databi/src/styles/cards.css index 0ecdb73..3269f57 100644 --- a/databi/src/styles/cards.css +++ b/databi/src/styles/cards.css @@ -11,11 +11,10 @@ .metric-card { grid-column: span 2; display: grid; - grid-template-columns: 44px 1fr; - height: 128px; - align-items: center; - gap: 15px; - padding: 14px 18px; + grid-template-columns: minmax(0, 1fr); + height: 140px; + align-items: stretch; + padding: 10px 14px; border-radius: 8px; } @@ -49,6 +48,8 @@ } .metric-content { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto 22px; min-width: 0; } @@ -64,7 +65,26 @@ display: flex; min-width: 0; align-items: center; - gap: 5px; + gap: 6px; +} + +.metric-title-icon { + display: inline-grid; + flex: 0 0 auto; + width: 15px; + height: 15px; + place-items: center; + color: #27e4f5; +} + +.metric-title-icon svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; } .metric-label-row > span { @@ -103,10 +123,11 @@ .metric-content strong { display: block; - margin-top: 6px; + align-self: center; + margin-top: 2px; overflow: hidden; color: #f6fbff; - font-size: clamp(24px, 1.5vw, 30px); + font-size: clamp(20px, 1.25vw, 25px); font-weight: 780; line-height: 1.25; text-overflow: ellipsis; @@ -117,11 +138,18 @@ display: flex; justify-content: space-between; gap: 8px; - margin-top: 9px; - padding-top: 8px; + margin-top: 2px; + padding-top: 4px; border-top: 1px solid rgba(120, 169, 205, 0.16); color: #6f8aa4; - font-size: 12px; + font-size: 11px; +} + +.metric-delta--compact { + margin-top: 2px; + padding-top: 0; + border-top: 0; + font-size: 10px; } .metric-delta b, @@ -143,6 +171,63 @@ color: #7f9bb7; } +.metric-sub-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + align-self: stretch; + margin-top: 4px; +} + +.metric-sub-item { + display: grid; + min-width: 0; + align-content: center; +} + +.metric-sub-item > span { + overflow: hidden; + color: #8aa6c2; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-sub-item strong { + align-self: auto; + margin-top: 2px; + font-size: clamp(14px, 0.86vw, 18px); +} + +.metric-sparkline { + display: block; + width: 100%; + height: 18px; + margin-top: 3px; + overflow: visible; +} + +.metric-sparkline--empty { + border-bottom: 2px solid rgba(39, 228, 245, 0.22); +} + +.metric-sparkline-line { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.2; + vector-effect: non-scaling-stroke; +} + +.metric-sparkline-line--1 { + stroke: #27e4f5; +} + +.metric-sparkline-line--2 { + stroke: #24d79f; + opacity: 0.82; +} + .skeleton-block { display: block; position: relative; diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css index d1f10ca..1942516 100644 --- a/databi/src/styles/layout.css +++ b/databi/src/styles/layout.css @@ -7,9 +7,9 @@ .databi-screen { --analysis-row-height: 320px; - --business-metric-row-height: 88px; + --business-metric-row-height: 104px; display: grid; - grid-template-rows: 72px 128px var(--business-metric-row-height) var(--analysis-row-height); + grid-template-rows: 72px 140px var(--business-metric-row-height) var(--analysis-row-height); gap: 16px; min-height: 0; } @@ -652,10 +652,14 @@ .metric-grid { display: grid; - grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 16px; } +.metric-grid .metric-card { + grid-column: span 1; +} + .business-metric-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); @@ -673,7 +677,7 @@ .business-metric-grid .metric-content { display: grid; grid-template-columns: minmax(max-content, 1fr) max-content; - grid-template-rows: auto 1fr; + grid-template-rows: auto 1fr 18px; column-gap: 8px; align-items: center; } @@ -745,6 +749,11 @@ color: #7f9bb7; } +.business-metric-grid .metric-sparkline { + grid-column: 1 / 3; + grid-row: 3; +} + .databi-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); diff --git a/databi/src/styles/panels.css b/databi/src/styles/panels.css index 4e6f460..8cd8926 100644 --- a/databi/src/styles/panels.css +++ b/databi/src/styles/panels.css @@ -179,8 +179,8 @@ height: 58px; border: 1px solid rgba(43, 229, 245, 0.36); border-radius: 2px; - background: linear-gradient(180deg, #2ff4e9 0%, #1fadc3 52%, #16759a 100%); - box-shadow: 0 0 14px rgba(37, 228, 245, 0.24); + background: linear-gradient(180deg, #24d79f 0%, #27e4f5 48%, #4281ff 100%); + box-shadow: 0 0 16px rgba(39, 228, 245, 0.32); } .rank-list { @@ -303,6 +303,7 @@ padding: 32px; background: rgba(1, 9, 19, 0.72); backdrop-filter: blur(8px); + animation: modal-overlay-in 180ms ease-out both; } .country-globe-dialog { @@ -317,6 +318,7 @@ linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)), rgba(5, 24, 42, 0.98); box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06); + animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; } .country-globe-head { @@ -343,18 +345,17 @@ } .country-globe-head button { - display: grid; + display: inline-flex; width: 34px; height: 34px; - place-items: center; + align-items: center; + justify-content: center; + padding: 0; border: 1px solid rgba(64, 148, 197, 0.48); border-radius: 8px; background: rgba(7, 29, 48, 0.9); color: #d8ebff; cursor: pointer; - font: inherit; - font-size: 22px; - line-height: 1; } .country-globe-head button:hover { @@ -376,6 +377,19 @@ padding: 32px; background: rgba(1, 9, 19, 0.72); backdrop-filter: blur(8px); + animation: modal-overlay-in 180ms ease-out both; +} + +.metric-trend-modal { + position: fixed; + inset: 0; + z-index: 88; + display: grid; + place-items: center; + padding: 32px; + background: rgba(1, 9, 19, 0.72); + backdrop-filter: blur(8px); + animation: modal-overlay-in 180ms ease-out both; } .pool-detail-dialog { @@ -390,6 +404,22 @@ linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)), rgba(5, 24, 42, 0.98); box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06); + animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.metric-trend-dialog { + display: grid; + width: min(760px, calc(100vw - 64px)); + height: min(480px, calc(100vh - 64px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid rgba(44, 191, 227, 0.58); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)), + rgba(5, 24, 42, 0.98); + box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06); + animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; } .pool-detail-head { @@ -401,6 +431,15 @@ border-bottom: 1px solid rgba(111, 148, 183, 0.2); } +.metric-trend-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid rgba(111, 148, 183, 0.2); +} + .pool-detail-head h2 { margin: 0; color: #f3f9ff; @@ -408,6 +447,13 @@ font-weight: 780; } +.metric-trend-head h2 { + margin: 0; + color: #f3f9ff; + font-size: 20px; + font-weight: 780; +} + .pool-detail-head span { display: block; margin-top: 4px; @@ -415,19 +461,39 @@ font-size: 12px; } +.metric-trend-head span { + display: block; + margin-top: 4px; + color: #8faac5; + font-size: 12px; +} + .pool-detail-head button { - display: grid; + display: inline-flex; width: 34px; height: 34px; - place-items: center; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid rgba(64, 148, 197, 0.48); + border-radius: 8px; + background: rgba(7, 29, 48, 0.9); + color: #d8ebff; + cursor: pointer; +} + +.metric-trend-head button { + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + padding: 0; border: 1px solid rgba(64, 148, 197, 0.48); border-radius: 8px; background: rgba(7, 29, 48, 0.9); color: #d8ebff; cursor: pointer; - font: inherit; - font-size: 22px; - line-height: 1; } .pool-detail-head button:hover { @@ -435,6 +501,105 @@ color: #ffffff; } +.metric-trend-head button:hover { + border-color: rgba(39, 228, 245, 0.78); + color: #ffffff; +} + +.country-globe-modal.is-closing, +.pool-detail-modal.is-closing, +.metric-trend-modal.is-closing { + animation: modal-overlay-out 160ms ease-in both; +} + +.country-globe-modal.is-closing .country-globe-dialog, +.pool-detail-modal.is-closing .pool-detail-dialog, +.metric-trend-modal.is-closing .metric-trend-dialog { + animation: modal-dialog-out 160ms ease-in both; +} + +.modal-close-mark { + position: relative; + display: block; + width: 16px; + height: 16px; +} + +.modal-close-mark::before, +.modal-close-mark::after { + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 2px; + border-radius: 999px; + background: currentColor; + content: ""; + transform-origin: center; +} + +.modal-close-mark::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.modal-close-mark::after { + transform: translate(-50%, -50%) rotate(-45deg); +} + +@keyframes modal-overlay-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes modal-overlay-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes modal-dialog-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes modal-dialog-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + + to { + opacity: 0; + transform: translateY(8px) scale(0.99); + } +} + +.metric-trend-body { + min-height: 0; + padding: 16px 18px 18px; +} + +.metric-trend-chart { + width: 100%; + height: 100%; +} + .pool-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/databi/src/styles/responsive.css b/databi/src/styles/responsive.css index ae40dab..d0d4871 100644 --- a/databi/src/styles/responsive.css +++ b/databi/src/styles/responsive.css @@ -1,7 +1,5 @@ @media (max-width: 1440px) { .metric-card { - grid-template-columns: 34px 1fr; - gap: 10px; padding: 12px; } @@ -135,7 +133,6 @@ } .metric-card { - grid-template-columns: 36px 1fr; padding-inline: 12px; } diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx index 7a25e7e..a167feb 100644 --- a/src/features/dashboard/pages/OverviewPage.jsx +++ b/src/features/dashboard/pages/OverviewPage.jsx @@ -1,29 +1,8 @@ import { useOutletContext } from "react-router-dom"; -import { DataState } from "@/shared/ui/DataState.jsx"; -import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx"; import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx"; -import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx"; -import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx"; -import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js"; export function OverviewPage() { - const page = useDashboardPage(); const { menus = [] } = useOutletContext() || {}; - return ( - <> - - - - - - - - - ); + return ; }