完善数据大屏

This commit is contained in:
zhx 2026-06-08 00:53:55 +08:00
parent e2c0565dbe
commit 62d88d178f
21 changed files with 853 additions and 199 deletions

View File

@ -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() {
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} loading={showSkeleton} />
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
))}
</section>
@ -196,6 +189,7 @@ export function DatabiApp() {
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
{trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
</main>
);
}
@ -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
};
})
};
}

View File

@ -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(<DatabiApp />);
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();
});

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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}<br/>` : ""}${row.marker}${row.seriesName}: ${formatTrendValue(row.data, item.trendFormat)}`).join("<br/>");
},
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");
}

View File

@ -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;
}

View File

@ -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) => (
<tr key={`${item.platform_code || "game"}-${item.game_id}`}>
<td>{index + 1}</td>
<td>{item.game_id}</td>
<td>{item.game_label || item.game_id}</td>
<td>{formatCoin(item.turnover_coin)}</td>
<td className="positive">{formatPercent(item.profit_rate)}</td>
<td>{formatPercent(item.turnover_coin / total)}</td>

View File

@ -8,7 +8,7 @@ export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
<div className="funnel-stack">
{loading
? Array.from({ length: 4 }).map((_, index) => <span className={`skeleton-block skeleton-funnel-stage skeleton-funnel-stage--${index + 1}`} key={index} />)
? Array.from({ length: 3 }).map((_, index) => <span className={`skeleton-block skeleton-funnel-stage skeleton-funnel-stage--${index + 1}`} key={index} />)
: funnel.map((item, index) => (
<div className="funnel-stage-row" key={item.name}>
<div className={`funnel-stage funnel-stage--${index + 1}`}>

View File

@ -24,7 +24,7 @@ function GameRankTable({ items }) {
{items.map((item, index) => (
<div className="game-row" key={`${item.platform_code || "game"}-${item.game_id}`}>
<span>{index + 1}</span>
<em>{item.game_id || "-"}</em>
<em>{item.game_label || item.game_id || "-"}</em>
<div className="bar-track">
<i style={{ width: `${Math.max(5, (item.turnover_coin / max) * 100)}%` }} />
</div>

View File

@ -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
</div>
<div className="rank-block">
<RankList items={rankedCountries} title="充值国家排行" valueFormatter={formatRankMoney} />
<button className="all-countries-button" disabled={!countryBreakdown.length} type="button" onClick={() => setGlobeOpen(true)}>
<button className="all-countries-button" disabled={!rechargeCountries.length} type="button" onClick={() => setGlobeOpen(true)}>
查看全部国家
</button>
</div>
</div>
)}
</Panel>
{globeOpen ? <CountryGlobeDialog items={countryBreakdown} onClose={() => setGlobeOpen(false)} /> : null}
{globeOpen ? <CountryGlobeDialog items={rechargeCountries} onClose={() => 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 (
<div className="country-globe-modal" role="presentation" onMouseDown={(event) => (event.target === event.currentTarget ? onClose() : null)}>
<div
className={["country-globe-modal", closing ? "is-closing" : ""].filter(Boolean).join(" ")}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
requestClose();
}
}}
>
<section aria-label="全部国家地球仪" aria-modal="true" className="country-globe-dialog" role="dialog">
<div className="country-globe-head">
<div>
<h2>全部国家</h2>
<span>{items.length} 个国家 / 按充值规模展示</span>
</div>
<button aria-label="关闭" type="button" onClick={onClose}>×</button>
<button aria-label="关闭" type="button" onClick={requestClose}>
<span aria-hidden="true" className="modal-close-mark" />
</button>
</div>
<div className="country-globe-body">
<GlobeMap items={items} />
@ -90,3 +119,7 @@ function formatRankMoney(value) {
}
return formatMoney(value);
}
function hasRecharge(item) {
return Number(item?.recharge_usd_minor) > 0;
}

View File

@ -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"
>
<canvas className="globe-canvas" ref={canvasRef} />
{hovered ? (
<div className="globe-tooltip" style={{ left: hovered.x, top: hovered.y }}>
<strong>{hovered.item.country}</strong>
<span>充值 {formatMoney(hovered.item.value)}</span>
<span>活跃 {formatNumber(hovered.item.activeUsers)}</span>
<span>付费 {formatNumber(hovered.item.paidUsers)}</span>
{visibleTooltip ? (
<div className="globe-tooltip" style={{ left: visibleTooltip.x, top: visibleTooltip.y }}>
<strong>{visibleTooltip.item.country}</strong>
<span>充值 {formatMoney(visibleTooltip.item.value)}</span>
<span>活跃 {formatNumber(visibleTooltip.item.activeUsers)}</span>
<span>付费 {formatNumber(visibleTooltip.item.paidUsers)}</span>
</div>
) : null}
</div>
@ -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 = [];

View File

@ -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 (
<div aria-labelledby="lucky-gift-pool-title" aria-modal="true" className="pool-detail-modal" role="dialog">
<div className="pool-detail-dialog">
<div
className={["pool-detail-modal", closing ? "is-closing" : ""].filter(Boolean).join(" ")}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
requestClose();
}
}}
>
<div aria-labelledby="lucky-gift-pool-title" aria-modal="true" className="pool-detail-dialog" role="dialog">
<header className="pool-detail-head">
<div>
<h2 id="lucky-gift-pool-title">幸运礼物奖池明细</h2>
<span> pool_id 汇总流水返奖和利润</span>
</div>
<button aria-label="关闭幸运礼物奖池明细" type="button" onClick={onClose}>×</button>
<button aria-label="关闭幸运礼物奖池明细" type="button" onClick={requestClose}>
<span aria-hidden="true" className="modal-close-mark" />
</button>
</header>
<section className="pool-detail-summary" aria-label="幸运礼物奖池汇总">

View File

@ -27,19 +27,77 @@ export function MetricCard({ item, loading = false, onClick }) {
}
} : undefined}
>
{Icon ? <div className="metric-icon"><Icon /></div> : null}
<div className="metric-content">
<div className="metric-label-row">
{Icon ? <i className="metric-title-icon"><Icon /></i> : null}
<span>{item.label}</span>
{UnitIcon ? <i className="metric-unit-icon"><UnitIcon /></i> : null}
{item.unit ? <em className="metric-unit">{item.unit}</em> : null}
</div>
<strong>{item.value}</strong>
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
<span>{item.caption}</span>
<b className={item.delta ? "" : "metric-delta-empty"}>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}</b>
</div>
{Array.isArray(item.subMetrics) && item.subMetrics.length ? <MetricSubValues items={item.subMetrics} /> : <MetricMainValue item={item} />}
<MetricSparkline series={item.trend} />
</div>
</article>
);
}
function MetricMainValue({ item }) {
return (
<>
<strong>{item.value}</strong>
<MetricDelta item={item} />
</>
);
}
function MetricSubValues({ items }) {
return (
<div className="metric-sub-grid">
{items.map((subItem) => (
<div className="metric-sub-item" key={subItem.label}>
<span>{subItem.label}</span>
<strong>{subItem.value}</strong>
<MetricDelta item={subItem} compact />
</div>
))}
</div>
);
}
function MetricDelta({ compact = false, item }) {
return (
<div className={["metric-delta", compact ? "metric-delta--compact" : "", item.deltaTone === "down" ? "metric-delta--down" : ""].filter(Boolean).join(" ")}>
<span>{item.caption}</span>
<b className={item.delta ? "" : "metric-delta-empty"}>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}</b>
</div>
);
}
function MetricSparkline({ series }) {
const lines = Array.isArray(series) ? series.filter((item) => Array.isArray(item.data) && item.data.length > 1) : [];
if (!lines.length) {
return <span className="metric-sparkline metric-sparkline--empty" />;
}
const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0));
const min = Math.min(...values);
const max = Math.max(...values);
return (
<svg className="metric-sparkline" preserveAspectRatio="none" viewBox="0 0 100 24" aria-hidden="true">
{lines.slice(0, 2).map((line, index) => (
<polyline className={`metric-sparkline-line metric-sparkline-line--${index + 1}`} key={line.name || index} points={sparklinePoints(line.data, min, max)} />
))}
</svg>
);
}
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(" ");
}

View File

@ -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 (
<div
className={["metric-trend-modal", closing ? "is-closing" : ""].filter(Boolean).join(" ")}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
requestClose();
}
}}
>
<section aria-label={`${item.label} 每日趋势`} aria-modal="true" className="metric-trend-dialog" role="dialog">
<header className="metric-trend-head">
<div>
<h2>{item.label}</h2>
<span>每日折线图</span>
</div>
<button aria-label="关闭每日趋势" type="button" onClick={requestClose}>
<span aria-hidden="true" className="modal-close-mark" />
</button>
</header>
<div className="metric-trend-body">
{hasTrend(item) ? <EChart className="metric-trend-chart" option={createMetricTrendOption(item)} /> : <div className="panel-empty">暂无趋势数据</div>}
</div>
</section>
</div>
);
}
function hasTrend(item) {
return Array.isArray(item?.trend) && item.trend.some((line) => Array.isArray(line.data) && line.data.length);
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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;

View File

@ -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));

View File

@ -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));

View File

@ -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;
}

View File

@ -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 (
<>
<DashboardToolbar
onRefresh={page.reload}
onRangeChange={page.setRange}
overview={page.overview}
range={page.range}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<DashboardStats overview={page.overview} stats={page.stats} />
<DashboardFrequentMenus menus={menus} />
<DashboardCharts overview={page.overview} />
</DataState>
</>
);
return <DashboardFrequentMenus menus={menus} />;
}