数据大屏
This commit is contained in:
parent
a1c7803af8
commit
ca194e46a3
@ -1,55 +1,112 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
|
import { fetchCountryStatisticsBreakdown, fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
|
||||||
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
||||||
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
||||||
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
||||||
import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
|
import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
|
||||||
|
import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
|
||||||
import { MetricCard } from "./components/MetricCard.jsx";
|
import { MetricCard } from "./components/MetricCard.jsx";
|
||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
import { sampleOverview } from "./data/sampleOverview.js";
|
import { rangeEndMs, rangeStartMs, todayRange } from "./utils/time.js";
|
||||||
import { lastDaysRange, rangeEndMs, rangeStartMs } 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() {
|
export function DatabiApp() {
|
||||||
const initialRange = useMemo(() => lastDaysRange(7), []);
|
const initialRange = useMemo(() => todayRange(), []);
|
||||||
const [range, setRange] = useState(initialRange);
|
const [range, setRange] = useState(initialRange);
|
||||||
const [countryId, setCountryId] = useState(0);
|
const [countryId, setCountryId] = useState(0);
|
||||||
const [regionId, setRegionId] = useState("all");
|
const [regionId, setRegionId] = useState("all");
|
||||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||||
const [overview, setOverview] = useState(sampleOverview);
|
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
||||||
const [loading, setLoading] = useState(false);
|
const [filtersLoading, setFiltersLoading] = useState(false);
|
||||||
|
const [overview, setOverview] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
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) => {
|
const handleAppChange = useCallback((value) => {
|
||||||
setAppCode(setCurrentAppCode(value));
|
setAppCode(setCurrentAppCode(value));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRegionChange = useCallback((value) => {
|
const handleRegionChange = useCallback((value) => {
|
||||||
setRegionId(value);
|
setRegionId(String(value || "all"));
|
||||||
setCountryId(0);
|
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 loadOverview = useCallback(async () => {
|
||||||
|
const requestId = overviewRequestIdRef.current + 1;
|
||||||
|
overviewRequestIdRef.current = requestId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setOverview(null);
|
||||||
try {
|
try {
|
||||||
|
const startMs = rangeStartMs(range);
|
||||||
|
const endMs = rangeEndMs(range);
|
||||||
const data = await fetchStatisticsOverview({
|
const data = await fetchStatisticsOverview({
|
||||||
appCode,
|
appCode,
|
||||||
countryId,
|
countryId,
|
||||||
endMs: rangeEndMs(range),
|
endMs,
|
||||||
startMs: rangeStartMs(range)
|
regionId,
|
||||||
|
startMs
|
||||||
});
|
});
|
||||||
setOverview(data || sampleOverview);
|
let nextOverview = data || {};
|
||||||
setPreview(false);
|
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) {
|
} catch (err) {
|
||||||
setOverview(sampleOverview);
|
if (requestId === overviewRequestIdRef.current) {
|
||||||
setPreview(true);
|
setOverview(null);
|
||||||
setError(err.message || "请求失败");
|
setError(err.message || "请求失败");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (requestId === overviewRequestIdRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [appCode, countryId, range]);
|
}, [appCode, countryId, range, regionId, scopedCountries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFilterOptions();
|
||||||
|
}, [loadFilterOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
@ -57,47 +114,82 @@ export function DatabiApp() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [loadOverview]);
|
}, [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 (
|
return (
|
||||||
<main className="databi-shell">
|
<main className="databi-shell">
|
||||||
<section className="databi-screen">
|
<section className="databi-screen">
|
||||||
<DatabiHeader
|
<DatabiHeader
|
||||||
appCode={appCode}
|
appCode={appCode}
|
||||||
|
appOptions={filterOptions.appOptions}
|
||||||
|
countryOptions={filterOptions.countryOptions}
|
||||||
countryId={countryId}
|
countryId={countryId}
|
||||||
error={error}
|
error={error}
|
||||||
|
filtersLoading={filtersLoading}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onAppChange={handleAppChange}
|
onAppChange={handleAppChange}
|
||||||
onCountryChange={setCountryId}
|
onCountryChange={setCountryId}
|
||||||
onRefresh={loadOverview}
|
onRefresh={loadOverview}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
onRegionChange={handleRegionChange}
|
onRegionChange={handleRegionChange}
|
||||||
preview={preview}
|
|
||||||
range={range}
|
range={range}
|
||||||
regionId={regionId}
|
regionId={regionId}
|
||||||
|
regionOptions={filterOptions.regionOptions}
|
||||||
updatedAt={model.updatedAt}
|
updatedAt={model.updatedAt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="metric-grid" aria-label="核心指标">
|
<section className="metric-grid" aria-label="核心指标">
|
||||||
{model.kpis.map((item) => (
|
{model.kpis.map((item) => (
|
||||||
<MetricCard key={item.label} item={item} />
|
<MetricCard key={item.label} item={item} loading={showSkeleton} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="business-metric-grid" aria-label="业务指标">
|
<section className="business-metric-grid" aria-label="业务指标">
|
||||||
{model.businessKpis.map((item) => (
|
{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>
|
||||||
|
|
||||||
<section className="databi-grid databi-grid--top">
|
<section className="databi-grid databi-grid--top">
|
||||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} topCountries={model.topCountries} />
|
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
||||||
<RevenueTrendPanel revenueSeries={model.revenueSeries} />
|
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
||||||
<FunnelPanel funnel={model.funnel} sideMetrics={model.sideMetrics} />
|
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
||||||
</section>
|
</section>
|
||||||
</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>
|
</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 TOKEN_KEY = "hyapp-admin.access-token";
|
||||||
const APP_CODE_KEY = "hyapp-admin.app-code";
|
const APP_CODE_KEY = "hyapp-admin.app-code";
|
||||||
const APP_CODE_HEADER = "X-App-Code";
|
const APP_CODE_HEADER = "X-App-Code";
|
||||||
@ -13,26 +16,59 @@ export function setCurrentAppCode(value) {
|
|||||||
return appCode;
|
return appCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) {
|
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, startMs }) {
|
||||||
const query = new URLSearchParams();
|
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
||||||
query.set("app_code", normalizeAppCode(appCode) || "lalu");
|
|
||||||
if (startMs) {
|
if (startMs) {
|
||||||
query.set("start_ms", String(startMs));
|
query.start_ms = String(startMs);
|
||||||
}
|
}
|
||||||
if (endMs) {
|
if (endMs) {
|
||||||
query.set("end_ms", String(endMs));
|
query.end_ms = String(endMs);
|
||||||
}
|
}
|
||||||
if (countryId) {
|
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",
|
credentials: "include",
|
||||||
headers: requestHeaders(appCode)
|
headers: requestHeaders(appCode)
|
||||||
});
|
});
|
||||||
const payload = await readJSON(response);
|
const payload = await readJSON(response);
|
||||||
if (isAuthExpired(response, payload)) {
|
if (isAuthExpired(response, payload)) {
|
||||||
redirectToLogin();
|
redirectToLogin(window);
|
||||||
throw new Error(payload.message || "访问凭证已失效");
|
throw new Error(payload.message || "访问凭证已失效");
|
||||||
}
|
}
|
||||||
if (!response.ok || payload.code !== 0) {
|
if (!response.ok || payload.code !== 0) {
|
||||||
@ -41,6 +77,18 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, start
|
|||||||
return payload.data;
|
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) {
|
function requestHeaders(appCode) {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const token = window.localStorage.getItem(TOKEN_KEY);
|
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;
|
return response.status === 401 || Number(payload?.code) === 40100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectToLogin() {
|
export function redirectToLogin(targetWindow = window) {
|
||||||
window.localStorage.removeItem(TOKEN_KEY);
|
targetWindow.localStorage.removeItem(TOKEN_KEY);
|
||||||
if (window.location.pathname !== "/login") {
|
if (targetWindow.location.pathname !== "/login") {
|
||||||
window.location.replace("/login");
|
targetWindow.location.replace("/login");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAppCode(value) {
|
function normalizeAppCode(value) {
|
||||||
return String(value || "").trim().toLowerCase();
|
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"],
|
color: ["#1bd8f2", "#20c9aa", "#4f8cff", "#f2f6ff"],
|
||||||
grid: { bottom: 30, left: 54, right: 54, top: 44 },
|
grid: { bottom: 30, left: 54, right: 54, top: 44 },
|
||||||
legend: [
|
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 }
|
{ 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" },
|
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: [
|
series: [
|
||||||
{ barGap: "-100%", barWidth: 28, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
|
{ 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.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)),
|
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
|
||||||
lineStyle: { color: "#f2f6ff", shadowBlur: 10, shadowColor: "rgba(242,246,255,.42)", width: 3 },
|
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 { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent } from "../utils/format.js";
|
||||||
import { Panel } from "./Panel.jsx";
|
import { Panel } from "./Panel.jsx";
|
||||||
|
|
||||||
|
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: "country", label: "国家表现" },
|
{ key: "country", label: "国家表现" },
|
||||||
{ key: "gift", label: "礼物排行" },
|
{ key: "gift", label: "礼物排行" },
|
||||||
{ key: "game", 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 [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 (
|
return (
|
||||||
<Panel className="panel--table" title={<TableTabs activeTab={activeTab} onChange={setActiveTab} />}>
|
<Panel className="panel--table" title={<TableTabs activeTab={activeTab} onChange={setActiveTab} />}>
|
||||||
<div className="country-table-wrap">
|
<div className="country-table-wrap">
|
||||||
{activeTab === "country" ? <CountryTable items={items} /> : null}
|
{activeTab === "country" ? (
|
||||||
{activeTab === "gift" ? <GiftTable items={giftRanking} /> : null}
|
<CountryTable
|
||||||
{activeTab === "game" ? <GameTable items={gameRanking} /> : null}
|
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>
|
</div>
|
||||||
</Panel>
|
</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 (
|
return (
|
||||||
<table className="country-table country-table--wide">
|
<table className="country-table country-table--wide">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>国家</th>
|
{countryColumns.map((column) => (
|
||||||
<th>访客</th>
|
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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}>
|
<tr key={item.country}>
|
||||||
<td>{index + 1}</td>
|
<td>{index + 1}</td>
|
||||||
<td>{item.flag ? <i className="table-flag">{item.flag}</i> : null}{item.country}</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 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 (
|
return (
|
||||||
<table className="country-table">
|
<table className="country-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>礼物</th>
|
{giftColumns.map((column) => (
|
||||||
<th>金币消费</th>
|
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
|
||||||
<th>占比</th>
|
))}
|
||||||
<th>近 7 日</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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}>
|
<tr key={item.name}>
|
||||||
<td>{index + 1}</td>
|
<td>{index + 1}</td>
|
||||||
<td>{item.name}</td>
|
<td>{item.name}</td>
|
||||||
<td>{formatCoin(item.value)}</td>
|
<td>{formatCoin(item.value)}</td>
|
||||||
<td>{formatPercent(item.value / total)}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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 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 (
|
return (
|
||||||
<table className="country-table">
|
<table className="country-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>游戏</th>
|
{gameColumns.map((column) => (
|
||||||
<th>流水 (USD)</th>
|
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
|
||||||
<th>利润率</th>
|
))}
|
||||||
<th>占比</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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}`}>
|
<tr key={`${item.platform_code || "game"}-${item.game_id}`}>
|
||||||
<td>{index + 1}</td>
|
<td>{index + 1}</td>
|
||||||
<td>{item.game_id}</td>
|
<td>{item.game_id}</td>
|
||||||
@ -134,3 +238,85 @@ function GameTable({ items }) {
|
|||||||
</table>
|
</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 CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
import { appOptions, countryOptions, regionOptions } from "../config/options.js";
|
import { lastDaysRange, todayRange } from "../utils/time.js";
|
||||||
import { lastDaysRange } 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 = [
|
const rangeOptions = [
|
||||||
|
{ label: "今日", value: "today", getRange: () => todayRange() },
|
||||||
{ label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) },
|
{ label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) },
|
||||||
{ label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) },
|
{ label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) },
|
||||||
{ label: "近 30 日", value: "30d", getRange: () => lastDaysRange(30) },
|
{ 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 minutes = Array.from({ length: 60 }, (_, index) => pad2(index));
|
||||||
const seconds = minutes;
|
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 [openControl, setOpenControl] = useState("");
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const appLabel = appOptions.find((item) => item.code === appCode)?.label || appCode;
|
const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode;
|
||||||
const regionLabel = regionOptions.find((item) => item.id === regionId)?.label || "全部区域";
|
const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域";
|
||||||
const filteredCountries = useMemo(() => {
|
const filteredCountries = useMemo(() => {
|
||||||
if (regionId === "all") {
|
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
|
||||||
return countryOptions;
|
return countryOptions;
|
||||||
}
|
}
|
||||||
return countryOptions.filter((item) => item.id === 0 || item.regionId === regionId);
|
return countryOptions.filter((item) => Number(item.id) === 0 || item.regionIds?.includes(String(regionId)) || item.regionId === regionId);
|
||||||
}, [regionId]);
|
}, [countryOptions, regionId, regionOptions]);
|
||||||
const countryLabel = filteredCountries.find((item) => item.id === countryId)?.label || "全部国家";
|
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
|
||||||
const activeRange = rangeOptions.find((item) => {
|
const activeRange = rangeOptions.find((item) => {
|
||||||
const next = item.getRange();
|
const next = item.getRange();
|
||||||
return sameRange(next, range);
|
return sameRange(next, range);
|
||||||
@ -65,6 +80,7 @@ export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange,
|
|||||||
icon={<span className="control-glyph">APP</span>}
|
icon={<span className="control-glyph">APP</span>}
|
||||||
isOpen={openControl === "app"}
|
isOpen={openControl === "app"}
|
||||||
label="App"
|
label="App"
|
||||||
|
loading={filtersLoading}
|
||||||
onSelect={(value) => selectItem(onAppChange, value)}
|
onSelect={(value) => selectItem(onAppChange, value)}
|
||||||
onToggle={() => toggleControl("app")}
|
onToggle={() => toggleControl("app")}
|
||||||
options={appOptions.map((item) => ({ label: item.label, value: item.code }))}
|
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" />}
|
icon={<PublicOutlined fontSize="small" />}
|
||||||
isOpen={openControl === "region"}
|
isOpen={openControl === "region"}
|
||||||
label="区域"
|
label="区域"
|
||||||
|
loading={filtersLoading}
|
||||||
onSelect={(value) => selectItem(onRegionChange, value)}
|
onSelect={(value) => selectItem(onRegionChange, value)}
|
||||||
onToggle={() => toggleControl("region")}
|
onToggle={() => toggleControl("region")}
|
||||||
options={regionOptions}
|
options={regionOptions}
|
||||||
value={regionId}
|
value={regionId}
|
||||||
valueLabel={regionLabel}
|
valueLabel={regionLabel}
|
||||||
/>
|
/>
|
||||||
<FilterMenu
|
<SearchableFilterMenu
|
||||||
icon={<span className="control-glyph">CN</span>}
|
icon={<span className="control-glyph">CN</span>}
|
||||||
isOpen={openControl === "country"}
|
isOpen={openControl === "country"}
|
||||||
label="国家"
|
label="国家"
|
||||||
|
loading={filtersLoading}
|
||||||
onSelect={(value) => selectItem(onCountryChange, Number(value))}
|
onSelect={(value) => selectItem(onCountryChange, Number(value))}
|
||||||
onToggle={() => toggleControl("country")}
|
onToggle={() => toggleControl("country")}
|
||||||
options={filteredCountries}
|
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 (
|
return (
|
||||||
<div className="filter-control">
|
<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}
|
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||||
<span className="filter-copy">
|
<span className="filter-copy">
|
||||||
<small>{label}</small>
|
<small>{label}</small>
|
||||||
@ -117,11 +135,65 @@ function FilterMenu({ icon, isOpen, label, onSelect, onToggle, options, value, v
|
|||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div className="filter-popover" role="listbox">
|
<div className="filter-popover" role="listbox">
|
||||||
{options.map((item) => (
|
{loading ? <span className="filter-empty">加载中...</span> : null}
|
||||||
<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">
|
{!loading && !options.length ? <span className="filter-empty">暂无选项</span> : null}
|
||||||
{item.label}
|
{!loading
|
||||||
</button>
|
? 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,25 +2,29 @@ import { formatNumber, formatPercent } from "../utils/format.js";
|
|||||||
import { MiniStat } from "./MiniStat.jsx";
|
import { MiniStat } from "./MiniStat.jsx";
|
||||||
import { Panel } from "./Panel.jsx";
|
import { Panel } from "./Panel.jsx";
|
||||||
|
|
||||||
export function FunnelPanel({ funnel, sideMetrics }) {
|
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
|
||||||
return (
|
return (
|
||||||
<Panel title="ARPU 转化漏斗">
|
<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">
|
<div className="funnel-stack">
|
||||||
{funnel.map((item, index) => (
|
{loading
|
||||||
<div className="funnel-stage-row" key={item.name}>
|
? Array.from({ length: 4 }).map((_, index) => <span className={`skeleton-block skeleton-funnel-stage skeleton-funnel-stage--${index + 1}`} key={index} />)
|
||||||
<div className={`funnel-stage funnel-stage--${index + 1}`}>
|
: funnel.map((item, index) => (
|
||||||
<span>{item.name}</span>
|
<div className="funnel-stage-row" key={item.name}>
|
||||||
<strong>{formatNumber(item.value)}</strong>
|
<div className={`funnel-stage funnel-stage--${index + 1}`}>
|
||||||
</div>
|
<span>{item.name}</span>
|
||||||
<b>{formatPercent(item.rate)}</b>
|
<strong>{formatNumber(item.value)}</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<b>{formatPercent(item.rate)}</b>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="side-metrics">
|
<div className="side-metrics">
|
||||||
{sideMetrics.map((item) => (
|
{loading
|
||||||
<MiniStat key={item.label} item={item} />
|
? 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>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@ -1,27 +1,85 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { EChart } from "../charts/EChart.jsx";
|
import { EChart } from "../charts/EChart.jsx";
|
||||||
import { createGeoOption } from "../charts/options/createGeoOption.js";
|
import { createGeoOption } from "../charts/options/createGeoOption.js";
|
||||||
import { formatMoney, moneyMajor } from "../utils/format.js";
|
import { formatMoney, moneyMajor } from "../utils/format.js";
|
||||||
|
import { GlobeMap } from "./GlobeMap.jsx";
|
||||||
import { Panel } from "./Panel.jsx";
|
import { Panel } from "./Panel.jsx";
|
||||||
import { RankList } from "./RankList.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 (
|
return (
|
||||||
<Panel className="panel--map" title="全球概览">
|
<>
|
||||||
<div className="geo-layout">
|
<Panel className="panel--map" title="全球概览">
|
||||||
<div className="geo-map">
|
{loading ? <GlobalOverviewSkeleton /> : (
|
||||||
<EChart className="geo-chart" option={createGeoOption(countryBreakdown)} />
|
<div className="geo-layout">
|
||||||
<div className="geo-scale">
|
<div className="geo-map">
|
||||||
<span>高</span>
|
{rankedCountries.length ? <EChart className="geo-chart" option={createGeoOption(rankedCountries)} /> : <div className="panel-empty">暂无国家数据</div>}
|
||||||
<i />
|
<div className="geo-scale">
|
||||||
<span>低</span>
|
<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>
|
)}
|
||||||
<div className="rank-block">
|
</Panel>
|
||||||
<RankList items={topCountries} title="充值国家排行" valueFormatter={formatRankMoney} />
|
{globeOpen ? <CountryGlobeDialog items={countryBreakdown} onClose={() => setGlobeOpen(false)} /> : null}
|
||||||
<button className="all-countries-button" type="button">查看全部国家</button>
|
</>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</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 Icon = item.icon;
|
||||||
const UnitIcon = item.unitIcon;
|
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 (
|
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}
|
{Icon ? <div className="metric-icon"><Icon /></div> : null}
|
||||||
<div className="metric-content">
|
<div className="metric-content">
|
||||||
<div className="metric-label-row">
|
<div className="metric-label-row">
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export function RankList({ items, title, valueFormatter }) {
|
|||||||
return (
|
return (
|
||||||
<div className="rank-list">
|
<div className="rank-list">
|
||||||
<div className="rank-title">{title}</div>
|
<div className="rank-title">{title}</div>
|
||||||
|
{!items.length ? <div className="rank-empty">暂无数据</div> : null}
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div className="rank-row" key={item.country}>
|
<div className="rank-row" key={item.country}>
|
||||||
<span>{index + 1}</span>
|
<span>{index + 1}</span>
|
||||||
|
|||||||
@ -2,10 +2,19 @@ import { EChart } from "../charts/EChart.jsx";
|
|||||||
import { createRevenueOption } from "../charts/options/createRevenueOption.js";
|
import { createRevenueOption } from "../charts/options/createRevenueOption.js";
|
||||||
import { Panel } from "./Panel.jsx";
|
import { Panel } from "./Panel.jsx";
|
||||||
|
|
||||||
export function RevenueTrendPanel({ revenueSeries }) {
|
export function RevenueTrendPanel({ loading = false, revenueSeries }) {
|
||||||
return (
|
return (
|
||||||
<Panel title="充值趋势">
|
<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>
|
</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("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripCountryFlagPrefix(value) {
|
||||||
|
return String(value || "").replace(/^(?:\s*[\u{1F1E6}-\u{1F1FF}]{2})+\s*/u, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeKey(value) {
|
function normalizeKey(value) {
|
||||||
return String(value || "").trim().toLowerCase();
|
return String(value || "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
|
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 { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
|
||||||
import { numberValue, ratio } from "../utils/number.js";
|
import { numberValue, ratio } from "../utils/number.js";
|
||||||
import { formatDateTime } from "../utils/time.js";
|
import { formatDateTime } from "../utils/time.js";
|
||||||
import { sampleOverview } from "./sampleOverview.js";
|
|
||||||
|
|
||||||
export function createDashboardModel(overview, { appCode, countryId, preview }) {
|
export function createDashboardModel(overview, { appCode, countryId, range }) {
|
||||||
const source = overview || sampleOverview;
|
const source = overview || {};
|
||||||
|
const comparisonCaption = isTodayRange(range) ? "与昨日相比" : "近 7 日";
|
||||||
const recharge = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
|
const recharge = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
|
||||||
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
||||||
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
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 arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
|
||||||
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
||||||
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
||||||
const luckyGiftTurnover = readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
|
const luckyGiftPools = normalizeLuckyGiftPools(source);
|
||||||
const luckyGiftPayout = readNumber(source, "lucky_gift_payout", "luckyGiftPayout");
|
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
|
||||||
const luckyGiftProfit = readNumber(source, "lucky_gift_profit", "luckyGiftProfit");
|
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 gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
|
||||||
const gameProfit = readNumber(source, "game_profit", "gameProfit");
|
const gameProfit = readNumber(source, "game_profit", "gameProfit");
|
||||||
const countryBreakdown = normalizeCountries(source, countryId);
|
const countryBreakdown = normalizeCountries(source, countryId);
|
||||||
@ -28,12 +30,12 @@ export function createDashboardModel(overview, { appCode, countryId, preview })
|
|||||||
return {
|
return {
|
||||||
appCode,
|
appCode,
|
||||||
businessKpis: [
|
businessKpis: [
|
||||||
coinMetric("礼物消费", giftCoinSpent, preview ? "+16.80%" : ""),
|
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption),
|
||||||
coinMetric("幸运礼物流水", luckyGiftTurnover, preview ? "+12.93%" : ""),
|
coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }),
|
||||||
coinMetric("幸运礼物返奖", luckyGiftPayout, preview ? "+8.15%" : ""),
|
coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }),
|
||||||
coinMetric("幸运礼物利润", luckyGiftProfit, preview ? "+4.66%" : ""),
|
coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }),
|
||||||
coinMetric("游戏流水", gameTurnover, preview ? "+14.20%" : ""),
|
coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption),
|
||||||
coinMetric("游戏利润", gameProfit, preview ? "+18.18%" : "")
|
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption)
|
||||||
],
|
],
|
||||||
countryBreakdown,
|
countryBreakdown,
|
||||||
funnel: [
|
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) }
|
{ name: "充值用户", rate: ratio(numberValue(source.lucky_gift_payers), activeUsers), value: numberValue(source.lucky_gift_payers) }
|
||||||
],
|
],
|
||||||
gameMetrics: [
|
gameMetrics: [
|
||||||
{ caption: "近 7 日 +14.20%", label: "流水", value: formatWholeMoney(source.game_turnover) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) },
|
||||||
{ caption: "近 7 日 +9.63%", label: "派奖", value: formatWholeMoney(source.game_payout) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_payout_delta_rate", "gamePayoutDeltaRate")), label: "派奖", value: formatWholeMoney(source.game_payout) },
|
||||||
{ caption: "近 7 日 -3.58%", label: "退款", value: formatWholeMoney(source.game_refund) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_refund_delta_rate", "gameRefundDeltaRate")), label: "退款", value: formatWholeMoney(source.game_refund) },
|
||||||
{ caption: "近 7 日 +18.18%", label: "利润", value: formatWholeMoney(source.game_profit) }
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate")), label: "利润", value: formatWholeMoney(source.game_profit) }
|
||||||
],
|
],
|
||||||
gameRanking,
|
gameRanking,
|
||||||
giftRanking: normalizeGiftRanking(source),
|
giftRanking: normalizeGiftRanking(source),
|
||||||
kpis: [
|
kpis: [
|
||||||
{ caption: "近 7 日", delta: preview ? "+18.10%" : "", icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
|
{ caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
|
||||||
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) },
|
{ caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) },
|
||||||
{ caption: "近 7 日", delta: preview ? "+10.77%" : "", icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
|
{ caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
|
||||||
{ caption: "近 7 日", delta: preview ? "+12.92%" : "", icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
|
{ caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
|
||||||
{ caption: "近 7 日", delta: preview ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) },
|
{ caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), 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, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) }
|
||||||
],
|
],
|
||||||
luckyMetrics: [
|
luckyMetrics: [
|
||||||
{ caption: "近 7 日 +10.62%", label: "流水", value: formatWholeMoney(source.lucky_gift_turnover) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) },
|
||||||
{ caption: "近 7 日 +8.15%", label: "返奖", value: formatWholeMoney(source.lucky_gift_payout) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate")), label: "返奖", value: formatWholeMoney(luckyGiftPayout) },
|
||||||
{ caption: "近 7 日 +4.66%", label: "利润", value: formatWholeMoney(source.lucky_gift_profit) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate")), label: "利润", value: formatWholeMoney(luckyGiftProfit) },
|
||||||
{ caption: "近 7 日 -2.01pp", label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_rate_delta_pp", "luckyGiftPayoutRateDeltaPp"), "pp"), 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_profit_rate_delta_pp", "luckyGiftProfitRateDeltaPp"), "pp"), label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
|
||||||
],
|
],
|
||||||
|
luckyGiftPools,
|
||||||
payoutDistribution,
|
payoutDistribution,
|
||||||
revenueSeries,
|
revenueSeries,
|
||||||
roomMetrics: [
|
roomMetrics: [
|
||||||
{ caption: "近 7 日 +16.80%", label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), 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, "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: [
|
sideMetrics: [
|
||||||
{ caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(arpu) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
|
||||||
{ caption: "近 7 日 +0.18pp", label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
|
||||||
{ caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(arppu) }
|
{ 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())
|
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function coinMetric(label, value, delta) {
|
function coinMetric(label, value, delta, caption, extra = {}) {
|
||||||
return { caption: "近 7 日", delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value) };
|
return { caption, delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value), ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionWithDelta(label, delta) {
|
||||||
|
return `${label} ${delta}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumber(source, ...keys) {
|
function readNumber(source, ...keys) {
|
||||||
@ -94,7 +101,49 @@ function readNumber(source, ...keys) {
|
|||||||
return 0;
|
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) {
|
function normalizeRevenueSeries(source) {
|
||||||
|
if (!source || !Object.keys(source).length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (Array.isArray(source.daily_series) && source.daily_series.length) {
|
if (Array.isArray(source.daily_series) && source.daily_series.length) {
|
||||||
return source.daily_series.map((item) => ({
|
return source.daily_series.map((item) => ({
|
||||||
coin_seller: numberValue(item.coin_seller ?? item.coinSeller),
|
coin_seller: numberValue(item.coin_seller ?? item.coinSeller),
|
||||||
@ -119,19 +168,15 @@ function normalizeRevenueSeries(source) {
|
|||||||
|
|
||||||
function normalizeCountries(source, countryId) {
|
function normalizeCountries(source, countryId) {
|
||||||
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
|
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
|
||||||
const rows =
|
const rows = raw || (countryId ? [{
|
||||||
raw ||
|
active_users: source.active_users,
|
||||||
[
|
arppu_usd_minor: source.arppu_usd_minor,
|
||||||
{
|
country: `国家 ${countryId}`,
|
||||||
active_users: source.active_users,
|
paid_users: source.paid_users,
|
||||||
arppu_usd_minor: source.arppu_usd_minor,
|
recharge_usd_minor: source.recharge_usd_minor,
|
||||||
country: countryId ? `国家 ${countryId}` : "全部区域",
|
x: 56,
|
||||||
paid_users: source.paid_users,
|
y: 52
|
||||||
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;
|
const totalRecharge = rows.reduce((sum, item) => sum + numberValue(item.recharge_usd_minor), 0) || 1;
|
||||||
return rows
|
return rows
|
||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
@ -142,7 +187,7 @@ function normalizeCountries(source, countryId) {
|
|||||||
|
|
||||||
function normalizeCountryRow(item, index, totalRecharge) {
|
function normalizeCountryRow(item, index, totalRecharge) {
|
||||||
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
|
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 meta = resolveCountryMeta({ code, name: country });
|
||||||
const recharge = numberValue(item.recharge_usd_minor);
|
const recharge = numberValue(item.recharge_usd_minor);
|
||||||
return {
|
return {
|
||||||
@ -168,21 +213,60 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
|||||||
|
|
||||||
function normalizeGiftRanking(source) {
|
function normalizeGiftRanking(source) {
|
||||||
if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) {
|
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) {
|
function normalizeDistribution(source) {
|
||||||
if (Array.isArray(source.payout_distribution) && source.payout_distribution.length) {
|
if (Array.isArray(source.payout_distribution) && source.payout_distribution.length) {
|
||||||
return source.payout_distribution.map((item) => ({ name: item.name || "-", value: numberValue(item.value) }));
|
return source.payout_distribution.map((item) => ({ name: item.name || "-", value: numberValue(item.value) }));
|
||||||
}
|
}
|
||||||
|
if (!source || !Object.keys(source).length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
|
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
|
||||||
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) }
|
{ 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) {
|
function normalizeGameRanking(source) {
|
||||||
if (Array.isArray(source.game_ranking) && source.game_ranking.length) {
|
if (Array.isArray(source.game_ranking) && source.game_ranking.length) {
|
||||||
return source.game_ranking.map((item) => ({
|
return source.game_ranking.map((item) => ({
|
||||||
@ -192,7 +276,7 @@ function normalizeGameRanking(source) {
|
|||||||
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin)
|
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) {
|
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;
|
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 {
|
.metric-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@ -119,10 +130,68 @@
|
|||||||
font-weight: 760;
|
font-weight: 760;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #f5a623;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-delta--down b {
|
.metric-delta--down b {
|
||||||
color: #f5a623;
|
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 {
|
.side-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@ -24,6 +24,24 @@
|
|||||||
min-height: 0;
|
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 {
|
.donut-chart {
|
||||||
height: 182px;
|
height: 182px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,6 +115,11 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-trigger:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-icon,
|
.filter-icon,
|
||||||
.filter-chevron {
|
.filter-chevron {
|
||||||
color: #cbd9e9;
|
color: #cbd9e9;
|
||||||
@ -279,6 +284,70 @@
|
|||||||
color: #f3fbff;
|
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 {
|
.date-time-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 10px);
|
top: calc(100% + 10px);
|
||||||
@ -589,6 +658,32 @@
|
|||||||
align-items: center;
|
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 {
|
.business-metric-grid .metric-label-row {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
|
|||||||
@ -103,6 +103,16 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-empty {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
place-items: center;
|
||||||
|
color: #7f9bb7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.globe-map {
|
.globe-map {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@ -180,6 +190,16 @@
|
|||||||
min-width: 0;
|
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 {
|
.rank-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20px 20px minmax(0, 1fr) 62px;
|
grid-template-columns: 20px 20px minmax(0, 1fr) 62px;
|
||||||
@ -231,9 +251,266 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(7, 35, 58, 0.72);
|
background: rgba(7, 35, 58, 0.72);
|
||||||
color: #2fe8f3;
|
color: #2fe8f3;
|
||||||
|
cursor: pointer;
|
||||||
font-size: 14px;
|
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,
|
.rank-row b,
|
||||||
.bar-row b,
|
.bar-row b,
|
||||||
.game-row b,
|
.game-row b,
|
||||||
@ -345,3 +622,25 @@
|
|||||||
margin-inline: 54px;
|
margin-inline: 54px;
|
||||||
background: linear-gradient(180deg, rgba(32, 203, 161, 0.94), rgba(18, 168, 137, 0.92));
|
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;
|
font-weight: 720;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.country-table th.is-sortable {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.country-table th:nth-child(2),
|
.country-table th:nth-child(2),
|
||||||
.country-table td:nth-child(2) {
|
.country-table td:nth-child(2) {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@ -48,6 +52,44 @@
|
|||||||
text-align: center;
|
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 {
|
.table-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
@ -73,3 +115,21 @@
|
|||||||
color: #e9f6ff;
|
color: #e9f6ff;
|
||||||
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
|
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) {
|
export function lastDaysRange(days) {
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
const start = new Date(end);
|
const start = new Date(end);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user