数据大屏
This commit is contained in:
parent
a1c7803af8
commit
ca194e46a3
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
69
databi/src/components/CountryPerformanceTable.test.jsx
Normal file
69
databi/src/components/CountryPerformanceTable.test.jsx
Normal 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];
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
72
databi/src/components/LuckyGiftPoolModal.jsx
Normal file
72
databi/src/components/LuckyGiftPoolModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
databi/src/components/LuckyGiftPoolModal.test.jsx
Normal file
23
databi/src/components/LuckyGiftPoolModal.test.jsx
Normal 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();
|
||||
});
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
];
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
27
databi/src/data/createDashboardModel.test.js
Normal file
27
databi/src/data/createDashboardModel.test.js
Normal 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;
|
||||
}
|
||||
@ -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()
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user