feat: add databi dashboard

This commit is contained in:
ZuoZuo 2026-06-01 01:51:39 +08:00
parent 49f54e0463
commit 6ddf5ee211
43 changed files with 2978 additions and 0 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ node_modules/
dist/
coverage/
*.log
.waylog/

12
databi/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HYApp 数据大屏</title>
</head>
<body>
<div id="databi-root"></div>
<script type="module" src="/databi/src/main.jsx"></script>
</body>
</html>

97
databi/src/DatabiApp.jsx Normal file
View File

@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchStatisticsOverview, getCurrentAppCode } from "./api.js";
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
import { DatabiHeader } from "./components/DatabiHeader.jsx";
import { FunnelPanel } from "./components/FunnelPanel.jsx";
import { GameEconomyPanel } from "./components/GameEconomyPanel.jsx";
import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
import { LuckyGiftPanel } from "./components/LuckyGiftPanel.jsx";
import { MetricCard } from "./components/MetricCard.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { RoomGiftPanel } from "./components/RoomGiftPanel.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
import { sampleOverview } from "./data/sampleOverview.js";
import { endOfDay, lastDaysRange, startOfDay } from "./utils/time.js";
export function DatabiApp() {
const initialRange = useMemo(() => lastDaysRange(7), []);
const [range, setRange] = useState(initialRange);
const [countryId, setCountryId] = useState(0);
const [overview, setOverview] = useState(sampleOverview);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [preview, setPreview] = useState(true);
const appCode = getCurrentAppCode();
const loadOverview = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await fetchStatisticsOverview({
appCode,
countryId,
endMs: endOfDay(range.end),
startMs: startOfDay(range.start)
});
setOverview(data || sampleOverview);
setPreview(false);
} catch (err) {
setOverview(sampleOverview);
setPreview(true);
setError(err.message || "请求失败");
} finally {
setLoading(false);
}
}, [appCode, countryId, range.end, range.start]);
useEffect(() => {
void loadOverview();
const timer = window.setInterval(() => void loadOverview(), 60_000);
return () => window.clearInterval(timer);
}, [loadOverview]);
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, preview }), [appCode, countryId, overview, preview]);
return (
<main className="databi-shell">
<section className="databi-screen">
<DatabiHeader
appCode={appCode}
countryId={countryId}
error={error}
loading={loading}
onCountryChange={setCountryId}
onRefresh={loadOverview}
onRangeChange={setRange}
preview={preview}
range={range}
updatedAt={model.updatedAt}
/>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} />
))}
</section>
<section className="databi-grid databi-grid--top">
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} topCountries={model.topCountries} />
<RevenueTrendPanel revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} sideMetrics={model.sideMetrics} />
</section>
<section className="databi-grid databi-grid--middle">
<RoomGiftPanel giftRanking={model.giftRanking} roomMetrics={model.roomMetrics} />
<LuckyGiftPanel luckyMetrics={model.luckyMetrics} payoutDistribution={model.payoutDistribution} />
<GameEconomyPanel gameMetrics={model.gameMetrics} gameRanking={model.gameRanking} />
</section>
</section>
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} />
<footer className="databi-footer">
<span>所有数据按 UTC 展示实时更新</span>
<strong>数据最多可能延迟 60 </strong>
</footer>
</main>
);
}

61
databi/src/api.js Normal file
View File

@ -0,0 +1,61 @@
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";
export function getCurrentAppCode() {
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
}
export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) {
const query = new URLSearchParams();
query.set("app_code", normalizeAppCode(appCode) || "lalu");
if (startMs) {
query.set("start_ms", String(startMs));
}
if (endMs) {
query.set("end_ms", String(endMs));
}
if (countryId) {
query.set("country_id", String(countryId));
}
const response = await fetch(`${API_BASE_URL}/v1/statistics/overview?${query.toString()}`, {
credentials: "include",
headers: requestHeaders(appCode)
});
const payload = await readJSON(response);
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || "请求失败");
}
return payload.data;
}
function requestHeaders(appCode) {
const headers = {};
const token = window.localStorage.getItem(TOKEN_KEY);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const normalized = normalizeAppCode(appCode);
if (normalized) {
headers[APP_CODE_HEADER] = normalized;
}
return headers;
}
async function readJSON(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch {
return { code: response.ok ? 0 : response.status, message: text };
}
}
function normalizeAppCode(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import { BarChart, EffectScatterChart, FunnelChart, LineChart, LinesChart, MapChart, PieChart, ScatterChart } from "echarts/charts";
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import * as echarts from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import worldMap from "../data/world.json";
echarts.use([BarChart, EffectScatterChart, FunnelChart, GeoComponent, GridComponent, LegendComponent, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TooltipComponent, CanvasRenderer]);
echarts.registerMap("databi-world", worldMap);
export function EChart({ className = "", option }) {
const elementRef = useRef(null);
const chartRef = useRef(null);
const optionRef = useRef(option);
useEffect(() => {
optionRef.current = option;
chartRef.current?.setOption(option, true);
}, [option]);
useEffect(() => {
const element = elementRef.current;
if (!element) {
return undefined;
}
const chart = echarts.init(element, null, { renderer: "canvas" });
chartRef.current = chart;
chart.setOption(optionRef.current, true);
const resize = () => chart.resize();
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
observer?.observe(element);
window.addEventListener("resize", resize);
return () => {
observer?.disconnect();
window.removeEventListener("resize", resize);
chart.dispose();
chartRef.current = null;
};
}, []);
return <div className={["databi-chart", className].filter(Boolean).join(" ")} ref={elementRef} />;
}

View File

@ -0,0 +1,23 @@
import { formatCoin } from "../../utils/format.js";
export function createDonutOption(items) {
return {
animationDuration: 650,
color: ["#2dd7ef", "#1dc9aa", "#367be8", "#9a9cfb"],
series: [
{
data: items,
itemStyle: { borderColor: "#062039", borderWidth: 2 },
label: { show: false },
radius: ["56%", "82%"],
type: "pie"
}
],
tooltip: {
backgroundColor: "#08243a",
borderColor: "#1a5f82",
formatter: ({ name, percent, value }) => `${name}<br/>${formatCoin(value)} · ${percent}%`,
textStyle: { color: "#e9f6ff" }
}
};
}

View File

@ -0,0 +1,24 @@
export function createFunnelOption(items) {
return {
animationDuration: 650,
color: ["#277be8", "#10a6c4", "#13b5b0", "#17c99d"],
series: [
{
bottom: 6,
data: items.map((item) => ({ name: item.name, value: Math.max(item.rate * 100, 8) })),
gap: 9,
itemStyle: { borderColor: "rgba(220, 250, 255, 0.92)", borderWidth: 1.2, shadowBlur: 14, shadowColor: "rgba(39, 228, 245, 0.16)" },
label: { color: "#e8f6ff", distance: 16, formatter: "{b}", fontSize: 12, lineHeight: 16, position: "right" },
labelLine: { show: false },
left: "5%",
maxSize: "88%",
minSize: "42%",
sort: "descending",
top: 4,
type: "funnel",
width: "58%"
}
],
tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", textStyle: { color: "#e9f6ff" } }
};
}

View File

@ -0,0 +1,55 @@
import { formatMoney } from "../../utils/format.js";
export function createGeoOption(items) {
const points = items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
.map((item) => [...item.geo_point, item.recharge_usd_minor, item.country]);
const max = Math.max(...points.map((item) => item[2]), 1);
return {
animationDuration: 650,
geo: {
bottom: 0,
center: [38, 12],
emphasis: { disabled: true },
itemStyle: {
areaColor: "#18354e",
borderColor: "rgba(62, 139, 184, 0.52)",
borderWidth: 0.65
},
label: { show: false },
left: 0,
map: "databi-world",
right: 0,
roam: false,
silent: true,
top: 0,
zoom: 1.04
},
series: [
{
coordinateSystem: "geo",
data: points,
itemStyle: { color: "rgba(40, 228, 245, 0.18)", shadowBlur: 26, shadowColor: "#23e4ff" },
silent: true,
symbolSize: (value) => 14 + (value[2] / max) * 32,
type: "scatter",
z: 2
},
{
coordinateSystem: "geo",
data: points,
itemStyle: { borderColor: "#9ffcff", borderWidth: 1.5, color: "#28e4f5", shadowBlur: 22, shadowColor: "#23e4ff" },
rippleEffect: { brushType: "stroke", number: 3, scale: 3.6 },
symbolSize: (value) => 6 + (value[2] / max) * 15,
type: "effectScatter",
z: 3
}
],
tooltip: {
backgroundColor: "#08243a",
borderColor: "#1a5f82",
formatter: ({ value }) => `${value[3]}<br/>${formatMoney(value[2])}`,
textStyle: { color: "#e9f6ff" }
}
};
}

View File

@ -0,0 +1,50 @@
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
export function createRevenueOption(series) {
const labels = series.map((item) => item.label);
const leftMax = Math.max(2_000_000, ...series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller)));
const rightMax = Math.max(8_000_000, ...series.map((item) => moneyMajor(item.lineTotal ?? item.total)));
return {
animationDuration: 650,
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: ["总计 (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" },
xAxis: { axisLabel: { color: "#90a8c4", margin: 10 }, axisLine: { lineStyle: { color: "#1a3a56" } }, axisTick: { show: false }, boundaryGap: true, data: labels, type: "category" },
yAxis: [
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: leftMax <= 2_000_000 ? 500_000 : undefined, max: roundAxisMax(leftMax), splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" },
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: rightMax <= 8_000_000 ? 2_000_000 : undefined, max: roundAxisMax(rightMax), splitLine: { show: false }, type: "value" }
],
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" },
{
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
lineStyle: { color: "#f2f6ff", shadowBlur: 10, shadowColor: "rgba(242,246,255,.42)", width: 3 },
name: "总计 (USD)",
smooth: 0.42,
symbol: "circle",
symbolSize: 7,
itemStyle: { borderColor: "#f2f6ff", borderWidth: 2, color: "#f2f6ff" },
type: "line",
yAxisIndex: 1,
z: 4
}
]
};
}
function roundAxisMax(value) {
if (value <= 2_000_000) {
return 2_000_000;
}
if (value <= 8_000_000) {
return 8_000_000;
}
const step = value > 10_000_000 ? 5_000_000 : 1_000_000;
return Math.ceil(value / step) * step;
}

View File

@ -0,0 +1,136 @@
import { useState } from "react";
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent } from "../utils/format.js";
import { Panel } from "./Panel.jsx";
const tabs = [
{ key: "country", label: "国家表现" },
{ key: "gift", label: "礼物排行" },
{ key: "game", label: "游戏排行" }
];
export function CountryPerformanceTable({ gameRanking, giftRanking, items }) {
const [activeTab, setActiveTab] = useState("country");
return (
<Panel className="panel--table" title={<TableTabs activeTab={activeTab} onChange={setActiveTab} />}>
<div className="country-table-wrap">
{activeTab === "country" ? <CountryTable items={items} /> : null}
{activeTab === "gift" ? <GiftTable items={giftRanking} /> : null}
{activeTab === "game" ? <GameTable items={gameRanking} /> : null}
</div>
</Panel>
);
}
function TableTabs({ activeTab, onChange }) {
return (
<div className="table-tabs">
{tabs.map((tab) => (
<button className={activeTab === tab.key ? "is-active" : ""} key={tab.key} type="button" onClick={() => onChange(tab.key)}>
{tab.label}
</button>
))}
</div>
);
}
function CountryTable({ items }) {
return (
<table className="country-table country-table--wide">
<thead>
<tr>
<th>#</th>
<th>国家</th>
<th>访客</th>
<th>活跃用户</th>
<th>付费用户</th>
<th>充值用户</th>
<th>总充值 (USD)</th>
<th>新用户充值 (USD)</th>
<th>ARPU (USD)</th>
<th>ARPPU (USD)</th>
<th>付费转化率</th>
<th>充值转化率</th>
<th>平均充值 (USD)</th>
<th> 7 </th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.country}>
<td>{index + 1}</td>
<td>{item.flag ? <i className="table-flag">{item.flag}</i> : null}{item.country}</td>
<td>{formatNumber(item.visitors)}</td>
<td>{formatNumber(item.active_users)}</td>
<td>{formatNumber(item.paid_users)}</td>
<td>{formatNumber(item.recharge_users)}</td>
<td>{formatMoneyFull(item.recharge_usd_minor)}</td>
<td>{formatMoneyFull(item.new_user_recharge_usd_minor)}</td>
<td>{formatMoney(item.arpu_usd_minor)}</td>
<td>{formatMoney(item.arppu_usd_minor)}</td>
<td>{formatPercent(item.payer_rate)}</td>
<td>{formatPercent(item.recharge_conversion_rate)}</td>
<td>{formatMoney(item.avg_recharge_usd_minor)}</td>
<td className="positive">+{formatPercent(item.trend_rate)}</td>
</tr>
))}
</tbody>
</table>
);
}
function GiftTable({ items }) {
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>礼物</th>
<th>金币消费</th>
<th>占比</th>
<th> 7 </th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.name}>
<td>{index + 1}</td>
<td>{item.name}</td>
<td>{formatCoin(item.value)}</td>
<td>{formatPercent(item.value / total)}</td>
<td className="positive">+{formatPercent(0.168 - index * 0.017)}</td>
</tr>
))}
</tbody>
</table>
);
}
function GameTable({ items }) {
const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1;
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>游戏</th>
<th>流水 (USD)</th>
<th>利润率</th>
<th>占比</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={`${item.platform_code || "game"}-${item.game_id}`}>
<td>{index + 1}</td>
<td>{item.game_id}</td>
<td>{formatCoin(item.turnover_coin)}</td>
<td className="positive">{formatPercent(item.profit_rate)}</td>
<td>{formatPercent(item.turnover_coin / total)}</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@ -0,0 +1,35 @@
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import { countryOptions } from "../config/options.js";
export function DatabiHeader({ countryId, onCountryChange, onRangeChange, range }) {
return (
<header className="databi-header">
<div className="brand-lockup">
<div className="brand-emblem" aria-hidden="true" />
<div>
<h1>全球运营指挥中心</h1>
</div>
</div>
<div className="header-controls">
<div className="range-control">
<input aria-label="开始日期" type="text" value={range.start} onChange={(event) => onRangeChange({ ...range, start: event.target.value })} />
<span>~</span>
<input aria-label="结束日期" type="text" value={range.end} onChange={(event) => onRangeChange({ ...range, end: event.target.value })} />
<CalendarMonthOutlined fontSize="small" />
</div>
<label className="region-control">
<PublicOutlined fontSize="small" />
<select aria-label="国家区域" value={countryId} onChange={(event) => onCountryChange(Number(event.target.value))}>
{countryOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<span className="live-dot">实时</span>
</div>
</header>
);
}

View File

@ -0,0 +1,28 @@
import { formatNumber, formatPercent } from "../utils/format.js";
import { MiniStat } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function FunnelPanel({ funnel, sideMetrics }) {
return (
<Panel title="ARPU 与付费转化漏斗">
<div className="funnel-layout">
<div className="funnel-stack">
{funnel.map((item, index) => (
<div className="funnel-stage-row" key={item.name}>
<div className={`funnel-stage funnel-stage--${index + 1}`}>
<span>{item.name}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
<b>{formatPercent(item.rate)}</b>
</div>
))}
</div>
<div className="side-metrics">
{sideMetrics.map((item) => (
<MiniStat key={item.label} item={item} />
))}
</div>
</div>
</Panel>
);
}

View File

@ -0,0 +1,37 @@
import { formatCoin, formatPercent } from "../utils/format.js";
import { FourMetricRow } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function GameEconomyPanel({ gameMetrics, gameRanking }) {
return (
<Panel title="游戏经济">
<FourMetricRow items={gameMetrics} />
<GameRankTable items={gameRanking} />
</Panel>
);
}
function GameRankTable({ items }) {
const max = Math.max(...items.map((item) => item.turnover_coin), 1);
const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1;
return (
<div className="game-table">
<div className="game-table-head">
<span>游戏流水排行</span>
<span>流水 (USD)</span>
<span>占比</span>
</div>
{items.map((item, index) => (
<div className="game-row" key={`${item.platform_code || "game"}-${item.game_id}`}>
<span>{index + 1}</span>
<em>{item.game_id || "-"}</em>
<div className="bar-track">
<i style={{ width: `${Math.max(5, (item.turnover_coin / max) * 100)}%` }} />
</div>
<b>{formatCoin(item.turnover_coin)}</b>
<strong>{formatPercent(item.turnover_coin / total)}</strong>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,34 @@
import { EChart } from "../charts/EChart.jsx";
import { createGeoOption } from "../charts/options/createGeoOption.js";
import { formatMoney, moneyMajor } from "../utils/format.js";
import { Panel } from "./Panel.jsx";
import { RankList } from "./RankList.jsx";
export function GlobalOverviewPanel({ countryBreakdown, topCountries }) {
return (
<Panel className="panel--map" title="全球概览">
<div className="geo-layout">
<div className="geo-map">
<EChart className="geo-chart" option={createGeoOption(countryBreakdown)} />
<div className="geo-scale">
<span></span>
<i />
<span></span>
</div>
</div>
<div className="rank-block">
<RankList items={topCountries} title="充值国家排行" valueFormatter={formatRankMoney} />
<button className="all-countries-button" type="button">查看全部国家</button>
</div>
</div>
</Panel>
);
}
function formatRankMoney(value) {
const amount = moneyMajor(value);
if (Math.abs(amount) >= 100_000) {
return `$${(amount / 1_000_000).toFixed(2)}M`;
}
return formatMoney(value);
}

View File

@ -0,0 +1,471 @@
import { useEffect, useMemo, useRef, useState } from "react";
import worldMap from "../data/world.json";
import { formatMoney, formatNumber } from "../utils/format.js";
const DEGREE = Math.PI / 180;
const INITIAL_ROTATION = { lat: 8, lon: 65 };
const MAX_LATITUDE = 58;
const LAND_RINGS = extractRings(worldMap.features);
export function GlobeMap({ items }) {
const canvasRef = useRef(null);
const wrapRef = useRef(null);
const dragRef = useRef(null);
const [dragging, setDragging] = useState(false);
const [hovered, setHovered] = useState(null);
const [rotation, setRotation] = useState(INITIAL_ROTATION);
const [size, setSize] = useState({ height: 0, width: 0 });
const points = useMemo(() => normalizePoints(items), [items]);
useEffect(() => {
const element = wrapRef.current;
if (!element) {
return undefined;
}
const syncSize = () => {
const rect = element.getBoundingClientRect();
setSize({ height: Math.max(1, Math.round(rect.height)), width: Math.max(1, Math.round(rect.width)) });
};
syncSize();
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(syncSize) : null;
observer?.observe(element);
window.addEventListener("resize", syncSize);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncSize);
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !size.width || !size.height) {
return;
}
drawGlobe(canvas, size, rotation, points);
}, [points, rotation, size]);
const onPointerDown = (event) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
lat: rotation.lat,
lon: rotation.lon,
x: event.clientX,
y: event.clientY
};
setHovered(null);
setDragging(true);
};
const onPointerMove = (event) => {
const drag = dragRef.current;
if (!drag) {
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
return;
}
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
setRotation({
lat: clamp(drag.lat + dy * 0.24, -MAX_LATITUDE, MAX_LATITUDE),
lon: normalizeLongitude(drag.lon - dx * 0.34)
});
};
const endDrag = (event) => {
if (dragRef.current) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
dragRef.current = null;
setDragging(false);
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
};
return (
<div
aria-label="可旋转全球充值分布"
className={["globe-map", dragging ? "is-dragging" : ""].filter(Boolean).join(" ")}
onPointerCancel={endDrag}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={() => setHovered(null)}
ref={wrapRef}
role="img"
>
<canvas className="globe-canvas" ref={canvasRef} />
{hovered ? (
<div className="globe-tooltip" style={{ left: hovered.x, top: hovered.y }}>
<strong>{hovered.item.country}</strong>
<span>充值 {formatMoney(hovered.item.value)}</span>
<span>活跃 {formatNumber(hovered.item.activeUsers)}</span>
<span>付费 {formatNumber(hovered.item.paidUsers)}</span>
</div>
) : null}
</div>
);
}
function drawGlobe(canvas, size, rotation, points) {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(size.width * dpr);
canvas.height = Math.round(size.height * dpr);
canvas.style.width = `${size.width}px`;
canvas.style.height = `${size.height}px`;
const context = canvas.getContext("2d");
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, size.width, size.height);
const cx = size.width / 2;
const cy = size.height / 2;
const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47));
drawAtmosphere(context, cx, cy, radius);
context.save();
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.clip();
drawGraticule(context, rotation, cx, cy, radius);
drawLand(context, rotation, cx, cy, radius);
drawRoutes(context, points, rotation, cx, cy, radius);
drawPoints(context, points, rotation, cx, cy, radius);
drawRankMarkers(context, points, rotation, cx, cy, radius);
context.restore();
drawRim(context, cx, cy, radius);
}
function drawAtmosphere(context, cx, cy, radius) {
context.save();
context.shadowBlur = 34;
context.shadowColor = "rgba(35, 228, 255, 0.22)";
context.fillStyle = "rgba(5, 31, 52, 0.92)";
context.beginPath();
context.arc(cx, cy, radius + 1, 0, Math.PI * 2);
context.fill();
context.restore();
const ocean = context.createRadialGradient(cx - radius * 0.42, cy - radius * 0.48, radius * 0.08, cx, cy, radius);
ocean.addColorStop(0, "#144766");
ocean.addColorStop(0.46, "#082c49");
ocean.addColorStop(0.76, "#061f36");
ocean.addColorStop(1, "#04182d");
context.fillStyle = ocean;
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill();
}
function drawGraticule(context, rotation, cx, cy, radius) {
context.save();
context.lineWidth = 0.65;
context.strokeStyle = "rgba(102, 184, 222, 0.16)";
for (let lat = -60; lat <= 60; lat += 30) {
drawProjectedLine(
context,
Array.from({ length: 91 }, (_, index) => [-180 + index * 4, lat]),
rotation,
cx,
cy,
radius
);
}
for (let lon = -150; lon <= 180; lon += 30) {
drawProjectedLine(
context,
Array.from({ length: 41 }, (_, index) => [lon, -80 + index * 4]),
rotation,
cx,
cy,
radius
);
}
context.restore();
}
function drawLand(context, rotation, cx, cy, radius) {
context.save();
context.fillStyle = "rgba(25, 63, 91, 0.9)";
context.strokeStyle = "rgba(75, 148, 190, 0.58)";
context.lineWidth = 0.62;
for (const ring of LAND_RINGS) {
drawRing(context, ring, rotation, cx, cy, radius);
}
context.restore();
}
function drawRoutes(context, points, rotation, cx, cy, radius) {
if (points.length < 2) {
return;
}
const hub = points[0];
const hubPoint = project(hub.point, rotation, cx, cy, radius);
if (!hubPoint) {
return;
}
context.save();
context.lineWidth = 0.8;
context.strokeStyle = "rgba(43, 230, 246, 0.22)";
for (const target of points.slice(1, 8)) {
const targetPoint = project(target.point, rotation, cx, cy, radius);
if (!targetPoint) {
continue;
}
const midX = (hubPoint.x + targetPoint.x) / 2;
const midY = (hubPoint.y + targetPoint.y) / 2;
const lift = Math.min(36, Math.hypot(targetPoint.x - hubPoint.x, targetPoint.y - hubPoint.y) * 0.22);
context.beginPath();
context.moveTo(hubPoint.x, hubPoint.y);
context.quadraticCurveTo(midX, midY - lift, targetPoint.x, targetPoint.y);
context.stroke();
}
context.restore();
}
function drawPoints(context, points, rotation, cx, cy, radius) {
const max = Math.max(...points.map((item) => item.value), 1);
for (const item of points) {
const point = project(item.point, rotation, cx, cy, radius);
if (!point) {
continue;
}
const strength = Math.sqrt(item.value / max);
const halo = 10 + strength * 24;
const core = 3.2 + strength * 5.4;
const glow = context.createRadialGradient(point.x, point.y, 1, point.x, point.y, halo);
glow.addColorStop(0, "rgba(129, 252, 255, 0.86)");
glow.addColorStop(0.2, "rgba(39, 228, 245, 0.42)");
glow.addColorStop(1, "rgba(39, 228, 245, 0)");
context.fillStyle = glow;
context.beginPath();
context.arc(point.x, point.y, halo, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(126, 252, 255, 0.42)";
context.lineWidth = 1;
context.beginPath();
context.arc(point.x, point.y, core * 2.2, 0, Math.PI * 2);
context.stroke();
context.fillStyle = "#35f0f3";
context.shadowBlur = 16;
context.shadowColor = "rgba(53, 240, 243, 0.78)";
context.beginPath();
context.arc(point.x, point.y, core, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
}
}
function drawRankMarkers(context, points, rotation, cx, cy, radius) {
const visible = points
.map((item, index) => ({ index, item, point: project(item.point, rotation, cx, cy, radius) }))
.filter((item) => item.point)
.filter((item) => item.index < 5);
if (!visible.length) {
return;
}
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "800 11px Inter, Arial, sans-serif";
for (const marker of visible) {
const rank = marker.index + 1;
const markerRadius = rank <= 2 ? 10.5 : 9.5;
context.shadowBlur = 16;
context.shadowColor = "rgba(47, 232, 243, 0.55)";
context.fillStyle = "rgba(4, 31, 50, 0.92)";
context.beginPath();
context.arc(marker.point.x, marker.point.y, markerRadius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
context.lineWidth = 1.5;
context.strokeStyle = rank <= 2 ? "rgba(143, 255, 255, 0.92)" : "rgba(76, 224, 241, 0.72)";
context.stroke();
context.fillStyle = rank <= 2 ? "#f0fcff" : "#bdf9ff";
context.fillText(String(rank), marker.point.x, marker.point.y + 0.5);
}
context.restore();
}
function drawRim(context, cx, cy, radius) {
context.save();
const rim = context.createRadialGradient(cx, cy, radius * 0.7, cx, cy, radius * 1.04);
rim.addColorStop(0, "rgba(37, 228, 245, 0)");
rim.addColorStop(0.78, "rgba(37, 228, 245, 0.02)");
rim.addColorStop(1, "rgba(37, 228, 245, 0.34)");
context.fillStyle = rim;
context.beginPath();
context.arc(cx, cy, radius * 1.04, 0, Math.PI * 2);
context.fill();
context.lineWidth = 1.1;
context.strokeStyle = "rgba(86, 186, 226, 0.42)";
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.stroke();
context.restore();
}
function drawRing(context, ring, rotation, cx, cy, radius) {
context.beginPath();
let started = false;
let hasPoint = false;
for (const coordinate of ring) {
const point = project(coordinate, rotation, cx, cy, radius);
if (!point) {
started = false;
continue;
}
if (!started) {
context.moveTo(point.x, point.y);
started = true;
} else {
context.lineTo(point.x, point.y);
}
hasPoint = true;
}
if (hasPoint) {
context.fill();
context.stroke();
}
}
function drawProjectedLine(context, coordinates, rotation, cx, cy, radius) {
context.beginPath();
let started = false;
for (const coordinate of coordinates) {
const point = project(coordinate, rotation, cx, cy, radius);
if (!point) {
started = false;
continue;
}
if (!started) {
context.moveTo(point.x, point.y);
started = true;
} else {
context.lineTo(point.x, point.y);
}
}
context.stroke();
}
function project(coordinate, rotation, cx, cy, radius) {
const lon = coordinate[0] * DEGREE;
const lat = coordinate[1] * DEGREE;
const centerLon = rotation.lon * DEGREE;
const centerLat = rotation.lat * DEGREE;
const deltaLon = lon - centerLon;
const cosc = Math.sin(centerLat) * Math.sin(lat) + Math.cos(centerLat) * Math.cos(lat) * Math.cos(deltaLon);
if (cosc <= 0.02) {
return null;
}
return {
x: cx + radius * Math.cos(lat) * Math.sin(deltaLon),
y: cy - radius * (Math.cos(centerLat) * Math.sin(lat) - Math.sin(centerLat) * Math.cos(lat) * Math.cos(deltaLon))
};
}
function normalizePoints(items = []) {
return items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
.map((item) => ({
activeUsers: Number(item.active_users) || 0,
country: item.country,
paidUsers: Number(item.paid_users) || 0,
point: item.geo_point,
value: Number(item.recharge_usd_minor) || 0
}))
.sort((left, right) => right.value - left.value);
}
function findHoveredPoint(event, element, size, rotation, points) {
if (!element || !size.width || !size.height || !points.length) {
return null;
}
const rect = element.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const cx = size.width / 2;
const cy = size.height / 2;
const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47));
let nearest = null;
for (const item of points) {
const projected = project(item.point, rotation, cx, cy, radius);
if (!projected) {
continue;
}
const distance = Math.hypot(projected.x - x, projected.y - y);
if (distance <= 18 && (!nearest || distance < nearest.distance)) {
nearest = { distance, item, x: projected.x, y: projected.y };
}
}
if (!nearest) {
return null;
}
return {
item: nearest.item,
x: clamp(nearest.x + 14, 8, size.width - 136),
y: clamp(nearest.y - 76, 8, size.height - 96)
};
}
function extractRings(features = []) {
const rings = [];
for (const feature of features) {
const geometry = feature.geometry;
if (!geometry) {
continue;
}
if (geometry.type === "Polygon") {
rings.push(...geometry.coordinates);
}
if (geometry.type === "MultiPolygon") {
for (const polygon of geometry.coordinates) {
rings.push(...polygon);
}
}
}
return rings;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeLongitude(value) {
return ((((value + 180) % 360) + 360) % 360) - 180;
}

View File

@ -0,0 +1,20 @@
import { formatCoin } from "../utils/format.js";
export function HorizontalBars({ items }) {
const max = Math.max(...items.map((item) => item.value), 1);
return (
<div className="bar-list">
<div className="section-label">礼物消费排行</div>
{items.map((item, index) => (
<div className="bar-row" key={item.name}>
<span>{index + 1}</span>
<em>{item.name}</em>
<div className="bar-track">
<i style={{ width: `${Math.max(4, (item.value / max) * 100)}%` }} />
</div>
<b>{formatCoin(item.value)}</b>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,43 @@
import { EChart } from "../charts/EChart.jsx";
import { createDonutOption } from "../charts/options/createDonutOption.js";
import { formatCoin, formatPercent, formatWholeMoney } from "../utils/format.js";
import { FiveMetricRow } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function LuckyGiftPanel({ luckyMetrics, payoutDistribution }) {
return (
<Panel title="幸运礼物返奖">
<FiveMetricRow items={luckyMetrics} />
<div className="donut-layout">
<div className="donut-wrap">
<EChart className="donut-chart" option={createDonutOption(payoutDistribution)} />
<div className="donut-center">
<strong>{formatWholeMoney(totalValue(payoutDistribution))}</strong>
<span>返奖总额</span>
</div>
</div>
<DistributionList items={payoutDistribution} />
</div>
</Panel>
);
}
function totalValue(items) {
return items.reduce((sum, item) => sum + item.value, 0);
}
function DistributionList({ items }) {
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
return (
<div className="distribution-list">
{items.map((item) => (
<div className="distribution-row" key={item.name}>
<span />
<em>{item.name}</em>
<strong>{formatPercent(item.value / total)}</strong>
<b>{formatCoin(item.value)}</b>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,16 @@
export function MetricCard({ item }) {
const Icon = item.icon;
return (
<article className="metric-card">
<div className="metric-icon">{Icon ? <Icon /> : null}</div>
<div className="metric-content">
<span>{item.label}</span>
<strong>{item.value}</strong>
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
<span>{item.caption}</span>
<b>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : ""}</b>
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,48 @@
export function CoinIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M6 8c0-2.2 3.6-4 8-4s8 1.8 8 4-3.6 4-8 4-8-1.8-8-4Zm0 5c0 2.2 3.6 4 8 4s8-1.8 8-4M6 18c0 2.2 3.6 4 8 4s8-1.8 8-4" />
<path d="M22 13v5M6 8v10" />
</svg>
);
}
export function UserPlusIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M11 14a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM3 24c1-4.2 4-6.5 8-6.5s7 2.3 8 6.5M22 8v8M18 12h8" />
</svg>
);
}
export function ActiveUsersIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M9 14a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM19 14a3.8 3.8 0 1 0 0-7.6 3.8 3.8 0 0 0 0 7.6ZM2.8 24c.8-4 3.2-6 6.2-6s5.4 2 6.2 6M15.5 18.5c2.8.2 5 2 5.7 5.5" />
</svg>
);
}
export function CrownIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="m4 9 5.5 4.5L14 6l4.5 7.5L24 9l-2.2 12H6.2L4 9ZM7 24h14" />
</svg>
);
}
export function TrendIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M4 23h20M7 19v-5M13 19V9M19 19V5M6 11l6-5 5 4 6-8" />
</svg>
);
}
export function StarUserIcon() {
return (
<svg viewBox="0 0 28 28" aria-hidden="true">
<path d="M10 13a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM3 24c.8-4.4 3.5-7 7-7 1.4 0 2.7.4 3.8 1.2M21 15l1.5 3 3.3.5-2.4 2.3.6 3.2-3-1.6-3 1.6.6-3.2-2.4-2.3 3.3-.5L21 15Z" />
</svg>
);
}

View File

@ -0,0 +1,53 @@
export function MiniStat({ item }) {
const caption = parseCaption(item.caption);
const deltaTone = item.deltaTone || (caption.delta.startsWith("-") ? "down" : "up");
return (
<article className="mini-stat">
<span>{item.label}</span>
<strong>{item.value}</strong>
<small className={`mini-stat-delta mini-stat-delta--${deltaTone}`}>
<span>{caption.label}</span>
{caption.delta ? <b>{`${deltaTone === "down" ? "↓" : "↑"} ${caption.delta.replace(/^[-+]/, "")}`}</b> : null}
</small>
</article>
);
}
function parseCaption(caption = "") {
const normalized = String(caption).trim().replace(/\s+/g, " ");
const match = normalized.match(/^(.*)\s([+-][\d.]+(?:%|pp)?)$/);
if (!match) {
return { delta: "", label: normalized };
}
return { delta: match[2], label: match[1] };
}
export function TwoMetricRow({ items }) {
return (
<div className="two-metric-row">
{items.map((item) => (
<MiniStat item={item} key={item.label} />
))}
</div>
);
}
export function FourMetricRow({ items }) {
return (
<div className="four-metric-row">
{items.map((item) => (
<MiniStat item={item} key={item.label} />
))}
</div>
);
}
export function FiveMetricRow({ items }) {
return (
<div className="five-metric-row">
{items.map((item) => (
<MiniStat item={item} key={item.label} />
))}
</div>
);
}

View File

@ -0,0 +1,11 @@
export function Panel({ children, className = "", title }) {
return (
<section className={["databi-panel", className].filter(Boolean).join(" ")}>
<div className="panel-head">
<div className="panel-title">{title}</div>
<span className="panel-info">i</span>
</div>
{children}
</section>
);
}

View File

@ -0,0 +1,15 @@
export function RankList({ items, title, valueFormatter }) {
return (
<div className="rank-list">
<div className="rank-title">{title}</div>
{items.map((item, index) => (
<div className="rank-row" key={item.country}>
<span>{index + 1}</span>
{item.flag ? <i className="rank-flag">{item.flag}</i> : null}
<strong>{item.country}</strong>
<b>{valueFormatter(item.recharge_usd_minor)}</b>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,11 @@
import { EChart } from "../charts/EChart.jsx";
import { createRevenueOption } from "../charts/options/createRevenueOption.js";
import { Panel } from "./Panel.jsx";
export function RevenueTrendPanel({ revenueSeries }) {
return (
<Panel title="充值趋势">
<EChart className="trend-chart" option={createRevenueOption(revenueSeries)} />
</Panel>
);
}

View File

@ -0,0 +1,12 @@
import { HorizontalBars } from "./HorizontalBars.jsx";
import { TwoMetricRow } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function RoomGiftPanel({ giftRanking, roomMetrics }) {
return (
<Panel title="房间与礼物">
<TwoMetricRow items={roomMetrics} />
<HorizontalBars items={giftRanking} />
</Panel>
);
}

View File

@ -0,0 +1,8 @@
export const countryOptions = [
{ id: 0, label: "全部区域" },
{ id: 101, label: "印度尼西亚" },
{ id: 102, label: "印度" },
{ id: 103, label: "美国" },
{ id: 104, label: "巴西" },
{ id: 105, label: "土耳其" }
];

View File

@ -0,0 +1,42 @@
const countryMeta = [
{ code: "BR", label: "巴西", names: ["Brazil", "巴西"], point: [-51.93, -14.24] },
{ code: "EG", label: "埃及", names: ["Egypt", "埃及"], point: [30.8, 26.82] },
{ code: "ID", label: "印度尼西亚", names: ["Indonesia", "印度尼西亚"], point: [113.92, -0.79] },
{ code: "IN", label: "印度", names: ["India", "印度"], point: [78.96, 20.59] },
{ code: "TH", label: "泰国", names: ["Thailand", "泰国"], point: [100.99, 15.87] },
{ code: "TR", label: "土耳其", names: ["Turkey", "土耳其"], point: [35.24, 38.96] },
{ code: "US", label: "美国", names: ["United States", "USA", "United States of America", "美国"], point: [-95.71, 37.09] },
{ code: "VN", label: "越南", names: ["Vietnam", "Viet Nam", "越南"], point: [108.28, 14.06] }
];
const metaByName = new Map();
const metaByCode = new Map();
for (const item of countryMeta) {
metaByCode.set(item.code, item);
for (const name of item.names) {
metaByName.set(normalizeKey(name), item);
}
}
export function resolveCountryMeta({ code, name }) {
const fromCode = code ? metaByCode.get(String(code).trim().toUpperCase()) : null;
if (fromCode) {
return fromCode;
}
return metaByName.get(normalizeKey(name)) || null;
}
export function countryFlag(code) {
const normalized = String(code || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(normalized)) {
return "";
}
return Array.from(normalized)
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join("");
}
function normalizeKey(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -0,0 +1,174 @@
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
import { countryFlag, resolveCountryMeta } from "./countryMeta.js";
import { 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;
const recharge = numberValue(source.recharge_usd_minor);
const activeUsers = numberValue(source.active_users);
const paidUsers = numberValue(source.paid_users);
const newUsers = numberValue(source.new_users);
const countryBreakdown = normalizeCountries(source, countryId);
const revenueSeries = normalizeRevenueSeries(source);
const payoutDistribution = normalizeDistribution(source);
const gameRanking = normalizeGameRanking(source);
return {
appCode,
countryBreakdown,
funnel: [
{ name: "访客", rate: 1, value: newUsers },
{ name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers },
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
{ 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) }
],
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(source.new_user_recharge_usd_minor) },
{ 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(source.arpu_usd_minor) },
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
],
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) }
],
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) }
],
sideMetrics: [
{ caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(source.arpu_usd_minor) },
{ caption: "近 7 日 +0.18pp", label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
{ caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
],
topCountries: countryBreakdown.slice(0, 5),
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
};
}
function normalizeRevenueSeries(source) {
if (Array.isArray(source.daily_series) && source.daily_series.length) {
return source.daily_series.map((item) => ({
coin_seller: numberValue(item.coin_seller ?? item.coinSeller),
google: numberValue(item.google),
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
lineTotal: numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? item.total ?? item.recharge_usd_minor),
mifapay: numberValue(item.mifapay ?? item.mifa_pay),
total: numberValue(item.total ?? item.recharge_usd_minor)
}));
}
return [
{
coin_seller: numberValue(source.coin_seller_recharge_usd_minor),
google: numberValue(source.google_recharge_usd_minor),
label: "当前",
lineTotal: numberValue(source.recharge_usd_minor),
mifapay: numberValue(source.mifapay_recharge_usd_minor),
total: numberValue(source.recharge_usd_minor)
}
];
}
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 totalRecharge = rows.reduce((sum, item) => sum + numberValue(item.recharge_usd_minor), 0) || 1;
return rows
.map((item, index) => ({
...normalizeCountryRow(item, index, totalRecharge)
}))
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
}
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 meta = resolveCountryMeta({ code, name: country });
const recharge = numberValue(item.recharge_usd_minor);
return {
active_users: numberValue(item.active_users),
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
arppu_usd_minor: numberValue(item.arppu_usd_minor),
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
country: meta?.label || country,
country_code: code || meta?.code || "",
flag: countryFlag(code || meta?.code),
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
paid_users: numberValue(item.paid_users),
payer_rate: ratio(item.paid_users, item.active_users),
recharge_usd_minor: recharge,
recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate),
recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers),
share: recharge / totalRecharge,
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)
};
}
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 [{ name: "礼物消费", value: numberValue(source.gift_coin_spent) }];
}
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) }));
}
return [
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) }
];
}
function normalizeGameRanking(source) {
if (Array.isArray(source.game_ranking) && source.game_ranking.length) {
return source.game_ranking.map((item) => ({
game_id: item.game_id || item.gameId || "-",
platform_code: item.platform_code || item.platformCode || "",
profit_rate: numberValue(item.profit_rate ?? item.profitRate),
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) }];
}
function localizeSeriesLabel(label) {
const match = String(label).match(/^May\s+(\d{1,2})$/i);
if (match) {
return `5月${match[1]}`;
}
return label;
}

View File

@ -0,0 +1,67 @@
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()
};

File diff suppressed because one or more lines are too long

10
databi/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { DatabiApp } from "./DatabiApp.jsx";
import "./styles/index.css";
createRoot(document.getElementById("databi-root")).render(
<React.StrictMode>
<DatabiApp />
</React.StrictMode>
);

View File

@ -0,0 +1,33 @@
* {
box-sizing: border-box;
}
body {
min-width: 1280px;
min-height: 720px;
margin: 0;
overflow: auto;
background:
linear-gradient(rgba(29, 216, 242, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(29, 216, 242, 0.04) 1px, transparent 1px),
radial-gradient(circle at 18% 24%, rgba(28, 216, 242, 0.12), transparent 28%),
radial-gradient(circle at 82% 12%, rgba(31, 201, 170, 0.1), transparent 26%),
#031322;
background-size:
24px 24px,
24px 24px,
auto,
auto,
auto;
color: #e9f6ff;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}

188
databi/src/styles/cards.css Normal file
View File

@ -0,0 +1,188 @@
.metric-card,
.databi-panel,
.mini-stat {
border: 1px solid rgba(35, 128, 180, 0.46);
background:
linear-gradient(180deg, rgba(9, 43, 70, 0.95), rgba(5, 27, 47, 0.92)),
rgba(8, 34, 56, 0.92);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 14px 36px rgba(0, 0, 0, 0.18);
}
.metric-card {
display: grid;
grid-template-columns: 44px 1fr;
height: 120px;
align-items: center;
gap: 15px;
padding: 14px 18px;
border-radius: 6px;
}
.metric-icon {
display: grid;
width: 40px;
height: 40px;
place-items: center;
color: #27e4f5;
}
.metric-icon svg {
width: 38px;
height: 38px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.9;
}
.metric-content {
min-width: 0;
}
.metric-content > span,
.mini-stat > span,
.section-label,
.rank-title {
color: #98afc9;
font-size: 12px;
}
.metric-content strong {
display: block;
margin-top: 6px;
overflow: hidden;
color: #f6fbff;
font-size: 24px;
font-weight: 780;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-delta {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 9px;
padding-top: 8px;
border-top: 1px solid rgba(120, 169, 205, 0.16);
color: #6f8aa4;
font-size: 12px;
}
.metric-delta b,
.positive {
color: #24d79f;
font-weight: 760;
}
.metric-delta--down b {
color: #f5a623;
}
.side-metrics {
display: grid;
gap: 8px;
}
.funnel-layout .side-metrics {
grid-template-columns: 1fr;
align-content: start;
gap: 9px;
}
.funnel-layout .mini-stat {
height: 74px;
padding: 10px 11px;
}
.mini-stat {
min-width: 0;
padding: 10px 11px;
border-radius: 5px;
}
.mini-stat strong {
display: block;
margin-top: 4px;
overflow: hidden;
color: #f6fbff;
font-size: 20px;
font-weight: 780;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.side-metrics .mini-stat strong {
font-size: 21px;
}
.mini-stat small {
display: flex;
justify-content: space-between;
gap: 6px;
margin-top: 3px;
overflow: hidden;
color: #6f8aa4;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.four-metric-row .mini-stat small,
.five-metric-row .mini-stat small {
gap: 2px;
font-size: 8px;
}
.mini-stat small span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-stat small b {
flex: 0 0 auto;
color: #24d79f;
font-weight: 760;
}
.mini-stat-delta--down b {
color: #f5a623;
}
.two-metric-row,
.four-metric-row,
.five-metric-row {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.two-metric-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.four-metric-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.five-metric-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.five-metric-row .mini-stat strong {
font-size: clamp(10px, 0.72vw, 16px);
}
.four-metric-row .mini-stat strong {
font-size: clamp(13px, 0.8vw, 16px);
}
.five-metric-row .mini-stat {
padding-inline: 6px;
}

View File

@ -0,0 +1,177 @@
.databi-chart {
width: 100%;
min-width: 0;
}
.geo-chart {
position: relative;
z-index: 1;
height: 232px;
}
.databi-grid--top .geo-chart {
height: 100%;
}
.trend-chart,
.funnel-chart {
height: 232px;
}
.databi-grid--top .trend-chart {
flex: 1;
height: auto;
min-height: 0;
}
.donut-chart {
height: 182px;
}
.bar-list {
display: grid;
gap: 8px;
}
.databi-grid--middle .bar-list {
gap: 15px;
margin-top: 12px;
}
.bar-row {
display: grid;
grid-template-columns: 20px minmax(86px, 112px) minmax(0, 1fr) 86px;
align-items: center;
gap: 9px;
min-height: 24px;
}
.bar-track {
position: relative;
height: 9px;
overflow: hidden;
border-radius: 0;
background: rgba(95, 151, 193, 0.18);
}
.bar-track i {
position: absolute;
inset: 0 auto 0 0;
background: linear-gradient(90deg, #25d8f5, #1aa7ef);
box-shadow: 0 0 12px rgba(37, 216, 245, 0.42);
}
.donut-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
align-items: center;
gap: 18px;
}
.databi-grid--middle .donut-layout {
flex: 1;
min-height: 0;
}
.donut-wrap {
position: relative;
}
.distribution-list {
display: grid;
gap: 10px;
}
.distribution-row {
display: grid;
grid-template-columns: 8px minmax(150px, 1fr) 58px 86px;
align-items: center;
gap: 8px;
min-width: 0;
}
.distribution-row span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #25d8f5;
}
.distribution-row:nth-child(2) span {
background: #1dc9aa;
}
.distribution-row:nth-child(3) span {
background: #367be8;
}
.distribution-row:nth-child(4) span {
background: #9a9cfb;
}
.distribution-row em {
min-width: 0;
overflow: hidden;
color: #cfe3f7;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.distribution-row strong {
color: #d9ebff;
font-weight: 680;
}
.game-table {
display: grid;
gap: 8px;
}
.databi-grid--middle .game-table {
gap: 13px;
margin-top: 12px;
}
.game-table-head {
display: grid;
grid-template-columns: 1fr 86px 64px;
gap: 10px;
color: #9eb7ce;
font-size: 12px;
}
.game-row {
display: grid;
grid-template-columns: 20px minmax(82px, 126px) minmax(0, 1fr) 86px 64px;
align-items: center;
gap: 9px;
min-height: 24px;
}
.game-row strong {
color: #24d79f;
font-weight: 720;
}
.donut-center {
position: absolute;
top: 50%;
left: 50%;
display: grid;
width: 82px;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.donut-center strong {
color: #f6fbff;
font-size: 18px;
font-weight: 820;
}
.donut-center span {
color: #9eb7ce;
font-size: 11px;
}

View File

@ -0,0 +1,8 @@
@import "./tokens.css";
@import "./base.css";
@import "./layout.css";
@import "./cards.css";
@import "./panels.css";
@import "./charts.css";
@import "./tables.css";
@import "./responsive.css";

View File

@ -0,0 +1,232 @@
.databi-shell {
width: 100vw;
min-width: 1280px;
min-height: 720px;
padding: 8px 24px 18px;
}
.databi-screen {
display: flex;
min-height: calc(100vh - 8px);
flex-direction: column;
}
.databi-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 46px;
margin: 0 -24px 14px;
padding: 0 24px;
border-bottom: 1px solid rgba(34, 121, 170, 0.46);
background: rgba(2, 15, 31, 0.36);
}
.brand-lockup {
display: flex;
min-width: 0;
align-items: center;
gap: 14px;
}
.brand-emblem {
display: grid;
position: relative;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid rgba(39, 228, 245, 0.7);
border-radius: 50%;
background: rgba(11, 45, 70, 0.82);
box-shadow:
inset 0 0 16px rgba(39, 228, 245, 0.18),
0 0 22px rgba(39, 228, 245, 0.16);
}
.brand-emblem::before {
width: 19px;
height: 19px;
border: 2px solid #27e4f5;
border-radius: 50%;
background:
linear-gradient(90deg, transparent 45%, #27e4f5 45% 55%, transparent 55%),
linear-gradient(transparent 45%, #27e4f5 45% 55%, transparent 55%);
box-shadow: 0 0 12px rgba(39, 228, 245, 0.55);
content: "";
}
.brand-emblem::after {
position: absolute;
inset: 7px;
border: 1px solid rgba(39, 228, 245, 0.55);
border-radius: 50%;
content: "";
}
.brand-lockup h1 {
margin: 0;
font-size: 24px;
font-weight: 760;
letter-spacing: 0;
}
.header-meta {
display: flex;
gap: 12px;
margin-top: 2px;
color: #7fa1bf;
font-size: 12px;
}
.header-controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 10px;
}
.range-control,
.region-control {
display: inline-flex;
height: 34px;
align-items: center;
gap: 10px;
border: 1px solid rgba(64, 148, 197, 0.42);
border-radius: 6px;
background: rgba(7, 29, 48, 0.86);
color: #d8ebff;
}
.range-control {
width: 278px;
padding: 0 10px;
}
.range-control input,
.region-control select {
min-width: 0;
border: 0;
background: transparent;
color: #d8ebff;
font: inherit;
outline: 0;
}
.range-control input {
width: 105px;
color-scheme: dark;
}
.range-control input::-webkit-calendar-picker-indicator {
display: none;
}
.range-control svg,
.region-control svg {
color: #ccd9e8;
}
.region-control {
width: 244px;
padding: 0 12px;
}
.region-control select {
flex: 1;
}
.range-control:focus-within,
.region-control:focus-within {
border-color: rgba(39, 228, 245, 0.86);
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.1);
}
.live-dot {
display: inline-flex;
align-items: center;
gap: 7px;
color: #20e7d6;
font-size: 13px;
font-weight: 720;
}
.live-dot::before {
width: 7px;
height: 7px;
border-radius: 50%;
background: #20e7d6;
box-shadow: 0 0 14px rgba(32, 231, 214, 0.72);
content: "";
}
.live-dot--warn {
color: #f7a33a;
}
.live-dot--warn::before {
background: #f7a33a;
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
margin-bottom: 12px;
}
.databi-grid {
display: grid;
gap: 12px;
margin-bottom: 12px;
}
.databi-grid--top {
grid-template-columns: 1.1fr 1.05fr 1.1fr;
}
.databi-grid--middle {
grid-template-columns: 1fr 1fr 1fr;
}
.databi-grid--top .databi-panel {
height: auto;
min-height: 0;
}
.databi-grid--middle .databi-panel {
height: auto;
min-height: 0;
}
.databi-screen .databi-grid--top,
.databi-screen .databi-grid--middle {
flex: 1;
min-height: 0;
}
.databi-screen .databi-grid--middle {
margin-bottom: 0;
}
.databi-screen .databi-grid--top .databi-panel,
.databi-screen .databi-grid--middle .databi-panel {
display: flex;
flex-direction: column;
}
.databi-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 34px;
margin-top: 10px;
color: #7f91a8;
font-size: 12px;
}
.databi-footer strong {
color: #f7a33a;
font-weight: 760;
}

View File

@ -0,0 +1,309 @@
.databi-panel {
min-width: 0;
min-height: 236px;
padding: 12px 14px;
border-radius: 6px;
}
.panel--table {
min-height: 190px;
margin-top: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.panel-title {
margin: 0;
color: #f3f9ff;
font-size: 14px;
font-weight: 760;
}
.panel-info {
display: grid;
width: 16px;
height: 16px;
place-items: center;
border: 1px solid rgba(157, 199, 232, 0.72);
border-radius: 50%;
color: #9dc7e8;
font-size: 11px;
}
.geo-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 14px;
height: 232px;
}
.databi-grid--top .geo-layout,
.databi-grid--top .funnel-layout {
flex: 1;
height: auto;
min-height: 0;
}
.databi-grid--top .geo-map {
height: 100%;
}
.geo-map {
position: relative;
min-width: 0;
overflow: hidden;
border-radius: 4px;
background:
radial-gradient(circle at 50% 50%, rgba(39, 228, 245, 0.1), transparent 48%),
radial-gradient(ellipse at 48% 52%, rgba(36, 116, 154, 0.2), transparent 68%),
linear-gradient(180deg, rgba(11, 48, 74, 0.62), rgba(5, 25, 43, 0.24));
}
.geo-map::before {
position: absolute;
inset: 26px 18px;
z-index: 0;
border: 0;
border-radius: 0;
background:
linear-gradient(90deg, rgba(39, 228, 245, 0.055) 1px, transparent 1px) 0 0 / 25% 100%,
linear-gradient(rgba(39, 228, 245, 0.05) 1px, transparent 1px) 0 0 / 100% 33%;
content: "";
pointer-events: none;
}
.geo-map::after {
position: absolute;
inset: 28px 28px;
z-index: 1;
border-top: 1px solid rgba(39, 228, 245, 0.06);
border-bottom: 1px solid rgba(39, 228, 245, 0.06);
background:
radial-gradient(ellipse at 24% 50%, rgba(37, 228, 245, 0.09), transparent 43%),
radial-gradient(ellipse at 74% 52%, rgba(37, 228, 245, 0.08), transparent 48%);
content: "";
pointer-events: none;
}
.rank-block {
display: grid;
align-content: start;
gap: 7px;
min-width: 0;
}
.globe-map {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
user-select: none;
}
.globe-map.is-dragging {
cursor: grabbing;
}
.globe-canvas {
display: block;
width: 100%;
height: 100%;
}
.globe-tooltip {
position: absolute;
z-index: 5;
display: grid;
gap: 3px;
min-width: 128px;
padding: 8px 10px;
border: 1px solid rgba(52, 199, 229, 0.48);
border-radius: 4px;
background: rgba(4, 24, 41, 0.94);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(37, 228, 245, 0.2);
color: #cfe3f7;
font-size: 11px;
line-height: 1.2;
pointer-events: none;
}
.globe-tooltip strong {
color: #f2fbff;
font-size: 12px;
font-weight: 760;
}
.globe-tooltip span {
color: #9eb7ce;
font-variant-numeric: tabular-nums;
}
.geo-scale {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 4;
display: grid;
gap: 5px;
color: #cdeeff;
font-size: 12px;
line-height: 1;
pointer-events: none;
}
.geo-scale i {
width: 13px;
height: 58px;
border: 1px solid rgba(43, 229, 245, 0.36);
border-radius: 2px;
background: linear-gradient(180deg, #2ff4e9 0%, #1fadc3 52%, #16759a 100%);
box-shadow: 0 0 14px rgba(37, 228, 245, 0.24);
}
.rank-list {
display: grid;
align-content: start;
gap: 7px;
min-width: 0;
}
.rank-row {
display: grid;
grid-template-columns: 17px 18px minmax(0, 1fr) 54px;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 27px;
padding: 0 6px;
border: 1px solid rgba(81, 154, 203, 0.2);
border-radius: 4px;
background: rgba(6, 27, 46, 0.72);
}
.rank-flag {
display: inline-grid;
min-width: 18px;
place-items: center;
font-style: normal;
line-height: 1;
}
.rank-row span,
.bar-row span,
.game-row span {
color: #9eb7ce;
font-variant-numeric: tabular-nums;
}
.rank-row strong,
.bar-row em,
.game-row em {
min-width: 0;
overflow: hidden;
color: #e9f6ff;
font-style: normal;
font-weight: 640;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-row strong,
.rank-row b {
font-size: 13px;
}
.all-countries-button {
height: 25px;
border: 1px solid rgba(39, 148, 203, 0.5);
border-radius: 4px;
background: rgba(7, 35, 58, 0.72);
color: #2fe8f3;
font-size: 12px;
}
.rank-row b,
.bar-row b,
.game-row b,
.country-table td {
color: #cfe3f7;
font-weight: 620;
font-variant-numeric: tabular-nums;
}
.funnel-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 14px;
align-items: center;
}
.funnel-stack {
display: grid;
gap: 13px;
padding: 2px 0 0 18px;
}
.funnel-stage-row {
display: grid;
grid-template-columns: minmax(210px, 1fr) 52px;
align-items: center;
gap: 12px;
}
.funnel-stage-row > b {
color: #9eb7ce;
font-size: 12px;
font-weight: 700;
text-align: right;
font-variant-numeric: tabular-nums;
}
.funnel-stage {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 10px;
height: 55px;
padding: 0 38px;
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
border: 1px solid rgba(196, 241, 255, 0.65);
background: linear-gradient(180deg, rgba(54, 138, 234, 0.94), rgba(30, 106, 196, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 16px rgba(39, 228, 245, 0.12);
color: #edf8ff;
}
.funnel-stage span {
font-size: 12px;
font-weight: 720;
line-height: 1.1;
}
.funnel-stage strong {
color: #f6fbff;
font-size: 17px;
font-weight: 820;
font-variant-numeric: tabular-nums;
}
.funnel-stage--2 {
margin-inline: 16px;
background: linear-gradient(180deg, rgba(20, 174, 194, 0.94), rgba(16, 137, 173, 0.92));
}
.funnel-stage--3 {
margin-inline: 34px;
background: linear-gradient(180deg, rgba(31, 190, 177, 0.94), rgba(19, 155, 157, 0.92));
}
.funnel-stage--4 {
margin-inline: 52px;
background: linear-gradient(180deg, rgba(32, 203, 161, 0.94), rgba(18, 168, 137, 0.92));
}

View File

@ -0,0 +1,209 @@
@media (max-width: 1440px) {
.databi-shell {
padding: 14px;
}
.metric-grid {
gap: 10px;
}
.metric-card {
grid-template-columns: 34px 1fr;
gap: 10px;
padding: 12px;
}
.metric-content strong {
font-size: 18px;
}
.funnel-layout {
grid-template-columns: minmax(0, 1fr) 176px;
gap: 10px;
}
.funnel-stack {
gap: 12px;
padding-left: 2px;
}
.funnel-stage-row {
grid-template-columns: minmax(160px, 1fr) 46px;
gap: 8px;
}
.funnel-stage {
height: 55px;
padding: 0 18px;
}
.funnel-stage span {
font-size: 11px;
}
.funnel-stage strong {
font-size: 15px;
}
.funnel-stage--2 {
margin-inline: 8px;
}
.funnel-stage--3 {
margin-inline: 14px;
}
.funnel-stage--4 {
margin-inline: 20px;
}
.funnel-layout .mini-stat {
height: 74px;
padding: 9px 10px;
}
.funnel-layout .mini-stat strong {
font-size: 20px;
}
.funnel-layout .mini-stat small {
font-size: 8px;
}
.donut-layout {
grid-template-columns: 128px minmax(0, 1fr);
gap: 12px;
}
.donut-chart {
height: 132px;
}
.distribution-row {
grid-template-columns: 8px minmax(118px, 1fr) 48px 68px;
gap: 7px;
}
.distribution-row em {
font-size: 12px;
}
.five-metric-row {
gap: 7px;
}
.five-metric-row .mini-stat {
padding: 8px 4px;
}
.five-metric-row .mini-stat strong {
font-size: 10px;
}
.five-metric-row .mini-stat small {
font-size: 7px;
}
}
@media (max-width: 1180px) {
body,
.databi-shell {
min-width: 0;
}
.databi-header,
.header-controls {
align-items: flex-start;
flex-wrap: wrap;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.databi-grid--top,
.databi-grid--middle {
grid-template-columns: 1fr;
}
.databi-grid--top .databi-panel:last-child {
grid-column: auto;
}
}
@media (max-width: 1300px) {
.databi-shell {
min-width: 1280px;
padding-inline: 14px;
}
.databi-header {
margin-inline: -14px;
padding-inline: 14px;
}
.metric-card {
grid-template-columns: 36px 1fr;
padding-inline: 12px;
}
.metric-content strong {
font-size: 20px;
}
.metric-delta {
font-size: 11px;
}
.metric-delta span {
white-space: nowrap;
}
.funnel-layout {
grid-template-columns: minmax(0, 1fr) 160px;
gap: 8px;
}
.funnel-stage-row {
grid-template-columns: minmax(150px, 1fr) 40px;
gap: 6px;
}
.funnel-stage {
padding: 0 12px;
}
.funnel-stage strong {
font-size: 14px;
}
.funnel-stage--2 {
margin-inline: 5px;
}
.funnel-stage--3 {
margin-inline: 9px;
}
.funnel-stage--4 {
margin-inline: 13px;
}
.funnel-layout .mini-stat {
padding-inline: 8px;
}
.funnel-layout .mini-stat strong {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
transition-duration: 1ms !important;
}
}

View File

@ -0,0 +1,75 @@
.country-table-wrap {
overflow-x: auto;
}
.country-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
}
.country-table--wide {
min-width: 1680px;
}
.country-table th,
.country-table td {
height: 25px;
padding: 0 12px;
border-bottom: 1px solid rgba(108, 157, 196, 0.22);
border-right: 1px solid rgba(108, 157, 196, 0.15);
text-align: right;
white-space: nowrap;
}
.country-table th {
background: rgba(128, 171, 211, 0.08);
color: #9eb7ce;
font-size: 12px;
font-weight: 720;
}
.country-table th:nth-child(2),
.country-table td:nth-child(2) {
text-align: left;
}
.country-table th:first-child,
.country-table td:first-child {
width: 42px;
text-align: center;
}
.table-flag {
display: inline-block;
width: 24px;
margin-right: 8px;
font-style: normal;
text-align: center;
}
.table-tabs {
display: inline-flex;
gap: 3px;
padding: 2px;
border: 1px solid rgba(61, 141, 190, 0.42);
border-radius: 4px;
background: rgba(7, 28, 47, 0.72);
}
.table-tabs button {
height: 24px;
padding: 0 12px;
border: 0;
border-radius: 3px;
background: transparent;
color: #9eb7ce;
font-size: 12px;
font-weight: 720;
}
.table-tabs button.is-active {
background: rgba(37, 216, 245, 0.18);
color: #e9f6ff;
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
}

View File

@ -0,0 +1,7 @@
:root {
color-scheme: dark;
font-family: Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
font-size: 14px;
line-height: 1.45;
text-rendering: geometricPrecision;
}

View File

@ -0,0 +1,56 @@
import { numberValue } from "./number.js";
export function formatMoney(value) {
const amount = moneyMajor(value);
if (Math.abs(amount) >= 1_000_000) {
return `$${(amount / 1_000_000).toFixed(2)}M`;
}
if (Math.abs(amount) >= 10_000) {
return `$${Math.round(amount).toLocaleString("en-US")}`;
}
return `$${amount.toLocaleString("en-US", { maximumFractionDigits: 2, minimumFractionDigits: amount < 10 ? 2 : 0 })}`;
}
export function formatMoneyFull(value) {
return formatSignedDollar(Math.round(moneyMajor(value)));
}
export function formatWholeMoney(value) {
return formatSignedDollar(numberValue(value));
}
export function formatCoin(value) {
return numberValue(value).toLocaleString("en-US");
}
export function formatNumber(value) {
return numberValue(value).toLocaleString("en-US");
}
export function formatPercent(value) {
return `${(numberValue(value) * 100).toFixed(2)}%`;
}
export function formatCompactPercent(value) {
return `${(numberValue(value) * 100).toFixed(1)}%`;
}
export function compactMoneyAxis(value) {
if (Math.abs(value) >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(value) >= 1_000) {
return `${Math.round(value / 1_000)}K`;
}
return value;
}
export function moneyMajor(value) {
return numberValue(value) / 100;
}
function formatSignedDollar(value) {
const amount = Number(value) || 0;
const sign = amount < 0 ? "-" : "";
return `${sign}$${Math.abs(amount).toLocaleString("en-US")}`;
}

View File

@ -0,0 +1,9 @@
export function numberValue(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
export function ratio(numerator, denominator) {
const den = numberValue(denominator);
return den > 0 ? numberValue(numerator) / den : 0;
}

27
databi/src/utils/time.js Normal file
View File

@ -0,0 +1,27 @@
export function lastDaysRange(days) {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - days + 1);
return { end: dateInputValue(end), start: dateInputValue(start) };
}
export function startOfDay(value) {
return value ? new Date(`${value}T00:00:00`).getTime() : "";
}
export function endOfDay(value) {
return value ? new Date(`${value}T23:59:59`).getTime() : "";
}
export function formatDateTime(value) {
const date = new Date(Number(value) || Date.now());
return `${dateInputValue(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
function dateInputValue(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function pad2(value) {
return String(value).padStart(2, "0");
}

View File

@ -2,6 +2,14 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
build: {
rollupOptions: {
input: {
admin: new URL("./index.html", import.meta.url).pathname,
databi: new URL("./databi/index.html", import.meta.url).pathname
}
}
},
css: {
modules: {
generateScopedName: "hy-[name]__[local]__[hash:base64:5]",