完善数据大屏
This commit is contained in:
parent
e2c0565dbe
commit
62d88d178f
@ -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
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
62
databi/src/charts/options/createMetricTrendOption.js
Normal file
62
databi/src/charts/options/createMetricTrendOption.js
Normal 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");
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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="幸运礼物奖池汇总">
|
||||
|
||||
@ -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(" ");
|
||||
}
|
||||
|
||||
64
databi/src/components/MetricTrendModal.jsx
Normal file
64
databi/src/components/MetricTrendModal.jsx
Normal 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);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user