diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index 7a7d8bc..9abaf81 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -1,55 +1,112 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { fetchCountryStatisticsBreakdown, fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js"; import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx"; import { DatabiHeader } from "./components/DatabiHeader.jsx"; import { FunnelPanel } from "./components/FunnelPanel.jsx"; import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx"; +import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx"; import { MetricCard } from "./components/MetricCard.jsx"; import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx"; import { createDashboardModel } from "./data/createDashboardModel.js"; -import { sampleOverview } from "./data/sampleOverview.js"; -import { lastDaysRange, rangeEndMs, rangeStartMs } from "./utils/time.js"; +import { rangeEndMs, rangeStartMs, todayRange } from "./utils/time.js"; + +const initialFilterOptions = { + appOptions: [], + countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }], + regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }] +}; export function DatabiApp() { - const initialRange = useMemo(() => lastDaysRange(7), []); + const initialRange = useMemo(() => todayRange(), []); const [range, setRange] = useState(initialRange); const [countryId, setCountryId] = useState(0); const [regionId, setRegionId] = useState("all"); const [appCode, setAppCode] = useState(() => getCurrentAppCode()); - const [overview, setOverview] = useState(sampleOverview); - const [loading, setLoading] = useState(false); + const [filterOptions, setFilterOptions] = useState(initialFilterOptions); + const [filtersLoading, setFiltersLoading] = useState(false); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [preview, setPreview] = useState(true); + const [poolModalOpen, setPoolModalOpen] = useState(false); + const overviewRequestIdRef = useRef(0); + const scopedCountries = useMemo(() => { + return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId)); + }, [filterOptions.countryOptions, regionId]); const handleAppChange = useCallback((value) => { setAppCode(setCurrentAppCode(value)); }, []); const handleRegionChange = useCallback((value) => { - setRegionId(value); + setRegionId(String(value || "all")); setCountryId(0); }, []); + const loadFilterOptions = useCallback(async () => { + setFiltersLoading(true); + try { + const options = await fetchFilterOptions({ appCode }); + setFilterOptions(options); + const nextAppCode = resolveSelectedAppCode(options.appOptions, appCode); + if (nextAppCode !== appCode) { + setAppCode(setCurrentAppCode(nextAppCode)); + } + setRegionId((current) => (options.regionOptions.some((item) => String(item.id) === String(current)) ? current : "all")); + setCountryId((current) => (options.countryOptions.some((item) => Number(item.id) === Number(current)) ? current : 0)); + } catch (err) { + setError(err.message || "筛选项加载失败"); + } finally { + setFiltersLoading(false); + } + }, [appCode]); + const loadOverview = useCallback(async () => { + const requestId = overviewRequestIdRef.current + 1; + overviewRequestIdRef.current = requestId; setLoading(true); setError(""); + setOverview(null); try { + const startMs = rangeStartMs(range); + const endMs = rangeEndMs(range); const data = await fetchStatisticsOverview({ appCode, countryId, - endMs: rangeEndMs(range), - startMs: rangeStartMs(range) + endMs, + regionId, + startMs }); - setOverview(data || sampleOverview); - setPreview(false); + let nextOverview = data || {}; + if (Number(countryId) === 0 && scopedCountries.length && !hasCountryBreakdown(nextOverview)) { + const countryBreakdown = await fetchCountryStatisticsBreakdown({ + appCode, + countries: scopedCountries, + endMs, + regionId, + startMs + }); + if (countryBreakdown.length) { + nextOverview = { ...nextOverview, country_breakdown: countryBreakdown }; + } + } + if (requestId === overviewRequestIdRef.current) { + setOverview(nextOverview); + } } catch (err) { - setOverview(sampleOverview); - setPreview(true); - setError(err.message || "请求失败"); + if (requestId === overviewRequestIdRef.current) { + setOverview(null); + setError(err.message || "请求失败"); + } } finally { - setLoading(false); + if (requestId === overviewRequestIdRef.current) { + setLoading(false); + } } - }, [appCode, countryId, range]); + }, [appCode, countryId, range, regionId, scopedCountries]); + + useEffect(() => { + void loadFilterOptions(); + }, [loadFilterOptions]); useEffect(() => { void loadOverview(); @@ -57,47 +114,82 @@ export function DatabiApp() { return () => window.clearInterval(timer); }, [loadOverview]); - const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, preview }), [appCode, countryId, overview, preview]); + const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range }), [appCode, countryId, overview, range]); + const showSkeleton = loading && !overview; + const openLuckyGiftPools = useCallback(() => { + setPoolModalOpen(true); + }, []); return (
{model.kpis.map((item) => ( - + ))}
{model.businessKpis.map((item) => ( - + ))}
- - - + + +
- + + {poolModalOpen ? setPoolModalOpen(false)} /> : null}
); } + +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); +} diff --git a/databi/src/api.js b/databi/src/api.js index 27f142b..a3947a3 100644 --- a/databi/src/api.js +++ b/databi/src/api.js @@ -1,4 +1,7 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; +import { API_OPERATIONS, apiEndpointPath } from "../../src/shared/api/generated/endpoints.ts"; +import { countryFlag, stripCountryFlagPrefix } from "./data/countryMeta.js"; + +const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api"; const TOKEN_KEY = "hyapp-admin.access-token"; const APP_CODE_KEY = "hyapp-admin.app-code"; const APP_CODE_HEADER = "X-App-Code"; @@ -13,26 +16,59 @@ export function setCurrentAppCode(value) { return appCode; } -export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) { - const query = new URLSearchParams(); - query.set("app_code", normalizeAppCode(appCode) || "lalu"); +export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, startMs }) { + const query = { app_code: normalizeAppCode(appCode) || "lalu" }; if (startMs) { - query.set("start_ms", String(startMs)); + query.start_ms = String(startMs); } if (endMs) { - query.set("end_ms", String(endMs)); + query.end_ms = String(endMs); } if (countryId) { - query.set("country_id", String(countryId)); + query.country_id = String(countryId); + } + if (regionId && regionId !== "all") { + query.region_id = String(regionId); } - const response = await fetch(`${API_BASE_URL}/v1/statistics/overview?${query.toString()}`, { + return fetchDatabiData("/v1/statistics/overview", { appCode, query }); +} + +export async function fetchCountryStatisticsBreakdown({ appCode, countries = [], endMs, regionId, startMs }) { + return mapWithConcurrency( + countries.filter((country) => Number(country.id) > 0), + 8, + async (country) => { + const data = await fetchStatisticsOverview({ + appCode, + countryId: country.id, + endMs, + regionId, + startMs + }); + return normalizeCountryStatisticsRow(data, country); + } + ); +} + +export async function fetchFilterOptions({ appCode } = {}) { + const [apps, regions, countries] = await Promise.all([ + fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }), + fetchDatabiData(apiEndpointPath(API_OPERATIONS.listRegions), { appCode, query: { status: "active" } }), + fetchDatabiData(apiEndpointPath(API_OPERATIONS.listCountries), { appCode, query: { enabled: true } }) + ]); + + return normalizeFilterOptions({ apps, countries, regions }); +} + +async function fetchDatabiData(path, { appCode, query } = {}) { + const response = await fetch(buildURL(path, query), { credentials: "include", headers: requestHeaders(appCode) }); const payload = await readJSON(response); if (isAuthExpired(response, payload)) { - redirectToLogin(); + redirectToLogin(window); throw new Error(payload.message || "访问凭证已失效"); } if (!response.ok || payload.code !== 0) { @@ -41,6 +77,18 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, start return payload.data; } +function buildURL(path, query) { + const url = new URL(`${API_BASE_URL}${path}`, window.location.origin); + if (query) { + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + }); + } + return url.toString(); +} + function requestHeaders(appCode) { const headers = {}; const token = window.localStorage.getItem(TOKEN_KEY); @@ -66,17 +114,144 @@ async function readJSON(response) { } } -function isAuthExpired(response, payload) { +export function isAuthExpired(response, payload) { return response.status === 401 || Number(payload?.code) === 40100; } -function redirectToLogin() { - window.localStorage.removeItem(TOKEN_KEY); - if (window.location.pathname !== "/login") { - window.location.replace("/login"); +export function redirectToLogin(targetWindow = window) { + targetWindow.localStorage.removeItem(TOKEN_KEY); + if (targetWindow.location.pathname !== "/login") { + targetWindow.location.replace("/login"); } } function normalizeAppCode(value) { return String(value || "").trim().toLowerCase(); } + +function normalizeFilterOptions({ apps, countries, regions }) { + const regionItems = normalizeList(regions); + const regionOptions = [ + { countryCodes: [], id: "all", label: "全部区域", value: "all" }, + ...regionItems + .filter((region) => String(region.regionCode || "").toUpperCase() !== "GLOBAL") + .map((region) => { + const id = String(region.regionId ?? region.id ?? region.regionCode ?? ""); + return { + countryCodes: normalizeCodes(region.countries), + id, + label: [region.name, region.regionCode].filter(Boolean).join(" · ") || `区域 ${id}`, + value: id + }; + }) + .filter((region) => region.id) + ]; + const countryRegionIds = buildCountryRegionIndex(regionOptions); + const countryOptions = [ + { countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }, + ...normalizeList(countries) + .map((country) => normalizeCountryOption(country, countryRegionIds)) + .filter(Boolean) + ]; + const appOptions = normalizeList(apps) + .map((app) => { + const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code); + return { + code, + label: app.appName || app.app_name || app.name || code, + value: code + }; + }) + .filter((app) => app.code); + + return { + appOptions, + countryOptions, + regionOptions + }; +} + +function normalizeList(value) { + if (Array.isArray(value)) { + return value; + } + if (Array.isArray(value?.items)) { + return value.items; + } + if (Array.isArray(value?.list)) { + return value.list; + } + return []; +} + +function normalizeCodes(value) { + return Array.isArray(value) ? value.map((item) => String(item || "").trim().toUpperCase()).filter(Boolean) : []; +} + +function buildCountryRegionIndex(regions) { + const index = new Map(); + regions.forEach((region) => { + if (region.id === "all") { + return; + } + region.countryCodes.forEach((code) => { + const ids = index.get(code) || []; + ids.push(region.id); + index.set(code, ids); + }); + }); + return index; +} + +function normalizeCountryOption(country, countryRegionIds) { + const countryCode = String(country.countryCode ?? country.country_code ?? country.code ?? "").trim().toUpperCase(); + const id = Number(country.countryId ?? country.country_id ?? country.id ?? 0); + if (!countryCode && !id) { + return null; + } + const rawName = country.countryDisplayName || country.country_display_name || country.countryName || country.country_name || country.name || countryCode; + const name = stripCountryFlagPrefix(rawName); + const flag = country.flag || countryFlag(countryCode); + const label = [flag, name].filter(Boolean).join(" "); + const searchText = [flag, rawName, name, countryCode, country.isoAlpha3, country.iso_alpha3, country.phoneCountryCode, country.phone_country_code] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return { + countryCode, + flag, + id, + label, + name, + regionIds: countryRegionIds.get(countryCode) || [], + searchText, + value: id + }; +} + +function normalizeCountryStatisticsRow(data, country) { + const source = data || {}; + const row = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown[0] : source; + return { + ...row, + country: stripCountryFlagPrefix(row.country || row.country_name || country.name || country.label), + country_code: row.country_code || row.countryCode || country.countryCode, + country_id: row.country_id || row.countryId || country.id + }; +} + +async function mapWithConcurrency(items, limit, callback) { + const results = []; + let index = 0; + + async function worker() { + while (index < items.length) { + const currentIndex = index; + index += 1; + results[currentIndex] = await callback(items[currentIndex], currentIndex); + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); + return results.filter(Boolean); +} diff --git a/databi/src/charts/options/createRevenueOption.js b/databi/src/charts/options/createRevenueOption.js index 21deb43..2fd46c4 100644 --- a/databi/src/charts/options/createRevenueOption.js +++ b/databi/src/charts/options/createRevenueOption.js @@ -9,7 +9,7 @@ export function createRevenueOption(series) { color: ["#1bd8f2", "#20c9aa", "#4f8cff", "#f2f6ff"], grid: { bottom: 30, left: 54, right: 54, top: 44 }, legend: [ - { data: ["Google", "MifaPay", "金币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 22, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 1 }, + { data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 22, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 1 }, { data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#d8e8f8", fontSize: 12 }, top: 1 } ], tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", extraCssText: "box-shadow: 0 10px 26px rgba(0,0,0,.32);", textStyle: { color: "#e9f6ff" }, trigger: "axis" }, @@ -21,7 +21,7 @@ export function createRevenueOption(series) { series: [ { barGap: "-100%", barWidth: 28, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" }, { barWidth: 28, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" }, - { barWidth: 28, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "金币商", stack: "recharge", type: "bar" }, + { barWidth: 28, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" }, { data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)), lineStyle: { color: "#f2f6ff", shadowBlur: 10, shadowColor: "rgba(242,246,255,.42)", width: 3 }, diff --git a/databi/src/components/CountryPerformanceTable.jsx b/databi/src/components/CountryPerformanceTable.jsx index 704e0bc..4e290da 100644 --- a/databi/src/components/CountryPerformanceTable.jsx +++ b/databi/src/components/CountryPerformanceTable.jsx @@ -1,22 +1,114 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent } from "../utils/format.js"; import { Panel } from "./Panel.jsx"; +const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" }); + const tabs = [ { key: "country", label: "国家表现" }, { key: "gift", label: "礼物排行" }, { key: "game", label: "游戏排行" } ]; -export function CountryPerformanceTable({ gameRanking, giftRanking, items }) { +const defaultSortByTab = { + country: { direction: "desc", key: "recharge_usd_minor", type: "number" }, + gift: { direction: "desc", key: "value", type: "number" }, + game: { direction: "desc", key: "turnover_coin", type: "number" } +}; + +const countryColumns = [ + { key: "country", label: "国家", type: "text", align: "left" }, + { key: "visitors", label: "访客" }, + { key: "active_users", label: "活跃用户" }, + { key: "paid_users", label: "付费用户" }, + { key: "recharge_users", label: "充值用户" }, + { key: "recharge_usd_minor", label: "总充值 (USD)" }, + { key: "new_user_recharge_usd_minor", label: "新用户充值 (USD)" }, + { key: "arpu_usd_minor", label: "ARPU (USD)" }, + { key: "arppu_usd_minor", label: "ARPPU (USD)" }, + { key: "payer_rate", label: "付费转化率" }, + { key: "recharge_conversion_rate", label: "充值转化率" }, + { key: "avg_recharge_usd_minor", label: "平均充值 (USD)" }, + { key: "trend_rate", label: "近 7 日" } +]; + +const countrySortAccessors = { + active_users: (item) => item.active_users, + arppu_usd_minor: (item) => item.arppu_usd_minor, + arpu_usd_minor: (item) => item.arpu_usd_minor, + avg_recharge_usd_minor: (item) => item.avg_recharge_usd_minor, + country: (item) => item.country, + new_user_recharge_usd_minor: (item) => item.new_user_recharge_usd_minor, + paid_users: (item) => item.paid_users, + payer_rate: (item) => item.payer_rate, + recharge_conversion_rate: (item) => item.recharge_conversion_rate, + recharge_users: (item) => item.recharge_users, + recharge_usd_minor: (item) => item.recharge_usd_minor, + trend_rate: (item) => item.trend_rate, + visitors: (item) => item.visitors +}; + +const giftColumns = [ + { key: "name", label: "礼物", type: "text", align: "left" }, + { key: "value", label: "金币消费" }, + { key: "share", label: "占比" }, + { key: "trend_rate", label: "近 7 日" } +]; + +const gameColumns = [ + { key: "game_id", label: "游戏", type: "text", align: "left" }, + { key: "turnover_coin", label: "流水 (金币)" }, + { key: "profit_rate", label: "利润率" }, + { key: "share", label: "占比" } +]; + +export function CountryPerformanceTable({ gameRanking, giftRanking, items, loading = false }) { const [activeTab, setActiveTab] = useState("country"); + const [sortByTab, setSortByTab] = useState(defaultSortByTab); + + const handleSort = (tabKey, columnKey, type = "number") => { + setSortByTab((previous) => { + const current = previous[tabKey]; + const nextDirection = current?.key === columnKey && current.direction === "desc" ? "asc" : "desc"; + + return { + ...previous, + [tabKey]: { + direction: type === "text" && current?.key !== columnKey ? "asc" : nextDirection, + key: columnKey, + type + } + }; + }); + }; return ( }>
- {activeTab === "country" ? : null} - {activeTab === "gift" ? : null} - {activeTab === "game" ? : null} + {activeTab === "country" ? ( + handleSort("country", columnKey, type)} + sortState={sortByTab.country} + /> + ) : null} + {activeTab === "gift" ? ( + handleSort("gift", columnKey, type)} + sortState={sortByTab.gift} + /> + ) : null} + {activeTab === "game" ? ( + handleSort("game", columnKey, type)} + sortState={sortByTab.game} + /> + ) : null}
); @@ -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 ( - - - - - - - - - - - - - + {countryColumns.map((column) => ( + + ))} - {items.map((item, index) => ( + {loading ? : null} + {!loading && !items.length ? : null} + {!loading && sortedItems.map((item, index) => ( @@ -79,27 +165,36 @@ function CountryTable({ items }) { ); } -function GiftTable({ items }) { +function GiftTable({ items, loading, onSort, sortState }) { const total = items.reduce((sum, item) => sum + item.value, 0) || 1; + const giftSortAccessors = useMemo(() => ({ + name: (item) => item.name, + share: (item) => item.value / total, + trend_rate: (item) => item.trend_rate, + value: (item) => item.value + }), [total]); + const sortedItems = useMemo(() => sortItems(items, sortState, giftSortAccessors), [giftSortAccessors, items, sortState]); + return (
#国家访客活跃用户付费用户充值用户总充值 (USD)新用户充值 (USD)ARPU (USD)ARPPU (USD)付费转化率充值转化率平均充值 (USD)近 7 日
{index + 1} {item.flag ? {item.flag} : null}{item.country}
- - - - + {giftColumns.map((column) => ( + + ))} - {items.map((item, index) => ( + {loading ? : null} + {!loading && !items.length ? : null} + {!loading && sortedItems.map((item, index) => ( - + ))} @@ -107,21 +202,30 @@ function GiftTable({ items }) { ); } -function GameTable({ items }) { +function GameTable({ items, loading, onSort, sortState }) { const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1; + const gameSortAccessors = useMemo(() => ({ + game_id: (item) => item.game_id, + profit_rate: (item) => item.profit_rate, + share: (item) => item.turnover_coin / total, + turnover_coin: (item) => item.turnover_coin + }), [total]); + const sortedItems = useMemo(() => sortItems(items, sortState, gameSortAccessors), [gameSortAccessors, items, sortState]); + return (
#礼物金币消费占比近 7 日
{index + 1} {item.name} {formatCoin(item.value)} {formatPercent(item.value / total)}+{formatPercent(0.168 - index * 0.017)}= 0 ? "positive" : "negative"}>{formatSignedPercent(item.trend_rate)}
- - - - + {gameColumns.map((column) => ( + + ))} - {items.map((item, index) => ( + {loading ? : null} + {!loading && !items.length ? : null} + {!loading && sortedItems.map((item, index) => ( @@ -134,3 +238,85 @@ function GameTable({ items }) {
#游戏流水 (USD)利润率占比
{index + 1} {item.game_id}
); } + +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 ( + + + + ); +} + +function SkeletonRows({ colSpan, rows }) { + return Array.from({ length: rows }).map((_, index) => ( + + + + )); +} + +function EmptyRow({ colSpan }) { + return ( + + 暂无数据 + + ); +} + +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; +} diff --git a/databi/src/components/CountryPerformanceTable.test.jsx b/databi/src/components/CountryPerformanceTable.test.jsx new file mode 100644 index 0000000..3ed8939 --- /dev/null +++ b/databi/src/components/CountryPerformanceTable.test.jsx @@ -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(); + + 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]; +} diff --git a/databi/src/components/DatabiHeader.jsx b/databi/src/components/DatabiHeader.jsx index 88a998c..db39bf9 100644 --- a/databi/src/components/DatabiHeader.jsx +++ b/databi/src/components/DatabiHeader.jsx @@ -2,10 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined"; import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; import PublicOutlined from "@mui/icons-material/PublicOutlined"; -import { appOptions, countryOptions, regionOptions } from "../config/options.js"; -import { lastDaysRange } from "../utils/time.js"; +import { lastDaysRange, todayRange } from "../utils/time.js"; +const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }]; +const defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]; const rangeOptions = [ + { label: "今日", value: "today", getRange: () => todayRange() }, { label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) }, { label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) }, { label: "近 30 日", value: "30d", getRange: () => lastDaysRange(30) }, @@ -16,18 +18,31 @@ const hours = Array.from({ length: 24 }, (_, index) => pad2(index)); const minutes = Array.from({ length: 60 }, (_, index) => pad2(index)); const seconds = minutes; -export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange, onRangeChange, onRegionChange, range, regionId }) { +export function DatabiHeader({ + appCode, + appOptions = [], + countryId, + countryOptions = defaultCountryOptions, + filtersLoading = false, + onAppChange, + onCountryChange, + onRangeChange, + onRegionChange, + range, + regionId, + regionOptions = defaultRegionOptions +}) { const [openControl, setOpenControl] = useState(""); const headerRef = useRef(null); - const appLabel = appOptions.find((item) => item.code === appCode)?.label || appCode; - const regionLabel = regionOptions.find((item) => item.id === regionId)?.label || "全部区域"; + const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode; + const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域"; const filteredCountries = useMemo(() => { - if (regionId === "all") { + if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) { return countryOptions; } - return countryOptions.filter((item) => item.id === 0 || item.regionId === regionId); - }, [regionId]); - const countryLabel = filteredCountries.find((item) => item.id === countryId)?.label || "全部国家"; + return countryOptions.filter((item) => Number(item.id) === 0 || item.regionIds?.includes(String(regionId)) || item.regionId === regionId); + }, [countryOptions, regionId, regionOptions]); + const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家"; const activeRange = rangeOptions.find((item) => { const next = item.getRange(); return sameRange(next, range); @@ -65,6 +80,7 @@ export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange, icon={APP} isOpen={openControl === "app"} label="App" + loading={filtersLoading} onSelect={(value) => selectItem(onAppChange, value)} onToggle={() => toggleControl("app")} options={appOptions.map((item) => ({ label: item.label, value: item.code }))} @@ -82,16 +98,18 @@ export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange, icon={} isOpen={openControl === "region"} label="区域" + loading={filtersLoading} onSelect={(value) => selectItem(onRegionChange, value)} onToggle={() => toggleControl("region")} options={regionOptions} value={regionId} valueLabel={regionLabel} /> - CN} isOpen={openControl === "country"} label="国家" + loading={filtersLoading} onSelect={(value) => selectItem(onCountryChange, Number(value))} onToggle={() => toggleControl("country")} options={filteredCountries} @@ -104,10 +122,10 @@ export function DatabiHeader({ appCode, countryId, onAppChange, onCountryChange, ); } -function FilterMenu({ icon, isOpen, label, onSelect, onToggle, options, value, valueLabel }) { +function FilterMenu({ icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) { return (
- {isOpen ? (
- {options.map((item) => ( - - ))} + {loading ? 加载中... : null} + {!loading && !options.length ? 暂无选项 : null} + {!loading + ? options.map((item) => ( + + )) + : null} +
+ ) : null} +
+ ); +} + +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 ( +
+ + {isOpen ? ( +
+ + {loading ? 加载中... : null} + {!loading && !filteredOptions.length ? 没有匹配国家 : null} + {!loading && filteredOptions.length ? ( +
+ {filteredOptions.map((item) => ( + + ))} +
+ ) : null}
) : null}
diff --git a/databi/src/components/FunnelPanel.jsx b/databi/src/components/FunnelPanel.jsx index 622d905..5b272d2 100644 --- a/databi/src/components/FunnelPanel.jsx +++ b/databi/src/components/FunnelPanel.jsx @@ -2,25 +2,29 @@ import { formatNumber, formatPercent } from "../utils/format.js"; import { MiniStat } from "./MiniStat.jsx"; import { Panel } from "./Panel.jsx"; -export function FunnelPanel({ funnel, sideMetrics }) { +export function FunnelPanel({ funnel, loading = false, sideMetrics }) { return ( -
+
- {funnel.map((item, index) => ( -
-
- {item.name} - {formatNumber(item.value)} -
- {formatPercent(item.rate)} -
- ))} + {loading + ? Array.from({ length: 4 }).map((_, index) => ) + : funnel.map((item, index) => ( +
+
+ {item.name} + {formatNumber(item.value)} +
+ {formatPercent(item.rate)} +
+ ))}
- {sideMetrics.map((item) => ( - - ))} + {loading + ? Array.from({ length: 3 }).map((_, index) => ) + : sideMetrics.map((item) => ( + + ))}
diff --git a/databi/src/components/GlobalOverviewPanel.jsx b/databi/src/components/GlobalOverviewPanel.jsx index 56d58f0..99063ed 100644 --- a/databi/src/components/GlobalOverviewPanel.jsx +++ b/databi/src/components/GlobalOverviewPanel.jsx @@ -1,27 +1,85 @@ +import { useEffect, useMemo, useState } from "react"; import { EChart } from "../charts/EChart.jsx"; import { createGeoOption } from "../charts/options/createGeoOption.js"; import { formatMoney, moneyMajor } from "../utils/format.js"; +import { GlobeMap } from "./GlobeMap.jsx"; import { Panel } from "./Panel.jsx"; import { RankList } from "./RankList.jsx"; -export function GlobalOverviewPanel({ countryBreakdown, topCountries }) { +export function GlobalOverviewPanel({ countryBreakdown, loading = false, topCountries }) { + const [globeOpen, setGlobeOpen] = useState(false); + const rankedCountries = useMemo(() => (topCountries.length ? topCountries : countryBreakdown.slice(0, 4)), [countryBreakdown, topCountries]); + return ( - -
-
- -
- - - + <> + + {loading ? : ( +
+
+ {rankedCountries.length ? :
暂无国家数据
} +
+ + + +
+
+
+ + +
-
-
- - -
+ )} + + {globeOpen ? setGlobeOpen(false)} /> : null} + + ); +} + +function GlobalOverviewSkeleton() { + return ( +
+
+
- +
+ + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + +
+
+ ); +} + +function CountryGlobeDialog({ items, onClose }) { + useEffect(() => { + const closeOnEscape = (event) => { + if (event.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [onClose]); + + return ( +
(event.target === event.currentTarget ? onClose() : null)}> +
+
+
+

全部国家

+ {items.length} 个国家 / 按充值规模展示 +
+ +
+
+ +
+
+
); } diff --git a/databi/src/components/LuckyGiftPoolModal.jsx b/databi/src/components/LuckyGiftPoolModal.jsx new file mode 100644 index 0000000..d46649a --- /dev/null +++ b/databi/src/components/LuckyGiftPoolModal.jsx @@ -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 ( +
+
+
+
+

幸运礼物奖池明细

+ 按 pool_id 汇总流水、返奖和利润 +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + + + + + + {items.length ? items.map((item, index) => ( + + + + + + + + + + )) : ( + + + + )} + +
#Pool ID流水(金币)返奖(金币)利润(金币)返奖率利润率
{index + 1}{item.pool_id}{formatCoin(item.turnover)}{formatCoin(item.payout)}{formatCoin(item.profit)}{formatPercent(item.payout_rate)}{formatPercent(item.profit_rate)}
暂无 pool 数据
+
+
+
+ ); +} + +function PoolSummary({ label, tone = "", value }) { + return ( +
+ {label} + {formatCoin(value)} + 金币 +
+ ); +} diff --git a/databi/src/components/LuckyGiftPoolModal.test.jsx b/databi/src/components/LuckyGiftPoolModal.test.jsx new file mode 100644 index 0000000..a349546 --- /dev/null +++ b/databi/src/components/LuckyGiftPoolModal.test.jsx @@ -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( + + ); + + 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(); +}); diff --git a/databi/src/components/MetricCard.jsx b/databi/src/components/MetricCard.jsx index 0e269d9..ed8737d 100644 --- a/databi/src/components/MetricCard.jsx +++ b/databi/src/components/MetricCard.jsx @@ -1,8 +1,32 @@ -export function MetricCard({ item }) { +export function MetricCard({ item, loading = false, onClick }) { const Icon = item.icon; const UnitIcon = item.unitIcon; + const clickable = Boolean(onClick); + if (loading) { + return ( +
+ {Icon ?
: null} +
+ + + +
+
+ ); + } return ( -
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClick(); + } + } : undefined} + > {Icon ?
: null}
diff --git a/databi/src/components/RankList.jsx b/databi/src/components/RankList.jsx index 727b518..a412406 100644 --- a/databi/src/components/RankList.jsx +++ b/databi/src/components/RankList.jsx @@ -2,6 +2,7 @@ export function RankList({ items, title, valueFormatter }) { return (
{title}
+ {!items.length ?
暂无数据
: null} {items.map((item, index) => (
{index + 1} diff --git a/databi/src/components/RevenueTrendPanel.jsx b/databi/src/components/RevenueTrendPanel.jsx index f9986fc..879a057 100644 --- a/databi/src/components/RevenueTrendPanel.jsx +++ b/databi/src/components/RevenueTrendPanel.jsx @@ -2,10 +2,19 @@ import { EChart } from "../charts/EChart.jsx"; import { createRevenueOption } from "../charts/options/createRevenueOption.js"; import { Panel } from "./Panel.jsx"; -export function RevenueTrendPanel({ revenueSeries }) { +export function RevenueTrendPanel({ loading = false, revenueSeries }) { return ( - + {loading ? ( +
+ + +
+ ) : revenueSeries.length ? ( + + ) : ( +
暂无趋势数据
+ )}
); } diff --git a/databi/src/config/options.js b/databi/src/config/options.js deleted file mode 100644 index 02717fa..0000000 --- a/databi/src/config/options.js +++ /dev/null @@ -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" } -]; diff --git a/databi/src/data/countryMeta.js b/databi/src/data/countryMeta.js index 5f0cfba..45408f2 100644 --- a/databi/src/data/countryMeta.js +++ b/databi/src/data/countryMeta.js @@ -37,6 +37,10 @@ export function countryFlag(code) { .join(""); } +export function stripCountryFlagPrefix(value) { + return String(value || "").replace(/^(?:\s*[\u{1F1E6}-\u{1F1FF}]{2})+\s*/u, "").trim(); +} + function normalizeKey(value) { return String(value || "").trim().toLowerCase(); } diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js index 65f6094..e5679c0 100644 --- a/databi/src/data/createDashboardModel.js +++ b/databi/src/data/createDashboardModel.js @@ -1,12 +1,12 @@ import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx"; -import { countryFlag, resolveCountryMeta } from "./countryMeta.js"; +import { countryFlag, resolveCountryMeta, stripCountryFlagPrefix } from "./countryMeta.js"; import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js"; import { numberValue, ratio } from "../utils/number.js"; import { formatDateTime } from "../utils/time.js"; -import { sampleOverview } from "./sampleOverview.js"; -export function createDashboardModel(overview, { appCode, countryId, preview }) { - const source = overview || sampleOverview; +export function createDashboardModel(overview, { appCode, countryId, range }) { + const source = overview || {}; + const comparisonCaption = isTodayRange(range) ? "与昨日相比" : "近 7 日"; const recharge = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor"); const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"); const activeUsers = readNumber(source, "active_users", "activeUsers"); @@ -15,9 +15,11 @@ export function createDashboardModel(overview, { appCode, countryId, preview }) const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor"); const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor"); const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent"); - const luckyGiftTurnover = readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover"); - const luckyGiftPayout = readNumber(source, "lucky_gift_payout", "luckyGiftPayout"); - const luckyGiftProfit = readNumber(source, "lucky_gift_profit", "luckyGiftProfit"); + const luckyGiftPools = normalizeLuckyGiftPools(source); + const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools); + const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover"); + const luckyGiftPayout = luckyGiftPools.length ? luckyGiftPoolTotals.payout : readNumber(source, "lucky_gift_payout", "luckyGiftPayout"); + const luckyGiftProfit = luckyGiftPools.length ? luckyGiftPoolTotals.profit : readNumber(source, "lucky_gift_profit", "luckyGiftProfit"); const gameTurnover = readNumber(source, "game_turnover", "gameTurnover"); const gameProfit = readNumber(source, "game_profit", "gameProfit"); const countryBreakdown = normalizeCountries(source, countryId); @@ -28,12 +30,12 @@ export function createDashboardModel(overview, { appCode, countryId, preview }) return { appCode, businessKpis: [ - coinMetric("礼物消费", giftCoinSpent, preview ? "+16.80%" : ""), - coinMetric("幸运礼物流水", luckyGiftTurnover, preview ? "+12.93%" : ""), - coinMetric("幸运礼物返奖", luckyGiftPayout, preview ? "+8.15%" : ""), - coinMetric("幸运礼物利润", luckyGiftProfit, preview ? "+4.66%" : ""), - coinMetric("游戏流水", gameTurnover, preview ? "+14.20%" : ""), - coinMetric("游戏利润", gameProfit, preview ? "+18.18%" : "") + coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption), + coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), + coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), + coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), + coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption), + coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption) ], countryBreakdown, funnel: [ @@ -43,46 +45,51 @@ export function createDashboardModel(overview, { appCode, countryId, preview }) { name: "充值用户", rate: ratio(numberValue(source.lucky_gift_payers), activeUsers), value: numberValue(source.lucky_gift_payers) } ], gameMetrics: [ - { caption: "近 7 日 +14.20%", label: "流水", value: formatWholeMoney(source.game_turnover) }, - { caption: "近 7 日 +9.63%", label: "派奖", value: formatWholeMoney(source.game_payout) }, - { caption: "近 7 日 -3.58%", label: "退款", value: formatWholeMoney(source.game_refund) }, - { caption: "近 7 日 +18.18%", label: "利润", value: formatWholeMoney(source.game_profit) } + { caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "game_payout_delta_rate", "gamePayoutDeltaRate")), label: "派奖", value: formatWholeMoney(source.game_payout) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "game_refund_delta_rate", "gameRefundDeltaRate")), label: "退款", value: formatWholeMoney(source.game_refund) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate")), label: "利润", value: formatWholeMoney(source.game_profit) } ], gameRanking, giftRanking: normalizeGiftRanking(source), kpis: [ - { caption: "近 7 日", delta: preview ? "+18.10%" : "", icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) }, - { caption: "近 7 日", delta: preview ? "+16.80%" : "", icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) }, - { caption: "近 7 日", delta: preview ? "+10.77%" : "", icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) }, - { caption: "近 7 日", delta: preview ? "+12.92%" : "", icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) }, - { caption: "近 7 日", delta: preview ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) }, - { caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) } + { caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) }, + { caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) }, + { caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) }, + { caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) }, + { caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) }, + { caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) } ], luckyMetrics: [ - { caption: "近 7 日 +10.62%", label: "流水", value: formatWholeMoney(source.lucky_gift_turnover) }, - { caption: "近 7 日 +8.15%", label: "返奖", value: formatWholeMoney(source.lucky_gift_payout) }, - { caption: "近 7 日 +4.66%", label: "利润", value: formatWholeMoney(source.lucky_gift_profit) }, - { caption: "近 7 日 -2.01pp", label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) }, - { caption: "近 7 日 +0.76pp", label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) } + { caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate")), label: "返奖", value: formatWholeMoney(luckyGiftPayout) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate")), label: "利润", value: formatWholeMoney(luckyGiftProfit) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_rate_delta_pp", "luckyGiftPayoutRateDeltaPp"), "pp"), label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_rate_delta_pp", "luckyGiftProfitRateDeltaPp"), "pp"), label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) } ], + luckyGiftPools, payoutDistribution, revenueSeries, roomMetrics: [ - { caption: "近 7 日 +16.80%", label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) }, - { caption: "近 7 日 +12.93%", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) } + { caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate")), label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) } ], sideMetrics: [ - { caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(arpu) }, - { caption: "近 7 日 +0.18pp", label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) }, - { caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(arppu) } + { caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) }, + { caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) } ], - topCountries: countryBreakdown.slice(0, 5), + topCountries: countryBreakdown.slice(0, 4), updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now()) }; } -function coinMetric(label, value, delta) { - return { caption: "近 7 日", delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value) }; +function coinMetric(label, value, delta, caption, extra = {}) { + return { caption, delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value), ...extra }; +} + +function captionWithDelta(label, delta) { + return `${label} ${delta}`; } function readNumber(source, ...keys) { @@ -94,7 +101,49 @@ function readNumber(source, ...keys) { return 0; } +function readDelta(source, ...keys) { + const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%"; + for (const key of keys) { + if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") { + return formatDelta(source[key], unit); + } + } + return ""; +} + +function deltaTone(source, ...keys) { + for (const key of keys) { + if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") { + return numberValue(source[key]) < 0 ? "down" : "up"; + } + } + return "up"; +} + +function formatDelta(value, unit) { + const numeric = numberValue(value); + const normalized = unit === "%" && Math.abs(numeric) <= 1 ? numeric * 100 : numeric; + const prefix = normalized > 0 ? "+" : ""; + return `${prefix}${normalized.toFixed(2)}${unit}`; +} + +function isTodayRange(range) { + const today = dateValue(new Date()); + return range?.start === today && range?.end === today; +} + +function dateValue(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +} + +function pad2(value) { + return String(value).padStart(2, "0"); +} + function normalizeRevenueSeries(source) { + if (!source || !Object.keys(source).length) { + return []; + } if (Array.isArray(source.daily_series) && source.daily_series.length) { return source.daily_series.map((item) => ({ coin_seller: numberValue(item.coin_seller ?? item.coinSeller), @@ -119,19 +168,15 @@ function normalizeRevenueSeries(source) { function normalizeCountries(source, countryId) { const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null; - const rows = - raw || - [ - { - active_users: source.active_users, - arppu_usd_minor: source.arppu_usd_minor, - country: countryId ? `国家 ${countryId}` : "全部区域", - paid_users: source.paid_users, - recharge_usd_minor: source.recharge_usd_minor, - x: 56, - y: 52 - } - ]; + const rows = raw || (countryId ? [{ + active_users: source.active_users, + arppu_usd_minor: source.arppu_usd_minor, + country: `国家 ${countryId}`, + paid_users: source.paid_users, + recharge_usd_minor: source.recharge_usd_minor, + x: 56, + y: 52 + }] : []); const totalRecharge = rows.reduce((sum, item) => sum + numberValue(item.recharge_usd_minor), 0) || 1; return rows .map((item, index) => ({ @@ -142,7 +187,7 @@ function normalizeCountries(source, countryId) { function normalizeCountryRow(item, index, totalRecharge) { const code = item.country_code || item.countryCode || item.iso_code || item.isoCode; - const country = item.country || item.country_name || code || `国家 ${index + 1}`; + const country = stripCountryFlagPrefix(item.country || item.country_name || code || `国家 ${index + 1}`); const meta = resolveCountryMeta({ code, name: country }); const recharge = numberValue(item.recharge_usd_minor); return { @@ -168,21 +213,60 @@ function normalizeCountryRow(item, index, totalRecharge) { function normalizeGiftRanking(source) { if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) { - return source.gift_ranking.map((item) => ({ name: item.name || item.gift_name || item.gift_id || "-", value: numberValue(item.value ?? item.amount ?? item.coin) })); + return source.gift_ranking.map((item) => ({ + name: item.name || item.gift_name || item.gift_id || "-", + trend_rate: numberValue(item.trend_rate ?? item.trendRate), + value: numberValue(item.value ?? item.amount ?? item.coin) + })); } - return [{ name: "礼物消费", value: numberValue(source.gift_coin_spent) }]; + return []; } function normalizeDistribution(source) { if (Array.isArray(source.payout_distribution) && source.payout_distribution.length) { return source.payout_distribution.map((item) => ({ name: item.name || "-", value: numberValue(item.value) })); } + if (!source || !Object.keys(source).length) { + return []; + } return [ { name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) }, { name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) } ]; } +function normalizeLuckyGiftPools(source) { + const raw = firstArray(source?.lucky_gift_pools, source?.luckyGiftPools, source?.lucky_gift_pool_stats, source?.luckyGiftPoolStats, source?.pool_breakdown); + return raw + .map((item) => { + const turnover = readNumber(item, "turnover_coin", "turnoverCoin", "turnover", "total_spent_coins", "totalSpentCoins", "lucky_gift_turnover", "luckyGiftTurnover"); + const payout = readNumber(item, "payout_coin", "payoutCoin", "payout", "total_reward_coins", "totalRewardCoins", "lucky_gift_payout", "luckyGiftPayout"); + const rawProfit = item.profit_coin ?? item.profitCoin ?? item.profit ?? item.lucky_gift_profit ?? item.luckyGiftProfit; + const profit = rawProfit === undefined || rawProfit === null ? turnover - payout : numberValue(rawProfit); + return { + payout, + payout_rate: numberValue(item.payout_rate ?? item.payoutRate ?? ratio(payout, turnover)), + pool_id: String(item.pool_id ?? item.poolId ?? item.id ?? "-").trim() || "-", + profit, + profit_rate: numberValue(item.profit_rate ?? item.profitRate ?? ratio(profit, turnover)), + turnover + }; + }) + .sort((left, right) => right.turnover - left.turnover || left.pool_id.localeCompare(right.pool_id)); +} + +function summarizeLuckyGiftPools(items) { + return items.reduce((summary, item) => ({ + payout: summary.payout + item.payout, + profit: summary.profit + item.profit, + turnover: summary.turnover + item.turnover + }), { payout: 0, profit: 0, turnover: 0 }); +} + +function firstArray(...values) { + return values.find((value) => Array.isArray(value)) || []; +} + function normalizeGameRanking(source) { if (Array.isArray(source.game_ranking) && source.game_ranking.length) { return source.game_ranking.map((item) => ({ @@ -192,7 +276,7 @@ function normalizeGameRanking(source) { turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin) })); } - return [{ game_id: "全部游戏", platform_code: "", profit_rate: numberValue(source.game_profit_rate), turnover_coin: numberValue(source.game_turnover) }]; + return []; } function localizeSeriesLabel(label) { diff --git a/databi/src/data/createDashboardModel.test.js b/databi/src/data/createDashboardModel.test.js new file mode 100644 index 0000000..221fdbf --- /dev/null +++ b/databi/src/data/createDashboardModel.test.js @@ -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; +} diff --git a/databi/src/data/sampleOverview.js b/databi/src/data/sampleOverview.js deleted file mode 100644 index 5722cf2..0000000 --- a/databi/src/data/sampleOverview.js +++ /dev/null @@ -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() -}; diff --git a/databi/src/styles/cards.css b/databi/src/styles/cards.css index 97e125a..b662376 100644 --- a/databi/src/styles/cards.css +++ b/databi/src/styles/cards.css @@ -19,6 +19,17 @@ border-radius: 8px; } +.metric-card--clickable { + cursor: pointer; +} + +.metric-card--clickable:hover, +.metric-card--clickable:focus-visible { + border-color: rgba(39, 228, 245, 0.78); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 0 0 3px rgba(39, 228, 245, 0.08); + outline: none; +} + .metric-icon { display: grid; width: 40px; @@ -119,10 +130,68 @@ font-weight: 760; } +.negative { + color: #f5a623; + font-weight: 760; +} + .metric-delta--down b { color: #f5a623; } +.skeleton-block { + display: block; + position: relative; + overflow: hidden; + border-radius: 4px; + background: rgba(95, 151, 193, 0.16); +} + +.skeleton-block::after { + position: absolute; + inset: 0; + transform: translateX(-110%); + background: linear-gradient(90deg, transparent, rgba(121, 213, 238, 0.22), transparent); + animation: skeleton-shimmer 1.35s ease-in-out infinite; + content: ""; +} + +@keyframes skeleton-shimmer { + to { + transform: translateX(110%); + } +} + +.metric-card.is-loading .metric-content { + display: grid; + gap: 10px; +} + +.metric-icon--skeleton { + color: transparent; +} + +.skeleton-icon { + width: 34px; + height: 34px; + border-radius: 50%; +} + +.skeleton-label { + width: 92px; + height: 13px; +} + +.skeleton-value { + width: min(190px, 78%); + height: 30px; +} + +.skeleton-caption { + width: min(160px, 68%); + height: 14px; +} + .side-metrics { display: grid; gap: 8px; diff --git a/databi/src/styles/charts.css b/databi/src/styles/charts.css index 05c613a..5308845 100644 --- a/databi/src/styles/charts.css +++ b/databi/src/styles/charts.css @@ -24,6 +24,24 @@ min-height: 0; } +.chart-skeleton { + display: grid; + align-content: start; + gap: 16px; + padding: 12px 20px 20px; +} + +.skeleton-chart-legend { + width: 58%; + height: 14px; +} + +.skeleton-chart-body { + width: 100%; + height: 196px; + border-radius: 8px; +} + .donut-chart { height: 182px; } diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css index 5880af7..18765ec 100644 --- a/databi/src/styles/layout.css +++ b/databi/src/styles/layout.css @@ -115,6 +115,11 @@ box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08); } +.filter-trigger:disabled { + cursor: progress; + opacity: 0.68; +} + .filter-icon, .filter-chevron { color: #cbd9e9; @@ -279,6 +284,70 @@ color: #f3fbff; } +.filter-popover--searchable { + width: 260px; +} + +.filter-search { + display: grid; + gap: 5px; + padding: 2px 2px 8px; +} + +.filter-search span { + color: #7f9bb7; + font-size: 10px; + font-weight: 720; +} + +.filter-search input { + width: 100%; + height: 34px; + padding: 0 10px; + border: 1px solid rgba(64, 148, 197, 0.36); + border-radius: 7px; + outline: 0; + background: rgba(3, 18, 31, 0.9); + color: #e6f3ff; + font: inherit; + font-size: 12px; +} + +.filter-search input:focus { + border-color: rgba(39, 228, 245, 0.72); + box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08); +} + +.filter-options-scroll { + display: grid; + max-height: 250px; + gap: 4px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; +} + +.filter-options-scroll button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-options-scroll button small { + flex: 0 0 auto; + color: #7f9bb7; + font-size: 10px; + font-weight: 760; +} + +.filter-empty { + display: block; + padding: 10px; + color: #8faac5; + font-size: 12px; +} + .date-time-popover { position: absolute; top: calc(100% + 10px); @@ -589,6 +658,32 @@ align-items: center; } +.business-metric-grid .metric-card.is-loading .metric-content { + grid-template-columns: minmax(0, 1fr) 96px; + grid-template-rows: 16px 28px; +} + +.business-metric-grid .metric-card.is-loading .skeleton-label { + grid-column: 1; + grid-row: 1; + width: 88px; +} + +.business-metric-grid .metric-card.is-loading .skeleton-value { + grid-column: 1; + grid-row: 2; + width: min(160px, 76%); + height: 24px; +} + +.business-metric-grid .metric-card.is-loading .skeleton-caption { + grid-column: 2; + grid-row: 1 / 3; + align-self: center; + justify-self: end; + width: 86px; +} + .business-metric-grid .metric-label-row { grid-column: 1; grid-row: 1; diff --git a/databi/src/styles/panels.css b/databi/src/styles/panels.css index e4a4a30..4e6f460 100644 --- a/databi/src/styles/panels.css +++ b/databi/src/styles/panels.css @@ -103,6 +103,16 @@ min-width: 0; } +.panel-empty { + display: grid; + width: 100%; + height: 100%; + min-height: 120px; + place-items: center; + color: #7f9bb7; + font-size: 13px; +} + .globe-map { position: relative; z-index: 2; @@ -180,6 +190,16 @@ min-width: 0; } +.rank-empty { + display: grid; + min-height: 112px; + place-items: center; + border: 1px dashed rgba(108, 157, 196, 0.22); + border-radius: 8px; + color: #7f9bb7; + font-size: 13px; +} + .rank-row { display: grid; grid-template-columns: 20px 20px minmax(0, 1fr) 62px; @@ -231,9 +251,266 @@ border-radius: 8px; background: rgba(7, 35, 58, 0.72); color: #2fe8f3; + cursor: pointer; font-size: 14px; } +.all-countries-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.skeleton-panel { + pointer-events: none; +} + +.skeleton-map { + display: grid; + min-height: 170px; + place-items: center; +} + +.skeleton-map-glow { + width: 74%; + height: 62%; + border-radius: 8px; +} + +.skeleton-rank-title { + width: 120px; + height: 14px; + margin-bottom: 2px; +} + +.skeleton-rank-row { + width: 100%; + height: 36px; + border-radius: 8px; +} + +.skeleton-button { + width: 100%; + height: 34px; + border-radius: 8px; +} + +.country-globe-modal { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 32px; + background: rgba(1, 9, 19, 0.72); + backdrop-filter: blur(8px); +} + +.country-globe-dialog { + display: grid; + width: min(1040px, calc(100vw - 64px)); + height: min(760px, calc(100vh - 64px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid rgba(44, 191, 227, 0.58); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)), + rgba(5, 24, 42, 0.98); + box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.country-globe-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid rgba(111, 148, 183, 0.2); +} + +.country-globe-head h2 { + margin: 0; + color: #f3f9ff; + font-size: 20px; + font-weight: 780; +} + +.country-globe-head span { + display: block; + margin-top: 4px; + color: #8faac5; + font-size: 12px; +} + +.country-globe-head button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid rgba(64, 148, 197, 0.48); + border-radius: 8px; + background: rgba(7, 29, 48, 0.9); + color: #d8ebff; + cursor: pointer; + font: inherit; + font-size: 22px; + line-height: 1; +} + +.country-globe-head button:hover { + border-color: rgba(39, 228, 245, 0.78); + color: #ffffff; +} + +.country-globe-body { + min-height: 0; + padding: 18px; +} + +.pool-detail-modal { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + padding: 32px; + background: rgba(1, 9, 19, 0.72); + backdrop-filter: blur(8px); +} + +.pool-detail-dialog { + display: grid; + width: min(860px, calc(100vw - 64px)); + max-height: min(680px, calc(100vh - 64px)); + grid-template-rows: auto auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid rgba(44, 191, 227, 0.58); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)), + rgba(5, 24, 42, 0.98); + box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.pool-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid rgba(111, 148, 183, 0.2); +} + +.pool-detail-head h2 { + margin: 0; + color: #f3f9ff; + font-size: 20px; + font-weight: 780; +} + +.pool-detail-head span { + display: block; + margin-top: 4px; + color: #8faac5; + font-size: 12px; +} + +.pool-detail-head button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid rgba(64, 148, 197, 0.48); + border-radius: 8px; + background: rgba(7, 29, 48, 0.9); + color: #d8ebff; + cursor: pointer; + font: inherit; + font-size: 22px; + line-height: 1; +} + +.pool-detail-head button:hover { + border-color: rgba(39, 228, 245, 0.78); + color: #ffffff; +} + +.pool-detail-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + padding: 16px 18px 8px; +} + +.pool-summary-card { + min-width: 0; + padding: 12px 14px; + border: 1px solid rgba(35, 128, 180, 0.46); + border-radius: 8px; + background: rgba(7, 29, 48, 0.76); +} + +.pool-summary-card span, +.pool-summary-card small { + color: #8faac5; + font-size: 12px; +} + +.pool-summary-card strong { + display: block; + margin-top: 6px; + overflow: hidden; + color: #f6fbff; + font-size: 22px; + font-weight: 780; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pool-detail-table-wrap { + min-height: 0; + padding: 10px 18px 18px; + overflow: auto; +} + +.pool-detail-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; +} + +.pool-detail-table th, +.pool-detail-table td { + height: 34px; + padding: 0 12px; + border-bottom: 1px solid rgba(108, 157, 196, 0.22); + border-right: 1px solid rgba(108, 157, 196, 0.15); + color: #cfe3f7; + font-weight: 620; + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.pool-detail-table th { + background: rgba(128, 171, 211, 0.08); + color: #9eb7ce; + font-size: 12px; + font-weight: 720; +} + +.pool-detail-table th:nth-child(2), +.pool-detail-table td:nth-child(2) { + text-align: left; +} + +.pool-detail-table th:first-child, +.pool-detail-table td:first-child { + width: 42px; + text-align: center; +} + .rank-row b, .bar-row b, .game-row b, @@ -345,3 +622,25 @@ margin-inline: 54px; background: linear-gradient(180deg, rgba(32, 203, 161, 0.94), rgba(18, 168, 137, 0.92)); } + +.skeleton-funnel-stage { + height: 37px; + clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%); +} + +.skeleton-funnel-stage--2 { + margin-inline: 18px; +} + +.skeleton-funnel-stage--3 { + margin-inline: 36px; +} + +.skeleton-funnel-stage--4 { + margin-inline: 54px; +} + +.skeleton-mini-stat { + height: 72px; + border-radius: 8px; +} diff --git a/databi/src/styles/tables.css b/databi/src/styles/tables.css index d1fa835..639cc7e 100644 --- a/databi/src/styles/tables.css +++ b/databi/src/styles/tables.css @@ -29,6 +29,10 @@ font-weight: 720; } +.country-table th.is-sortable { + padding: 0; +} + .country-table th:nth-child(2), .country-table td:nth-child(2) { text-align: left; @@ -48,6 +52,44 @@ text-align: center; } +.sort-header-button { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + width: 100%; + height: 25px; + padding: 0 12px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + line-height: 1; + text-align: right; + cursor: pointer; +} + +.sort-header-button--left { + justify-content: flex-start; + text-align: left; +} + +.sort-header-button:hover, +.sort-header-button.is-active { + color: #e9f6ff; +} + +.sort-indicator { + width: 10px; + color: #6f8ea8; + font-size: 10px; + text-align: center; +} + +.sort-header-button.is-active .sort-indicator { + color: #25d8f5; +} + .table-tabs { display: inline-flex; gap: 3px; @@ -73,3 +115,21 @@ color: #e9f6ff; box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38); } + +.table-skeleton-row td, +.table-empty-row td { + border-right: 0; + text-align: center; +} + +.skeleton-table-line { + width: 100%; + height: 15px; + border-radius: 3px; +} + +.table-empty-row td { + height: 76px; + color: #7f9bb7; + font-size: 13px; +} diff --git a/databi/src/utils/time.js b/databi/src/utils/time.js index a61e73d..878d043 100644 --- a/databi/src/utils/time.js +++ b/databi/src/utils/time.js @@ -1,3 +1,8 @@ +export function todayRange() { + const today = new Date(); + return { end: dateInputValue(today), endTime: "23:59:59", start: dateInputValue(today), startTime: "00:00:00" }; +} + export function lastDaysRange(days) { const end = new Date(); const start = new Date(end);