数据大屏

This commit is contained in:
zhx 2026-06-04 19:29:35 +08:00
parent a1c7803af8
commit ca194e46a3
24 changed files with 1624 additions and 265 deletions

View File

@ -1,55 +1,112 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchCountryStatisticsBreakdown, 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 { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
import { sampleOverview } from "./data/sampleOverview.js";
import { lastDaysRange, rangeEndMs, rangeStartMs } from "./utils/time.js";
import { rangeEndMs, rangeStartMs, todayRange } from "./utils/time.js";
const initialFilterOptions = {
appOptions: [],
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
};
export function DatabiApp() {
const initialRange = useMemo(() => lastDaysRange(7), []);
const initialRange = useMemo(() => todayRange(), []);
const [range, setRange] = useState(initialRange);
const [countryId, setCountryId] = useState(0);
const [regionId, setRegionId] = useState("all");
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
const [overview, setOverview] = useState(sampleOverview);
const [loading, setLoading] = useState(false);
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
const [filtersLoading, setFiltersLoading] = useState(false);
const [overview, setOverview] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [preview, setPreview] = useState(true);
const [poolModalOpen, setPoolModalOpen] = useState(false);
const overviewRequestIdRef = useRef(0);
const scopedCountries = useMemo(() => {
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
}, [filterOptions.countryOptions, regionId]);
const handleAppChange = useCallback((value) => {
setAppCode(setCurrentAppCode(value));
}, []);
const handleRegionChange = useCallback((value) => {
setRegionId(value);
setRegionId(String(value || "all"));
setCountryId(0);
}, []);
const loadFilterOptions = useCallback(async () => {
setFiltersLoading(true);
try {
const options = await fetchFilterOptions({ appCode });
setFilterOptions(options);
const nextAppCode = resolveSelectedAppCode(options.appOptions, appCode);
if (nextAppCode !== appCode) {
setAppCode(setCurrentAppCode(nextAppCode));
}
setRegionId((current) => (options.regionOptions.some((item) => String(item.id) === String(current)) ? current : "all"));
setCountryId((current) => (options.countryOptions.some((item) => Number(item.id) === Number(current)) ? current : 0));
} catch (err) {
setError(err.message || "筛选项加载失败");
} finally {
setFiltersLoading(false);
}
}, [appCode]);
const loadOverview = useCallback(async () => {
const requestId = overviewRequestIdRef.current + 1;
overviewRequestIdRef.current = requestId;
setLoading(true);
setError("");
setOverview(null);
try {
const startMs = rangeStartMs(range);
const endMs = rangeEndMs(range);
const data = await fetchStatisticsOverview({
appCode,
countryId,
endMs: rangeEndMs(range),
startMs: rangeStartMs(range)
endMs,
regionId,
startMs
});
setOverview(data || sampleOverview);
setPreview(false);
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 };
}
}
if (requestId === overviewRequestIdRef.current) {
setOverview(nextOverview);
}
} catch (err) {
setOverview(sampleOverview);
setPreview(true);
setError(err.message || "请求失败");
if (requestId === overviewRequestIdRef.current) {
setOverview(null);
setError(err.message || "请求失败");
}
} finally {
setLoading(false);
if (requestId === overviewRequestIdRef.current) {
setLoading(false);
}
}
}, [appCode, countryId, range]);
}, [appCode, countryId, range, regionId, scopedCountries]);
useEffect(() => {
void loadFilterOptions();
}, [loadFilterOptions]);
useEffect(() => {
void loadOverview();
@ -57,47 +114,82 @@ export function DatabiApp() {
return () => window.clearInterval(timer);
}, [loadOverview]);
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, preview }), [appCode, countryId, overview, preview]);
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range }), [appCode, countryId, overview, range]);
const showSkeleton = loading && !overview;
const openLuckyGiftPools = useCallback(() => {
setPoolModalOpen(true);
}, []);
return (
<main className="databi-shell">
<section className="databi-screen">
<DatabiHeader
appCode={appCode}
appOptions={filterOptions.appOptions}
countryOptions={filterOptions.countryOptions}
countryId={countryId}
error={error}
filtersLoading={filtersLoading}
loading={loading}
onAppChange={handleAppChange}
onCountryChange={setCountryId}
onRefresh={loadOverview}
onRangeChange={setRange}
onRegionChange={handleRegionChange}
preview={preview}
range={range}
regionId={regionId}
regionOptions={filterOptions.regionOptions}
updatedAt={model.updatedAt}
/>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} />
<MetricCard key={item.label} item={item} loading={showSkeleton} />
))}
</section>
<section className="business-metric-grid" aria-label="业务指标">
{model.businessKpis.map((item) => (
<MetricCard key={item.label} item={item} />
<MetricCard
key={item.label}
item={item}
loading={showSkeleton}
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
/>
))}
</section>
<section className="databi-grid databi-grid--top">
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} topCountries={model.topCountries} />
<RevenueTrendPanel revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} sideMetrics={model.sideMetrics} />
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
</section>
</section>
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} />
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
</main>
);
}
function resolveSelectedAppCode(options, current) {
const normalized = String(current || "").trim().toLowerCase();
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
return normalized;
}
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 countryBelongsToRegion(country, regionId) {
if (!regionId || regionId === "all") {
return true;
}
if (Array.isArray(country.regionIds) && country.regionIds.length) {
return country.regionIds.includes(String(regionId));
}
return String(country.regionId || "") === String(regionId);
}

View File

@ -1,4 +1,7 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
import { API_OPERATIONS, apiEndpointPath } from "../../src/shared/api/generated/endpoints.ts";
import { countryFlag, stripCountryFlagPrefix } from "./data/countryMeta.js";
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
const TOKEN_KEY = "hyapp-admin.access-token";
const APP_CODE_KEY = "hyapp-admin.app-code";
const APP_CODE_HEADER = "X-App-Code";
@ -13,26 +16,59 @@ export function setCurrentAppCode(value) {
return appCode;
}
export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) {
const query = new URLSearchParams();
query.set("app_code", normalizeAppCode(appCode) || "lalu");
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, startMs }) {
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
if (startMs) {
query.set("start_ms", String(startMs));
query.start_ms = String(startMs);
}
if (endMs) {
query.set("end_ms", String(endMs));
query.end_ms = String(endMs);
}
if (countryId) {
query.set("country_id", String(countryId));
query.country_id = String(countryId);
}
if (regionId && regionId !== "all") {
query.region_id = String(regionId);
}
const response = await fetch(`${API_BASE_URL}/v1/statistics/overview?${query.toString()}`, {
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 }),
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listRegions), { appCode, query: { status: "active" } }),
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listCountries), { appCode, query: { enabled: true } })
]);
return normalizeFilterOptions({ apps, countries, regions });
}
async function fetchDatabiData(path, { appCode, query } = {}) {
const response = await fetch(buildURL(path, query), {
credentials: "include",
headers: requestHeaders(appCode)
});
const payload = await readJSON(response);
if (isAuthExpired(response, payload)) {
redirectToLogin();
redirectToLogin(window);
throw new Error(payload.message || "访问凭证已失效");
}
if (!response.ok || payload.code !== 0) {
@ -41,6 +77,18 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, start
return payload.data;
}
function buildURL(path, query) {
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
}
return url.toString();
}
function requestHeaders(appCode) {
const headers = {};
const token = window.localStorage.getItem(TOKEN_KEY);
@ -66,17 +114,144 @@ async function readJSON(response) {
}
}
function isAuthExpired(response, payload) {
export function isAuthExpired(response, payload) {
return response.status === 401 || Number(payload?.code) === 40100;
}
function redirectToLogin() {
window.localStorage.removeItem(TOKEN_KEY);
if (window.location.pathname !== "/login") {
window.location.replace("/login");
export function redirectToLogin(targetWindow = window) {
targetWindow.localStorage.removeItem(TOKEN_KEY);
if (targetWindow.location.pathname !== "/login") {
targetWindow.location.replace("/login");
}
}
function normalizeAppCode(value) {
return String(value || "").trim().toLowerCase();
}
function normalizeFilterOptions({ apps, countries, regions }) {
const regionItems = normalizeList(regions);
const regionOptions = [
{ countryCodes: [], id: "all", label: "全部区域", value: "all" },
...regionItems
.filter((region) => String(region.regionCode || "").toUpperCase() !== "GLOBAL")
.map((region) => {
const id = String(region.regionId ?? region.id ?? region.regionCode ?? "");
return {
countryCodes: normalizeCodes(region.countries),
id,
label: [region.name, region.regionCode].filter(Boolean).join(" · ") || `区域 ${id}`,
value: id
};
})
.filter((region) => region.id)
];
const countryRegionIds = buildCountryRegionIndex(regionOptions);
const countryOptions = [
{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 },
...normalizeList(countries)
.map((country) => normalizeCountryOption(country, countryRegionIds))
.filter(Boolean)
];
const appOptions = normalizeList(apps)
.map((app) => {
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
return {
code,
label: app.appName || app.app_name || app.name || code,
value: code
};
})
.filter((app) => app.code);
return {
appOptions,
countryOptions,
regionOptions
};
}
function normalizeList(value) {
if (Array.isArray(value)) {
return value;
}
if (Array.isArray(value?.items)) {
return value.items;
}
if (Array.isArray(value?.list)) {
return value.list;
}
return [];
}
function normalizeCodes(value) {
return Array.isArray(value) ? value.map((item) => String(item || "").trim().toUpperCase()).filter(Boolean) : [];
}
function buildCountryRegionIndex(regions) {
const index = new Map();
regions.forEach((region) => {
if (region.id === "all") {
return;
}
region.countryCodes.forEach((code) => {
const ids = index.get(code) || [];
ids.push(region.id);
index.set(code, ids);
});
});
return index;
}
function normalizeCountryOption(country, countryRegionIds) {
const countryCode = String(country.countryCode ?? country.country_code ?? country.code ?? "").trim().toUpperCase();
const id = Number(country.countryId ?? country.country_id ?? country.id ?? 0);
if (!countryCode && !id) {
return null;
}
const rawName = country.countryDisplayName || country.country_display_name || country.countryName || country.country_name || country.name || countryCode;
const name = stripCountryFlagPrefix(rawName);
const flag = country.flag || countryFlag(countryCode);
const label = [flag, name].filter(Boolean).join(" ");
const searchText = [flag, rawName, name, countryCode, country.isoAlpha3, country.iso_alpha3, country.phoneCountryCode, country.phone_country_code]
.filter(Boolean)
.join(" ")
.toLowerCase();
return {
countryCode,
flag,
id,
label,
name,
regionIds: countryRegionIds.get(countryCode) || [],
searchText,
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

@ -9,7 +9,7 @@ export function createRevenueOption(series) {
color: ["#1bd8f2", "#20c9aa", "#4f8cff", "#f2f6ff"],
grid: { bottom: 30, left: 54, right: 54, top: 44 },
legend: [
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 22, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 1 },
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 22, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 1 },
{ data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#d8e8f8", fontSize: 12 }, top: 1 }
],
tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", extraCssText: "box-shadow: 0 10px 26px rgba(0,0,0,.32);", textStyle: { color: "#e9f6ff" }, trigger: "axis" },
@ -21,7 +21,7 @@ export function createRevenueOption(series) {
series: [
{ barGap: "-100%", barWidth: 28, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" },
{
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
lineStyle: { color: "#f2f6ff", shadowBlur: 10, shadowColor: "rgba(242,246,255,.42)", width: 3 },

View File

@ -1,22 +1,114 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent } from "../utils/format.js";
import { Panel } from "./Panel.jsx";
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
const tabs = [
{ key: "country", label: "国家表现" },
{ key: "gift", label: "礼物排行" },
{ key: "game", label: "游戏排行" }
];
export function CountryPerformanceTable({ gameRanking, giftRanking, items }) {
const defaultSortByTab = {
country: { direction: "desc", key: "recharge_usd_minor", type: "number" },
gift: { direction: "desc", key: "value", type: "number" },
game: { direction: "desc", key: "turnover_coin", type: "number" }
};
const countryColumns = [
{ key: "country", label: "国家", type: "text", align: "left" },
{ key: "visitors", label: "访客" },
{ key: "active_users", label: "活跃用户" },
{ key: "paid_users", label: "付费用户" },
{ key: "recharge_users", label: "充值用户" },
{ key: "recharge_usd_minor", label: "总充值 (USD)" },
{ key: "new_user_recharge_usd_minor", label: "新用户充值 (USD)" },
{ key: "arpu_usd_minor", label: "ARPU (USD)" },
{ key: "arppu_usd_minor", label: "ARPPU (USD)" },
{ key: "payer_rate", label: "付费转化率" },
{ key: "recharge_conversion_rate", label: "充值转化率" },
{ key: "avg_recharge_usd_minor", label: "平均充值 (USD)" },
{ key: "trend_rate", label: "近 7 日" }
];
const countrySortAccessors = {
active_users: (item) => item.active_users,
arppu_usd_minor: (item) => item.arppu_usd_minor,
arpu_usd_minor: (item) => item.arpu_usd_minor,
avg_recharge_usd_minor: (item) => item.avg_recharge_usd_minor,
country: (item) => item.country,
new_user_recharge_usd_minor: (item) => item.new_user_recharge_usd_minor,
paid_users: (item) => item.paid_users,
payer_rate: (item) => item.payer_rate,
recharge_conversion_rate: (item) => item.recharge_conversion_rate,
recharge_users: (item) => item.recharge_users,
recharge_usd_minor: (item) => item.recharge_usd_minor,
trend_rate: (item) => item.trend_rate,
visitors: (item) => item.visitors
};
const giftColumns = [
{ key: "name", label: "礼物", type: "text", align: "left" },
{ key: "value", label: "金币消费" },
{ key: "share", label: "占比" },
{ key: "trend_rate", label: "近 7 日" }
];
const gameColumns = [
{ key: "game_id", label: "游戏", type: "text", align: "left" },
{ key: "turnover_coin", label: "流水 (金币)" },
{ key: "profit_rate", label: "利润率" },
{ key: "share", label: "占比" }
];
export function CountryPerformanceTable({ gameRanking, giftRanking, items, loading = false }) {
const [activeTab, setActiveTab] = useState("country");
const [sortByTab, setSortByTab] = useState(defaultSortByTab);
const handleSort = (tabKey, columnKey, type = "number") => {
setSortByTab((previous) => {
const current = previous[tabKey];
const nextDirection = current?.key === columnKey && current.direction === "desc" ? "asc" : "desc";
return {
...previous,
[tabKey]: {
direction: type === "text" && current?.key !== columnKey ? "asc" : nextDirection,
key: columnKey,
type
}
};
});
};
return (
<Panel className="panel--table" title={<TableTabs activeTab={activeTab} onChange={setActiveTab} />}>
<div className="country-table-wrap">
{activeTab === "country" ? <CountryTable items={items} /> : null}
{activeTab === "gift" ? <GiftTable items={giftRanking} /> : null}
{activeTab === "game" ? <GameTable items={gameRanking} /> : null}
{activeTab === "country" ? (
<CountryTable
items={items}
loading={loading}
onSort={(columnKey, type) => handleSort("country", columnKey, type)}
sortState={sortByTab.country}
/>
) : null}
{activeTab === "gift" ? (
<GiftTable
items={giftRanking}
loading={loading}
onSort={(columnKey, type) => handleSort("gift", columnKey, type)}
sortState={sortByTab.gift}
/>
) : null}
{activeTab === "game" ? (
<GameTable
items={gameRanking}
loading={loading}
onSort={(columnKey, type) => handleSort("game", columnKey, type)}
sortState={sortByTab.game}
/>
) : null}
</div>
</Panel>
);
@ -34,29 +126,23 @@ function TableTabs({ activeTab, onChange }) {
);
}
function CountryTable({ items }) {
function CountryTable({ items, loading, onSort, sortState }) {
const sortedItems = useMemo(() => sortItems(items, sortState, countrySortAccessors), [items, sortState]);
return (
<table className="country-table country-table--wide">
<thead>
<tr>
<th>#</th>
<th>国家</th>
<th>访客</th>
<th>活跃用户</th>
<th>付费用户</th>
<th>充值用户</th>
<th>总充值 (USD)</th>
<th>新用户充值 (USD)</th>
<th>ARPU (USD)</th>
<th>ARPPU (USD)</th>
<th>付费转化率</th>
<th>充值转化率</th>
<th>平均充值 (USD)</th>
<th> 7 </th>
{countryColumns.map((column) => (
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
))}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
{loading ? <SkeletonRows colSpan={14} rows={6} /> : null}
{!loading && !items.length ? <EmptyRow colSpan={14} /> : null}
{!loading && sortedItems.map((item, index) => (
<tr key={item.country}>
<td>{index + 1}</td>
<td>{item.flag ? <i className="table-flag">{item.flag}</i> : null}{item.country}</td>
@ -79,27 +165,36 @@ function CountryTable({ items }) {
);
}
function GiftTable({ items }) {
function GiftTable({ items, loading, onSort, sortState }) {
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
const giftSortAccessors = useMemo(() => ({
name: (item) => item.name,
share: (item) => item.value / total,
trend_rate: (item) => item.trend_rate,
value: (item) => item.value
}), [total]);
const sortedItems = useMemo(() => sortItems(items, sortState, giftSortAccessors), [giftSortAccessors, items, sortState]);
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>礼物</th>
<th>金币消费</th>
<th>占比</th>
<th> 7 </th>
{giftColumns.map((column) => (
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
))}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
{loading ? <SkeletonRows colSpan={5} rows={5} /> : null}
{!loading && !items.length ? <EmptyRow colSpan={5} /> : null}
{!loading && sortedItems.map((item, index) => (
<tr key={item.name}>
<td>{index + 1}</td>
<td>{item.name}</td>
<td>{formatCoin(item.value)}</td>
<td>{formatPercent(item.value / total)}</td>
<td className="positive">+{formatPercent(0.168 - index * 0.017)}</td>
<td className={item.trend_rate >= 0 ? "positive" : "negative"}>{formatSignedPercent(item.trend_rate)}</td>
</tr>
))}
</tbody>
@ -107,21 +202,30 @@ function GiftTable({ items }) {
);
}
function GameTable({ items }) {
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,
profit_rate: (item) => item.profit_rate,
share: (item) => item.turnover_coin / total,
turnover_coin: (item) => item.turnover_coin
}), [total]);
const sortedItems = useMemo(() => sortItems(items, sortState, gameSortAccessors), [gameSortAccessors, items, sortState]);
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>游戏</th>
<th>流水 (USD)</th>
<th>利润率</th>
<th>占比</th>
{gameColumns.map((column) => (
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
))}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
{loading ? <SkeletonRows colSpan={5} rows={5} /> : null}
{!loading && !items.length ? <EmptyRow colSpan={5} /> : null}
{!loading && sortedItems.map((item, index) => (
<tr key={`${item.platform_code || "game"}-${item.game_id}`}>
<td>{index + 1}</td>
<td>{item.game_id}</td>
@ -134,3 +238,85 @@ function GameTable({ items }) {
</table>
);
}
function SortableHeader({ column, onSort, sortState }) {
const isActive = sortState?.key === column.key;
const direction = isActive ? sortState.direction : "none";
const ariaSort = isActive ? (sortState.direction === "asc" ? "ascending" : "descending") : "none";
return (
<th aria-sort={ariaSort} className="is-sortable" scope="col">
<button
className={`sort-header-button ${column.align === "left" ? "sort-header-button--left" : ""} ${isActive ? "is-active" : ""}`}
type="button"
onClick={() => onSort(column.key, column.type || "number")}
>
<span>{column.label}</span>
<span aria-hidden="true" className="sort-indicator">{direction === "asc" ? "↑" : direction === "desc" ? "↓" : "↕"}</span>
</button>
</th>
);
}
function SkeletonRows({ colSpan, rows }) {
return Array.from({ length: rows }).map((_, index) => (
<tr className="table-skeleton-row" key={index}>
<td colSpan={colSpan}><span className="skeleton-block skeleton-table-line" /></td>
</tr>
));
}
function EmptyRow({ colSpan }) {
return (
<tr className="table-empty-row">
<td colSpan={colSpan}>暂无数据</td>
</tr>
);
}
function formatSignedPercent(value) {
if (value === undefined || value === null || Number.isNaN(Number(value))) {
return "";
}
return `${Number(value) >= 0 ? "+" : ""}${formatPercent(value)}`;
}
function sortItems(items, sortState, accessors) {
if (!sortState?.key || !accessors[sortState.key]) {
return items;
}
const directionMultiplier = sortState.direction === "asc" ? 1 : -1;
return items
.map((item, index) => ({ index, item }))
.sort((left, right) => {
const leftValue = accessors[sortState.key](left.item);
const rightValue = accessors[sortState.key](right.item);
const result = compareValues(leftValue, rightValue, sortState.type);
return result === 0 ? left.index - right.index : result * directionMultiplier;
})
.map(({ item }) => item);
}
function compareValues(leftValue, rightValue, type) {
if (type === "text") {
return textCollator.compare(String(leftValue || ""), String(rightValue || ""));
}
const leftNumber = Number(leftValue);
const rightNumber = Number(rightValue);
if (!Number.isFinite(leftNumber) && !Number.isFinite(rightNumber)) {
return 0;
}
if (!Number.isFinite(leftNumber)) {
return -1;
}
if (!Number.isFinite(rightNumber)) {
return 1;
}
return leftNumber - rightNumber;
}

View File

@ -0,0 +1,69 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { CountryPerformanceTable } from "./CountryPerformanceTable.jsx";
const countryItems = [
createCountryRow({
country: "阿富汗",
flag: "🇦🇫",
recharge_usd_minor: 50000,
visitors: 10
}),
createCountryRow({
country: "巴西",
flag: "🇧🇷",
recharge_usd_minor: 10000,
visitors: 30
}),
createCountryRow({
country: "中国",
flag: "🇨🇳",
recharge_usd_minor: 30000,
visitors: 20
})
];
test("sorts country table headers ascending and descending", async () => {
const user = userEvent.setup();
render(<CountryPerformanceTable gameRanking={[]} giftRanking={[]} items={countryItems} />);
expect(firstCountryCell()).toHaveTextContent("阿富汗");
await user.click(screen.getByRole("button", { name: /访客/ }));
expect(screen.getByRole("columnheader", { name: /访客/ })).toHaveAttribute("aria-sort", "descending");
expect(firstCountryCell()).toHaveTextContent("巴西");
await user.click(screen.getByRole("button", { name: /访客/ }));
expect(screen.getByRole("columnheader", { name: /访客/ })).toHaveAttribute("aria-sort", "ascending");
expect(firstCountryCell()).toHaveTextContent("阿富汗");
});
function createCountryRow(overrides) {
return {
active_users: 0,
arppu_usd_minor: 0,
arpu_usd_minor: 0,
avg_recharge_usd_minor: 0,
country: "",
flag: "",
new_user_recharge_usd_minor: 0,
paid_users: 0,
payer_rate: 0,
recharge_conversion_rate: 0,
recharge_users: 0,
recharge_usd_minor: 0,
trend_rate: 0,
visitors: 0,
...overrides
};
}
function firstCountryCell() {
const [, firstDataRow] = screen.getAllByRole("row");
return within(firstDataRow).getAllByRole("cell")[1];
}

View File

@ -2,10 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import { appOptions, countryOptions, regionOptions } from "../config/options.js";
import { lastDaysRange } from "../utils/time.js";
import { lastDaysRange, todayRange } from "../utils/time.js";
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
const defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }];
const rangeOptions = [
{ label: "今日", value: "today", getRange: () => todayRange() },
{ label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) },
{ label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) },
{ label: "近 30 日", value: "30d", getRange: () => lastDaysRange(30) },
@ -16,18 +18,31 @@ const hours = Array.from({ length: 24 }, (_, index) => pad2(index));
const minutes = Array.from({ length: 60 }, (_, index) => pad2(index));
const seconds = minutes;
export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange, onRangeChange, onRegionChange, range, regionId }) {
export function DatabiHeader({
appCode,
appOptions = [],
countryId,
countryOptions = defaultCountryOptions,
filtersLoading = false,
onAppChange,
onCountryChange,
onRangeChange,
onRegionChange,
range,
regionId,
regionOptions = defaultRegionOptions
}) {
const [openControl, setOpenControl] = useState("");
const headerRef = useRef(null);
const appLabel = appOptions.find((item) => item.code === appCode)?.label || appCode;
const regionLabel = regionOptions.find((item) => item.id === regionId)?.label || "全部区域";
const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode;
const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域";
const filteredCountries = useMemo(() => {
if (regionId === "all") {
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
return countryOptions;
}
return countryOptions.filter((item) => item.id === 0 || item.regionId === regionId);
}, [regionId]);
const countryLabel = filteredCountries.find((item) => item.id === countryId)?.label || "全部国家";
return countryOptions.filter((item) => Number(item.id) === 0 || item.regionIds?.includes(String(regionId)) || item.regionId === regionId);
}, [countryOptions, regionId, regionOptions]);
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
const activeRange = rangeOptions.find((item) => {
const next = item.getRange();
return sameRange(next, range);
@ -65,6 +80,7 @@ export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange,
icon={<span className="control-glyph">APP</span>}
isOpen={openControl === "app"}
label="App"
loading={filtersLoading}
onSelect={(value) => selectItem(onAppChange, value)}
onToggle={() => toggleControl("app")}
options={appOptions.map((item) => ({ label: item.label, value: item.code }))}
@ -82,16 +98,18 @@ export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange,
icon={<PublicOutlined fontSize="small" />}
isOpen={openControl === "region"}
label="区域"
loading={filtersLoading}
onSelect={(value) => selectItem(onRegionChange, value)}
onToggle={() => toggleControl("region")}
options={regionOptions}
value={regionId}
valueLabel={regionLabel}
/>
<FilterMenu
<SearchableFilterMenu
icon={<span className="control-glyph">CN</span>}
isOpen={openControl === "country"}
label="国家"
loading={filtersLoading}
onSelect={(value) => selectItem(onCountryChange, Number(value))}
onToggle={() => toggleControl("country")}
options={filteredCountries}
@ -104,10 +122,10 @@ export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange,
);
}
function FilterMenu({ icon, isOpen, label, onSelect, onToggle, options, value, valueLabel }) {
function FilterMenu({ icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
return (
<div className="filter-control">
<button aria-expanded={isOpen} className="filter-trigger" onClick={onToggle} type="button">
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
{icon ? <span className="filter-icon">{icon}</span> : null}
<span className="filter-copy">
<small>{label}</small>
@ -117,11 +135,65 @@ function FilterMenu({ icon, isOpen, label, onSelect, onToggle, options, value, v
</button>
{isOpen ? (
<div className="filter-popover" role="listbox">
{options.map((item) => (
<button className={item.value === value || item.id === value ? "is-active" : ""} key={item.value ?? item.id} onClick={() => onSelect(item.value ?? item.id)} role="option" type="button">
{item.label}
</button>
))}
{loading ? <span className="filter-empty">加载中...</span> : null}
{!loading && !options.length ? <span className="filter-empty">暂无选项</span> : null}
{!loading
? options.map((item) => (
<button className={item.value === value || item.id === value ? "is-active" : ""} key={item.value ?? item.id} onClick={() => onSelect(item.value ?? item.id)} role="option" type="button">
{item.label}
</button>
))
: null}
</div>
) : null}
</div>
);
}
function SearchableFilterMenu({ icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
const [query, setQuery] = useState("");
const filteredOptions = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) {
return options;
}
return options.filter((item) => `${item.label || ""} ${item.countryCode || ""} ${item.searchText || ""}`.toLowerCase().includes(keyword));
}, [options, query]);
useEffect(() => {
if (isOpen) {
setQuery("");
}
}, [isOpen]);
return (
<div className="filter-control filter-control--country">
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
{icon ? <span className="filter-icon">{icon}</span> : null}
<span className="filter-copy">
<small>{label}</small>
<b>{valueLabel}</b>
</span>
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
</button>
{isOpen ? (
<div className="filter-popover filter-popover--searchable" role="listbox">
<label className="filter-search">
<span>搜索国家</span>
<input placeholder="国家 / Code" type="text" value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
{loading ? <span className="filter-empty">加载中...</span> : null}
{!loading && !filteredOptions.length ? <span className="filter-empty">没有匹配国家</span> : null}
{!loading && filteredOptions.length ? (
<div className="filter-options-scroll">
{filteredOptions.map((item) => (
<button className={Number(item.id) === Number(value) ? "is-active" : ""} key={item.value ?? item.id} onClick={() => onSelect(item.value ?? item.id)} role="option" type="button">
<span>{item.label}</span>
{item.countryCode ? <small>{item.countryCode}</small> : null}
</button>
))}
</div>
) : null}
</div>
) : null}
</div>

View File

@ -2,25 +2,29 @@ import { formatNumber, formatPercent } from "../utils/format.js";
import { MiniStat } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function FunnelPanel({ funnel, sideMetrics }) {
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
return (
<Panel title="ARPU 转化漏斗">
<div className="funnel-layout">
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
<div className="funnel-stack">
{funnel.map((item, index) => (
<div className="funnel-stage-row" key={item.name}>
<div className={`funnel-stage funnel-stage--${index + 1}`}>
<span>{item.name}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
<b>{formatPercent(item.rate)}</b>
</div>
))}
{loading
? Array.from({ length: 4 }).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}`}>
<span>{item.name}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
<b>{formatPercent(item.rate)}</b>
</div>
))}
</div>
<div className="side-metrics">
{sideMetrics.map((item) => (
<MiniStat key={item.label} item={item} />
))}
{loading
? Array.from({ length: 3 }).map((_, index) => <span className="skeleton-block skeleton-mini-stat" key={index} />)
: sideMetrics.map((item) => (
<MiniStat key={item.label} item={item} />
))}
</div>
</div>
</Panel>

View File

@ -1,27 +1,85 @@
import { useEffect, useMemo, useState } from "react";
import { EChart } from "../charts/EChart.jsx";
import { createGeoOption } from "../charts/options/createGeoOption.js";
import { formatMoney, moneyMajor } from "../utils/format.js";
import { GlobeMap } from "./GlobeMap.jsx";
import { Panel } from "./Panel.jsx";
import { RankList } from "./RankList.jsx";
export function GlobalOverviewPanel({ countryBreakdown, topCountries }) {
export function GlobalOverviewPanel({ countryBreakdown, loading = false, topCountries }) {
const [globeOpen, setGlobeOpen] = useState(false);
const rankedCountries = useMemo(() => (topCountries.length ? topCountries : countryBreakdown.slice(0, 4)), [countryBreakdown, topCountries]);
return (
<Panel className="panel--map" title="全球概览">
<div className="geo-layout">
<div className="geo-map">
<EChart className="geo-chart" option={createGeoOption(countryBreakdown)} />
<div className="geo-scale">
<span></span>
<i />
<span></span>
<>
<Panel className="panel--map" title="全球概览">
{loading ? <GlobalOverviewSkeleton /> : (
<div className="geo-layout">
<div className="geo-map">
{rankedCountries.length ? <EChart className="geo-chart" option={createGeoOption(rankedCountries)} /> : <div className="panel-empty">暂无国家数据</div>}
<div className="geo-scale">
<span></span>
<i />
<span></span>
</div>
</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>
</div>
</div>
</div>
<div className="rank-block">
<RankList items={topCountries} title="充值国家排行" valueFormatter={formatRankMoney} />
<button className="all-countries-button" type="button">查看全部国家</button>
</div>
)}
</Panel>
{globeOpen ? <CountryGlobeDialog items={countryBreakdown} onClose={() => setGlobeOpen(false)} /> : null}
</>
);
}
function GlobalOverviewSkeleton() {
return (
<div className="geo-layout skeleton-panel" aria-busy="true">
<div className="geo-map skeleton-map">
<span className="skeleton-block skeleton-map-glow" />
</div>
</Panel>
<div className="rank-block">
<span className="skeleton-block skeleton-rank-title" />
{Array.from({ length: 4 }).map((_, index) => (
<span className="skeleton-block skeleton-rank-row" key={index} />
))}
<span className="skeleton-block skeleton-button" />
</div>
</div>
);
}
function CountryGlobeDialog({ items, onClose }) {
useEffect(() => {
const closeOnEscape = (event) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [onClose]);
return (
<div className="country-globe-modal" role="presentation" onMouseDown={(event) => (event.target === event.currentTarget ? onClose() : null)}>
<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>
</div>
<div className="country-globe-body">
<GlobeMap items={items} />
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { formatCoin, formatPercent } from "../utils/format.js";
export function LuckyGiftPoolModal({ items, onClose }) {
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 });
return (
<div aria-labelledby="lucky-gift-pool-title" aria-modal="true" className="pool-detail-modal" role="dialog">
<div className="pool-detail-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>
</header>
<section className="pool-detail-summary" aria-label="幸运礼物奖池汇总">
<PoolSummary label="总流水" value={totals.turnover} />
<PoolSummary label="总返奖" value={totals.payout} />
<PoolSummary label="总利润" value={totals.profit} tone={totals.profit < 0 ? "negative" : "positive"} />
</section>
<div className="pool-detail-table-wrap">
<table className="pool-detail-table">
<thead>
<tr>
<th>#</th>
<th>Pool ID</th>
<th>流水(金币)</th>
<th>返奖(金币)</th>
<th>利润(金币)</th>
<th>返奖率</th>
<th>利润率</th>
</tr>
</thead>
<tbody>
{items.length ? items.map((item, index) => (
<tr key={item.pool_id}>
<td>{index + 1}</td>
<td>{item.pool_id}</td>
<td>{formatCoin(item.turnover)}</td>
<td>{formatCoin(item.payout)}</td>
<td className={item.profit < 0 ? "negative" : "positive"}>{formatCoin(item.profit)}</td>
<td>{formatPercent(item.payout_rate)}</td>
<td>{formatPercent(item.profit_rate)}</td>
</tr>
)) : (
<tr>
<td colSpan={7}>暂无 pool 数据</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function PoolSummary({ label, tone = "", value }) {
return (
<article className="pool-summary-card">
<span>{label}</span>
<strong className={tone}>{formatCoin(value)}</strong>
<small>金币</small>
</article>
);
}

View File

@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { LuckyGiftPoolModal } from "./LuckyGiftPoolModal.jsx";
test("renders lucky gift pool turnover payout and profit", () => {
render(
<LuckyGiftPoolModal
items={[
{ payout: 300, payout_rate: 0.3, pool_id: "lucky_100", profit: 700, profit_rate: 0.7, turnover: 1_000 },
{ payout: 50, payout_rate: 0.1, pool_id: "super_lucky", profit: 450, profit_rate: 0.9, turnover: 500 }
]}
onClose={vi.fn()}
/>
);
expect(screen.getByRole("dialog", { name: /幸运礼物奖池明细/ })).toBeInTheDocument();
expect(screen.getByText("总流水")).toBeInTheDocument();
expect(screen.getByText("1,500")).toBeInTheDocument();
expect(screen.getByText("lucky_100")).toBeInTheDocument();
expect(screen.getByText("super_lucky")).toBeInTheDocument();
expect(screen.getByText("700")).toBeInTheDocument();
expect(screen.getByText("450")).toBeInTheDocument();
});

View File

@ -1,8 +1,32 @@
export function MetricCard({ item }) {
export function MetricCard({ item, loading = false, onClick }) {
const Icon = item.icon;
const UnitIcon = item.unitIcon;
const clickable = Boolean(onClick);
if (loading) {
return (
<article className={["metric-card", Icon ? "" : "metric-card--no-icon", "is-loading"].filter(Boolean).join(" ")} aria-busy="true">
{Icon ? <div className="metric-icon metric-icon--skeleton"><span className="skeleton-block skeleton-icon" /></div> : null}
<div className="metric-content">
<span className="skeleton-block skeleton-label" />
<span className="skeleton-block skeleton-value" />
<span className="skeleton-block skeleton-caption" />
</div>
</article>
);
}
return (
<article className={["metric-card", Icon ? "" : "metric-card--no-icon"].filter(Boolean).join(" ")}>
<article
className={["metric-card", Icon ? "" : "metric-card--no-icon", clickable ? "metric-card--clickable" : ""].filter(Boolean).join(" ")}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={onClick}
onKeyDown={clickable ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
} : undefined}
>
{Icon ? <div className="metric-icon"><Icon /></div> : null}
<div className="metric-content">
<div className="metric-label-row">

View File

@ -2,6 +2,7 @@ export function RankList({ items, title, valueFormatter }) {
return (
<div className="rank-list">
<div className="rank-title">{title}</div>
{!items.length ? <div className="rank-empty">暂无数据</div> : null}
{items.map((item, index) => (
<div className="rank-row" key={item.country}>
<span>{index + 1}</span>

View File

@ -2,10 +2,19 @@ import { EChart } from "../charts/EChart.jsx";
import { createRevenueOption } from "../charts/options/createRevenueOption.js";
import { Panel } from "./Panel.jsx";
export function RevenueTrendPanel({ revenueSeries }) {
export function RevenueTrendPanel({ loading = false, revenueSeries }) {
return (
<Panel title="充值趋势">
<EChart className="trend-chart" option={createRevenueOption(revenueSeries)} />
{loading ? (
<div className="trend-chart chart-skeleton" aria-busy="true">
<span className="skeleton-block skeleton-chart-legend" />
<span className="skeleton-block skeleton-chart-body" />
</div>
) : revenueSeries.length ? (
<EChart className="trend-chart" option={createRevenueOption(revenueSeries)} />
) : (
<div className="trend-chart panel-empty">暂无趋势数据</div>
)}
</Panel>
);
}

View File

@ -1,20 +0,0 @@
export const appOptions = [
{ code: "lalu", label: "Lalu" },
{ code: "mifa", label: "Mifa" },
{ code: "mifapay", label: "MifaPay" }
];
export const regionOptions = [
{ id: "all", label: "全部区域" },
{ id: "asia", label: "亚洲" },
{ id: "americas", label: "美洲" }
];
export const countryOptions = [
{ id: 0, label: "全部国家", regionId: "all" },
{ id: 101, label: "印度尼西亚", regionId: "asia" },
{ id: 102, label: "印度", regionId: "asia" },
{ id: 103, label: "美国", regionId: "americas" },
{ id: 104, label: "巴西", regionId: "americas" },
{ id: 105, label: "土耳其", regionId: "asia" }
];

View File

@ -37,6 +37,10 @@ export function countryFlag(code) {
.join("");
}
export function stripCountryFlagPrefix(value) {
return String(value || "").replace(/^(?:\s*[\u{1F1E6}-\u{1F1FF}]{2})+\s*/u, "").trim();
}
function normalizeKey(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -1,12 +1,12 @@
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
import { countryFlag, resolveCountryMeta } from "./countryMeta.js";
import { countryFlag, resolveCountryMeta, stripCountryFlagPrefix } from "./countryMeta.js";
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
import { numberValue, ratio } from "../utils/number.js";
import { formatDateTime } from "../utils/time.js";
import { sampleOverview } from "./sampleOverview.js";
export function createDashboardModel(overview, { appCode, countryId, preview }) {
const source = overview || sampleOverview;
export function createDashboardModel(overview, { appCode, countryId, range }) {
const source = overview || {};
const comparisonCaption = isTodayRange(range) ? "与昨日相比" : "近 7 日";
const recharge = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
const activeUsers = readNumber(source, "active_users", "activeUsers");
@ -15,9 +15,11 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
const luckyGiftTurnover = readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
const luckyGiftPayout = readNumber(source, "lucky_gift_payout", "luckyGiftPayout");
const luckyGiftProfit = readNumber(source, "lucky_gift_profit", "luckyGiftProfit");
const luckyGiftPools = normalizeLuckyGiftPools(source);
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
const luckyGiftPayout = luckyGiftPools.length ? luckyGiftPoolTotals.payout : readNumber(source, "lucky_gift_payout", "luckyGiftPayout");
const luckyGiftProfit = luckyGiftPools.length ? luckyGiftPoolTotals.profit : readNumber(source, "lucky_gift_profit", "luckyGiftProfit");
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
const gameProfit = readNumber(source, "game_profit", "gameProfit");
const countryBreakdown = normalizeCountries(source, countryId);
@ -28,12 +30,12 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
return {
appCode,
businessKpis: [
coinMetric("礼物消费", giftCoinSpent, preview ? "+16.80%" : ""),
coinMetric("幸运礼物流水", luckyGiftTurnover, preview ? "+12.93%" : ""),
coinMetric("幸运礼物返奖", luckyGiftPayout, preview ? "+8.15%" : ""),
coinMetric("幸运礼物利润", luckyGiftProfit, preview ? "+4.66%" : ""),
coinMetric("游戏流水", gameTurnover, preview ? "+14.20%" : ""),
coinMetric("游戏利润", gameProfit, preview ? "+18.18%" : "")
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), 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)
],
countryBreakdown,
funnel: [
@ -43,46 +45,51 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
{ name: "充值用户", rate: ratio(numberValue(source.lucky_gift_payers), activeUsers), value: numberValue(source.lucky_gift_payers) }
],
gameMetrics: [
{ caption: "近 7 日 +14.20%", label: "流水", value: formatWholeMoney(source.game_turnover) },
{ caption: "近 7 日 +9.63%", label: "派奖", value: formatWholeMoney(source.game_payout) },
{ caption: "近 7 日 -3.58%", label: "退款", value: formatWholeMoney(source.game_refund) },
{ caption: "近 7 日 +18.18%", label: "利润", value: formatWholeMoney(source.game_profit) }
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_payout_delta_rate", "gamePayoutDeltaRate")), label: "派奖", value: formatWholeMoney(source.game_payout) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_refund_delta_rate", "gameRefundDeltaRate")), label: "退款", value: formatWholeMoney(source.game_refund) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate")), label: "利润", value: formatWholeMoney(source.game_profit) }
],
gameRanking,
giftRanking: normalizeGiftRanking(source),
kpis: [
{ caption: "近 7 日", delta: preview ? "+18.10%" : "", icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) },
{ caption: "近 7 日", delta: preview ? "+10.77%" : "", icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
{ caption: "近 7 日", delta: preview ? "+12.92%" : "", icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
{ caption: "近 7 日", delta: preview ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) },
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) }
{ 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) }
],
luckyMetrics: [
{ caption: "近 7 日 +10.62%", label: "流水", value: formatWholeMoney(source.lucky_gift_turnover) },
{ caption: "近 7 日 +8.15%", label: "返奖", value: formatWholeMoney(source.lucky_gift_payout) },
{ caption: "近 7 日 +4.66%", label: "利润", value: formatWholeMoney(source.lucky_gift_profit) },
{ caption: "近 7 日 -2.01pp", label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
{ caption: "近 7 日 +0.76pp", label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate")), label: "返奖", value: formatWholeMoney(luckyGiftPayout) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate")), label: "利润", value: formatWholeMoney(luckyGiftProfit) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_rate_delta_pp", "luckyGiftPayoutRateDeltaPp"), "pp"), label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_rate_delta_pp", "luckyGiftProfitRateDeltaPp"), "pp"), label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
],
luckyGiftPools,
payoutDistribution,
revenueSeries,
roomMetrics: [
{ caption: "近 7 日 +16.80%", label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
{ caption: "近 7 日 +12.93%", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate")), label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
],
sideMetrics: [
{ caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(arpu) },
{ caption: "近 7 日 +0.18pp", label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
{ caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(arppu) }
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
],
topCountries: countryBreakdown.slice(0, 5),
topCountries: countryBreakdown.slice(0, 4),
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
};
}
function coinMetric(label, value, delta) {
return { caption: "近 7 日", delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value) };
function coinMetric(label, value, delta, caption, extra = {}) {
return { caption, delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value), ...extra };
}
function captionWithDelta(label, delta) {
return `${label} ${delta}`;
}
function readNumber(source, ...keys) {
@ -94,7 +101,49 @@ function readNumber(source, ...keys) {
return 0;
}
function readDelta(source, ...keys) {
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
for (const key of keys) {
if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") {
return formatDelta(source[key], unit);
}
}
return "";
}
function deltaTone(source, ...keys) {
for (const key of keys) {
if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") {
return numberValue(source[key]) < 0 ? "down" : "up";
}
}
return "up";
}
function formatDelta(value, unit) {
const numeric = numberValue(value);
const normalized = unit === "%" && Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
const prefix = normalized > 0 ? "+" : "";
return `${prefix}${normalized.toFixed(2)}${unit}`;
}
function isTodayRange(range) {
const today = dateValue(new Date());
return range?.start === today && range?.end === today;
}
function dateValue(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function normalizeRevenueSeries(source) {
if (!source || !Object.keys(source).length) {
return [];
}
if (Array.isArray(source.daily_series) && source.daily_series.length) {
return source.daily_series.map((item) => ({
coin_seller: numberValue(item.coin_seller ?? item.coinSeller),
@ -119,19 +168,15 @@ function normalizeRevenueSeries(source) {
function normalizeCountries(source, countryId) {
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
const rows =
raw ||
[
{
active_users: source.active_users,
arppu_usd_minor: source.arppu_usd_minor,
country: countryId ? `国家 ${countryId}` : "全部区域",
paid_users: source.paid_users,
recharge_usd_minor: source.recharge_usd_minor,
x: 56,
y: 52
}
];
const rows = raw || (countryId ? [{
active_users: source.active_users,
arppu_usd_minor: source.arppu_usd_minor,
country: `国家 ${countryId}`,
paid_users: source.paid_users,
recharge_usd_minor: source.recharge_usd_minor,
x: 56,
y: 52
}] : []);
const totalRecharge = rows.reduce((sum, item) => sum + numberValue(item.recharge_usd_minor), 0) || 1;
return rows
.map((item, index) => ({
@ -142,7 +187,7 @@ function normalizeCountries(source, countryId) {
function normalizeCountryRow(item, index, totalRecharge) {
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
const country = item.country || item.country_name || code || `国家 ${index + 1}`;
const country = stripCountryFlagPrefix(item.country || item.country_name || code || `国家 ${index + 1}`);
const meta = resolveCountryMeta({ code, name: country });
const recharge = numberValue(item.recharge_usd_minor);
return {
@ -168,21 +213,60 @@ function normalizeCountryRow(item, index, totalRecharge) {
function normalizeGiftRanking(source) {
if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) {
return source.gift_ranking.map((item) => ({ name: item.name || item.gift_name || item.gift_id || "-", value: numberValue(item.value ?? item.amount ?? item.coin) }));
return source.gift_ranking.map((item) => ({
name: item.name || item.gift_name || item.gift_id || "-",
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
value: numberValue(item.value ?? item.amount ?? item.coin)
}));
}
return [{ name: "礼物消费", value: numberValue(source.gift_coin_spent) }];
return [];
}
function normalizeDistribution(source) {
if (Array.isArray(source.payout_distribution) && source.payout_distribution.length) {
return source.payout_distribution.map((item) => ({ name: item.name || "-", value: numberValue(item.value) }));
}
if (!source || !Object.keys(source).length) {
return [];
}
return [
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) }
];
}
function normalizeLuckyGiftPools(source) {
const raw = firstArray(source?.lucky_gift_pools, source?.luckyGiftPools, source?.lucky_gift_pool_stats, source?.luckyGiftPoolStats, source?.pool_breakdown);
return raw
.map((item) => {
const turnover = readNumber(item, "turnover_coin", "turnoverCoin", "turnover", "total_spent_coins", "totalSpentCoins", "lucky_gift_turnover", "luckyGiftTurnover");
const payout = readNumber(item, "payout_coin", "payoutCoin", "payout", "total_reward_coins", "totalRewardCoins", "lucky_gift_payout", "luckyGiftPayout");
const rawProfit = item.profit_coin ?? item.profitCoin ?? item.profit ?? item.lucky_gift_profit ?? item.luckyGiftProfit;
const profit = rawProfit === undefined || rawProfit === null ? turnover - payout : numberValue(rawProfit);
return {
payout,
payout_rate: numberValue(item.payout_rate ?? item.payoutRate ?? ratio(payout, turnover)),
pool_id: String(item.pool_id ?? item.poolId ?? item.id ?? "-").trim() || "-",
profit,
profit_rate: numberValue(item.profit_rate ?? item.profitRate ?? ratio(profit, turnover)),
turnover
};
})
.sort((left, right) => right.turnover - left.turnover || left.pool_id.localeCompare(right.pool_id));
}
function summarizeLuckyGiftPools(items) {
return 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 });
}
function firstArray(...values) {
return values.find((value) => Array.isArray(value)) || [];
}
function normalizeGameRanking(source) {
if (Array.isArray(source.game_ranking) && source.game_ranking.length) {
return source.game_ranking.map((item) => ({
@ -192,7 +276,7 @@ function normalizeGameRanking(source) {
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin)
}));
}
return [{ game_id: "全部游戏", platform_code: "", profit_rate: numberValue(source.game_profit_rate), turnover_coin: numberValue(source.game_turnover) }];
return [];
}
function localizeSeriesLabel(label) {

View File

@ -0,0 +1,27 @@
import { expect, test } from "vitest";
import { createDashboardModel } from "./createDashboardModel.js";
test("uses lucky gift pool breakdown to aggregate business metrics", () => {
const model = createDashboardModel({
lucky_gift_pools: [
{ payout_coin: 300, pool_id: "lucky_100", turnover_coin: 1_000 },
{ payout_coin: 50, pool_id: "super_lucky", turnover_coin: 500 }
],
lucky_gift_payout: 1,
lucky_gift_profit: 1,
lucky_gift_turnover: 1
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
expect(model.luckyGiftPools).toEqual([
expect.objectContaining({ payout: 300, pool_id: "lucky_100", profit: 700, turnover: 1_000 }),
expect.objectContaining({ payout: 50, pool_id: "super_lucky", profit: 450, turnover: 500 })
]);
expect(metricValue(model, "幸运礼物流水")).toBe("1,500");
expect(metricValue(model, "幸运礼物返奖")).toBe("350");
expect(metricValue(model, "幸运礼物利润")).toBe("1,150");
expect(model.businessKpis.filter((item) => item.detailKey === "luckyGiftPools")).toHaveLength(3);
});
function metricValue(model, label) {
return model.businessKpis.find((item) => item.label === label)?.value;
}

View File

@ -1,67 +0,0 @@
export const sampleOverview = {
active_users: 221188,
arppu_usd_minor: 306,
arpu_usd_minor: 5,
coin_seller_recharge_usd_minor: 196046500,
country_breakdown: [
{ active_users: 81560, arpu_usd_minor: 7, arppu_usd_minor: 334, avg_recharge_usd_minor: 2520, country: "印度尼西亚", new_user_recharge_usd_minor: 142140000, paid_users: 7008, recharge_conversion_rate: 0.0183, recharge_users: 5687, recharge_usd_minor: 235490000, trend_rate: 0.2043, visitors: 310200 },
{ active_users: 58190, arpu_usd_minor: 7, arppu_usd_minor: 303, avg_recharge_usd_minor: 2373, country: "印度", new_user_recharge_usd_minor: 94280000, paid_users: 4850, recharge_conversion_rate: 0.0179, recharge_users: 3985, recharge_usd_minor: 158732000, trend_rate: 0.1795, visitors: 222150 },
{ active_users: 24350, arpu_usd_minor: 7, arppu_usd_minor: 318, avg_recharge_usd_minor: 2485, country: "美国", new_user_recharge_usd_minor: 38165000, paid_users: 1980, recharge_conversion_rate: 0.0214, recharge_users: 1620, recharge_usd_minor: 62876000, trend_rate: 0.1611, visitors: 112700 },
{ active_users: 19840, arpu_usd_minor: 5, arppu_usd_minor: 297, avg_recharge_usd_minor: 2310, country: "巴西", new_user_recharge_usd_minor: 25041000, paid_users: 1360, recharge_conversion_rate: 0.0188, recharge_users: 1120, recharge_usd_minor: 40368000, trend_rate: 0.1238, visitors: 89350 },
{ active_users: 16720, arpu_usd_minor: 5, arppu_usd_minor: 290, avg_recharge_usd_minor: 2026, country: "土耳其", new_user_recharge_usd_minor: 15362000, paid_users: 1150, recharge_conversion_rate: 0.0188, recharge_users: 945, recharge_usd_minor: 24456000, trend_rate: 0.1081, visitors: 74880 },
{ active_users: 11220, arpu_usd_minor: 5, arppu_usd_minor: 284, avg_recharge_usd_minor: 2083, country: "埃及", new_user_recharge_usd_minor: 8941000, paid_users: 780, recharge_conversion_rate: 0.0188, recharge_users: 610, recharge_usd_minor: 15894000, trend_rate: 0.0942, visitors: 48660 },
{ active_users: 9880, arpu_usd_minor: 4, arppu_usd_minor: 270, avg_recharge_usd_minor: 2011, country: "泰国", new_user_recharge_usd_minor: 7403000, paid_users: 650, recharge_conversion_rate: 0.0171, recharge_users: 520, recharge_usd_minor: 13287000, trend_rate: 0.0725, visitors: 45210 },
{ active_users: 8860, arpu_usd_minor: 4, arppu_usd_minor: 251, avg_recharge_usd_minor: 1973, country: "越南", new_user_recharge_usd_minor: 6224000, paid_users: 580, recharge_conversion_rate: 0.0171, recharge_users: 470, recharge_usd_minor: 11643000, trend_rate: 0.0618, visitors: 42120 }
],
daily_series: [
{ coin_seller: 38000000, google: 42000000, label: "5月25日", line_total: 470000000, mifapay: 36000000, total: 116000000 },
{ coin_seller: 44000000, google: 47000000, label: "5月26日", line_total: 510000000, mifapay: 39000000, total: 130000000 },
{ coin_seller: 48000000, google: 52000000, label: "5月27日", line_total: 640000000, mifapay: 61000000, total: 161000000 },
{ coin_seller: 39000000, google: 39000000, label: "5月28日", line_total: 455000000, mifapay: 34000000, total: 112000000 },
{ coin_seller: 42000000, google: 44000000, label: "5月29日", line_total: 515000000, mifapay: 41000000, total: 127000000 },
{ coin_seller: 52000000, google: 61000000, label: "5月30日", line_total: 635000000, mifapay: 43000000, total: 156000000 },
{ coin_seller: 56000000, google: 53000000, label: "5月31日", line_total: 710000000, mifapay: 65000000, total: 174000000 }
],
game_payout: 343560,
game_players: 12932,
game_profit: 436248,
game_profit_rate: 0.5405,
game_ranking: [
{ game_id: "龙腾财富", payout_coin: 64020, player_count: 2038, profit_coin: 146410, profit_rate: 0.6958, turnover_coin: 210430 },
{ game_id: "海洋宝藏", payout_coin: 53260, player_count: 1820, profit_coin: 113660, profit_rate: 0.6809, turnover_coin: 166920 },
{ game_id: "幸运转盘", payout_coin: 43980, player_count: 1460, profit_coin: 84690, profit_rate: 0.6582, turnover_coin: 128670 },
{ game_id: "黄金帝国", payout_coin: 33400, player_count: 1024, profit_coin: 54060, profit_rate: 0.6254, turnover_coin: 87460 },
{ game_id: "探险之旅", payout_coin: 21980, player_count: 760, profit_coin: 38340, profit_rate: 0.6356, turnover_coin: 60320 }
],
game_refund: 27200,
game_turnover: 807008,
gift_coin_spent: 4277860,
gift_ranking: [
{ name: "飞梦", value: 568200 },
{ name: "银河飞船", value: 412450 },
{ name: "爱心城堡", value: 329180 },
{ name: "玫瑰花园", value: 265770 },
{ name: "水晶之心", value: 198260 }
],
google_recharge_usd_minor: 223200000,
lucky_gift_payout: 2573750,
lucky_gift_payout_rate: 6.3815,
lucky_gift_payers: 12932,
lucky_gift_profit: -2171086,
lucky_gift_profit_rate: -5.3815,
lucky_gift_turnover: 402664,
mifapay_recharge_usd_minor: 318500000,
new_user_recharge_usd_minor: 481316000,
new_users: 1012000,
paid_users: 16170,
payout_distribution: [
{ name: "用户幸运礼物返奖", value: 1616840 },
{ name: "幸运盒子返奖", value: 518420 },
{ name: "活动返奖", value: 269040 },
{ name: "其他", value: 169450 }
],
recharge_usd_minor: 737746500,
retention: { day1_rate: 0.6136, day7_rate: 0.181, day30_rate: 0.1077 },
room_lucky_gift_turnover: 635150,
updated_at_ms: Date.now()
};

View File

@ -19,6 +19,17 @@
border-radius: 8px;
}
.metric-card--clickable {
cursor: pointer;
}
.metric-card--clickable:hover,
.metric-card--clickable:focus-visible {
border-color: rgba(39, 228, 245, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 0 0 3px rgba(39, 228, 245, 0.08);
outline: none;
}
.metric-icon {
display: grid;
width: 40px;
@ -119,10 +130,68 @@
font-weight: 760;
}
.negative {
color: #f5a623;
font-weight: 760;
}
.metric-delta--down b {
color: #f5a623;
}
.skeleton-block {
display: block;
position: relative;
overflow: hidden;
border-radius: 4px;
background: rgba(95, 151, 193, 0.16);
}
.skeleton-block::after {
position: absolute;
inset: 0;
transform: translateX(-110%);
background: linear-gradient(90deg, transparent, rgba(121, 213, 238, 0.22), transparent);
animation: skeleton-shimmer 1.35s ease-in-out infinite;
content: "";
}
@keyframes skeleton-shimmer {
to {
transform: translateX(110%);
}
}
.metric-card.is-loading .metric-content {
display: grid;
gap: 10px;
}
.metric-icon--skeleton {
color: transparent;
}
.skeleton-icon {
width: 34px;
height: 34px;
border-radius: 50%;
}
.skeleton-label {
width: 92px;
height: 13px;
}
.skeleton-value {
width: min(190px, 78%);
height: 30px;
}
.skeleton-caption {
width: min(160px, 68%);
height: 14px;
}
.side-metrics {
display: grid;
gap: 8px;

View File

@ -24,6 +24,24 @@
min-height: 0;
}
.chart-skeleton {
display: grid;
align-content: start;
gap: 16px;
padding: 12px 20px 20px;
}
.skeleton-chart-legend {
width: 58%;
height: 14px;
}
.skeleton-chart-body {
width: 100%;
height: 196px;
border-radius: 8px;
}
.donut-chart {
height: 182px;
}

View File

@ -115,6 +115,11 @@
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
}
.filter-trigger:disabled {
cursor: progress;
opacity: 0.68;
}
.filter-icon,
.filter-chevron {
color: #cbd9e9;
@ -279,6 +284,70 @@
color: #f3fbff;
}
.filter-popover--searchable {
width: 260px;
}
.filter-search {
display: grid;
gap: 5px;
padding: 2px 2px 8px;
}
.filter-search span {
color: #7f9bb7;
font-size: 10px;
font-weight: 720;
}
.filter-search input {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid rgba(64, 148, 197, 0.36);
border-radius: 7px;
outline: 0;
background: rgba(3, 18, 31, 0.9);
color: #e6f3ff;
font: inherit;
font-size: 12px;
}
.filter-search input:focus {
border-color: rgba(39, 228, 245, 0.72);
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
}
.filter-options-scroll {
display: grid;
max-height: 250px;
gap: 4px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.filter-options-scroll button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-options-scroll button small {
flex: 0 0 auto;
color: #7f9bb7;
font-size: 10px;
font-weight: 760;
}
.filter-empty {
display: block;
padding: 10px;
color: #8faac5;
font-size: 12px;
}
.date-time-popover {
position: absolute;
top: calc(100% + 10px);
@ -589,6 +658,32 @@
align-items: center;
}
.business-metric-grid .metric-card.is-loading .metric-content {
grid-template-columns: minmax(0, 1fr) 96px;
grid-template-rows: 16px 28px;
}
.business-metric-grid .metric-card.is-loading .skeleton-label {
grid-column: 1;
grid-row: 1;
width: 88px;
}
.business-metric-grid .metric-card.is-loading .skeleton-value {
grid-column: 1;
grid-row: 2;
width: min(160px, 76%);
height: 24px;
}
.business-metric-grid .metric-card.is-loading .skeleton-caption {
grid-column: 2;
grid-row: 1 / 3;
align-self: center;
justify-self: end;
width: 86px;
}
.business-metric-grid .metric-label-row {
grid-column: 1;
grid-row: 1;

View File

@ -103,6 +103,16 @@
min-width: 0;
}
.panel-empty {
display: grid;
width: 100%;
height: 100%;
min-height: 120px;
place-items: center;
color: #7f9bb7;
font-size: 13px;
}
.globe-map {
position: relative;
z-index: 2;
@ -180,6 +190,16 @@
min-width: 0;
}
.rank-empty {
display: grid;
min-height: 112px;
place-items: center;
border: 1px dashed rgba(108, 157, 196, 0.22);
border-radius: 8px;
color: #7f9bb7;
font-size: 13px;
}
.rank-row {
display: grid;
grid-template-columns: 20px 20px minmax(0, 1fr) 62px;
@ -231,9 +251,266 @@
border-radius: 8px;
background: rgba(7, 35, 58, 0.72);
color: #2fe8f3;
cursor: pointer;
font-size: 14px;
}
.all-countries-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.skeleton-panel {
pointer-events: none;
}
.skeleton-map {
display: grid;
min-height: 170px;
place-items: center;
}
.skeleton-map-glow {
width: 74%;
height: 62%;
border-radius: 8px;
}
.skeleton-rank-title {
width: 120px;
height: 14px;
margin-bottom: 2px;
}
.skeleton-rank-row {
width: 100%;
height: 36px;
border-radius: 8px;
}
.skeleton-button {
width: 100%;
height: 34px;
border-radius: 8px;
}
.country-globe-modal {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 32px;
background: rgba(1, 9, 19, 0.72);
backdrop-filter: blur(8px);
}
.country-globe-dialog {
display: grid;
width: min(1040px, calc(100vw - 64px));
height: min(760px, 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);
}
.country-globe-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);
}
.country-globe-head h2 {
margin: 0;
color: #f3f9ff;
font-size: 20px;
font-weight: 780;
}
.country-globe-head span {
display: block;
margin-top: 4px;
color: #8faac5;
font-size: 12px;
}
.country-globe-head button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
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 {
border-color: rgba(39, 228, 245, 0.78);
color: #ffffff;
}
.country-globe-body {
min-height: 0;
padding: 18px;
}
.pool-detail-modal {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
padding: 32px;
background: rgba(1, 9, 19, 0.72);
backdrop-filter: blur(8px);
}
.pool-detail-dialog {
display: grid;
width: min(860px, calc(100vw - 64px));
max-height: min(680px, calc(100vh - 64px));
grid-template-rows: auto 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);
}
.pool-detail-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;
font-size: 20px;
font-weight: 780;
}
.pool-detail-head span {
display: block;
margin-top: 4px;
color: #8faac5;
font-size: 12px;
}
.pool-detail-head button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
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 {
border-color: rgba(39, 228, 245, 0.78);
color: #ffffff;
}
.pool-detail-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 16px 18px 8px;
}
.pool-summary-card {
min-width: 0;
padding: 12px 14px;
border: 1px solid rgba(35, 128, 180, 0.46);
border-radius: 8px;
background: rgba(7, 29, 48, 0.76);
}
.pool-summary-card span,
.pool-summary-card small {
color: #8faac5;
font-size: 12px;
}
.pool-summary-card strong {
display: block;
margin-top: 6px;
overflow: hidden;
color: #f6fbff;
font-size: 22px;
font-weight: 780;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-detail-table-wrap {
min-height: 0;
padding: 10px 18px 18px;
overflow: auto;
}
.pool-detail-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
}
.pool-detail-table th,
.pool-detail-table td {
height: 34px;
padding: 0 12px;
border-bottom: 1px solid rgba(108, 157, 196, 0.22);
border-right: 1px solid rgba(108, 157, 196, 0.15);
color: #cfe3f7;
font-weight: 620;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.pool-detail-table th {
background: rgba(128, 171, 211, 0.08);
color: #9eb7ce;
font-size: 12px;
font-weight: 720;
}
.pool-detail-table th:nth-child(2),
.pool-detail-table td:nth-child(2) {
text-align: left;
}
.pool-detail-table th:first-child,
.pool-detail-table td:first-child {
width: 42px;
text-align: center;
}
.rank-row b,
.bar-row b,
.game-row b,
@ -345,3 +622,25 @@
margin-inline: 54px;
background: linear-gradient(180deg, rgba(32, 203, 161, 0.94), rgba(18, 168, 137, 0.92));
}
.skeleton-funnel-stage {
height: 37px;
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
}
.skeleton-funnel-stage--2 {
margin-inline: 18px;
}
.skeleton-funnel-stage--3 {
margin-inline: 36px;
}
.skeleton-funnel-stage--4 {
margin-inline: 54px;
}
.skeleton-mini-stat {
height: 72px;
border-radius: 8px;
}

View File

@ -29,6 +29,10 @@
font-weight: 720;
}
.country-table th.is-sortable {
padding: 0;
}
.country-table th:nth-child(2),
.country-table td:nth-child(2) {
text-align: left;
@ -48,6 +52,44 @@
text-align: center;
}
.sort-header-button {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
width: 100%;
height: 25px;
padding: 0 12px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
text-align: right;
cursor: pointer;
}
.sort-header-button--left {
justify-content: flex-start;
text-align: left;
}
.sort-header-button:hover,
.sort-header-button.is-active {
color: #e9f6ff;
}
.sort-indicator {
width: 10px;
color: #6f8ea8;
font-size: 10px;
text-align: center;
}
.sort-header-button.is-active .sort-indicator {
color: #25d8f5;
}
.table-tabs {
display: inline-flex;
gap: 3px;
@ -73,3 +115,21 @@
color: #e9f6ff;
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
}
.table-skeleton-row td,
.table-empty-row td {
border-right: 0;
text-align: center;
}
.skeleton-table-line {
width: 100%;
height: 15px;
border-radius: 3px;
}
.table-empty-row td {
height: 76px;
color: #7f9bb7;
font-size: 13px;
}

View File

@ -1,3 +1,8 @@
export function todayRange() {
const today = new Date();
return { end: dateInputValue(today), endTime: "23:59:59", start: dateInputValue(today), startTime: "00:00:00" };
}
export function lastDaysRange(days) {
const end = new Date();
const start = new Date(end);