feat: add databi dashboard
This commit is contained in:
parent
49f54e0463
commit
6ddf5ee211
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
|
.waylog/
|
||||||
|
|||||||
12
databi/index.html
Normal file
12
databi/index.html
Normal 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
97
databi/src/DatabiApp.jsx
Normal 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
61
databi/src/api.js
Normal 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();
|
||||||
|
}
|
||||||
45
databi/src/charts/EChart.jsx
Normal file
45
databi/src/charts/EChart.jsx
Normal 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} />;
|
||||||
|
}
|
||||||
23
databi/src/charts/options/createDonutOption.js
Normal file
23
databi/src/charts/options/createDonutOption.js
Normal 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" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
24
databi/src/charts/options/createFunnelOption.js
Normal file
24
databi/src/charts/options/createFunnelOption.js
Normal 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" } }
|
||||||
|
};
|
||||||
|
}
|
||||||
55
databi/src/charts/options/createGeoOption.js
Normal file
55
databi/src/charts/options/createGeoOption.js
Normal 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" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
50
databi/src/charts/options/createRevenueOption.js
Normal file
50
databi/src/charts/options/createRevenueOption.js
Normal 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;
|
||||||
|
}
|
||||||
136
databi/src/components/CountryPerformanceTable.jsx
Normal file
136
databi/src/components/CountryPerformanceTable.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
databi/src/components/DatabiHeader.jsx
Normal file
35
databi/src/components/DatabiHeader.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
databi/src/components/FunnelPanel.jsx
Normal file
28
databi/src/components/FunnelPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
databi/src/components/GameEconomyPanel.jsx
Normal file
37
databi/src/components/GameEconomyPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
databi/src/components/GlobalOverviewPanel.jsx
Normal file
34
databi/src/components/GlobalOverviewPanel.jsx
Normal 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);
|
||||||
|
}
|
||||||
471
databi/src/components/GlobeMap.jsx
Normal file
471
databi/src/components/GlobeMap.jsx
Normal 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;
|
||||||
|
}
|
||||||
20
databi/src/components/HorizontalBars.jsx
Normal file
20
databi/src/components/HorizontalBars.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
databi/src/components/LuckyGiftPanel.jsx
Normal file
43
databi/src/components/LuckyGiftPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
databi/src/components/MetricCard.jsx
Normal file
16
databi/src/components/MetricCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
databi/src/components/MetricIcons.jsx
Normal file
48
databi/src/components/MetricIcons.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
databi/src/components/MiniStat.jsx
Normal file
53
databi/src/components/MiniStat.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
databi/src/components/Panel.jsx
Normal file
11
databi/src/components/Panel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
databi/src/components/RankList.jsx
Normal file
15
databi/src/components/RankList.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
databi/src/components/RevenueTrendPanel.jsx
Normal file
11
databi/src/components/RevenueTrendPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
databi/src/components/RoomGiftPanel.jsx
Normal file
12
databi/src/components/RoomGiftPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
databi/src/config/options.js
Normal file
8
databi/src/config/options.js
Normal 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: "土耳其" }
|
||||||
|
];
|
||||||
42
databi/src/data/countryMeta.js
Normal file
42
databi/src/data/countryMeta.js
Normal 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();
|
||||||
|
}
|
||||||
174
databi/src/data/createDashboardModel.js
Normal file
174
databi/src/data/createDashboardModel.js
Normal 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;
|
||||||
|
}
|
||||||
67
databi/src/data/sampleOverview.js
Normal file
67
databi/src/data/sampleOverview.js
Normal 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()
|
||||||
|
};
|
||||||
1
databi/src/data/world.json
Normal file
1
databi/src/data/world.json
Normal file
File diff suppressed because one or more lines are too long
10
databi/src/main.jsx
Normal file
10
databi/src/main.jsx
Normal 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>
|
||||||
|
);
|
||||||
33
databi/src/styles/base.css
Normal file
33
databi/src/styles/base.css
Normal 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
188
databi/src/styles/cards.css
Normal 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;
|
||||||
|
}
|
||||||
177
databi/src/styles/charts.css
Normal file
177
databi/src/styles/charts.css
Normal 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;
|
||||||
|
}
|
||||||
8
databi/src/styles/index.css
Normal file
8
databi/src/styles/index.css
Normal 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";
|
||||||
232
databi/src/styles/layout.css
Normal file
232
databi/src/styles/layout.css
Normal 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;
|
||||||
|
}
|
||||||
309
databi/src/styles/panels.css
Normal file
309
databi/src/styles/panels.css
Normal 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));
|
||||||
|
}
|
||||||
209
databi/src/styles/responsive.css
Normal file
209
databi/src/styles/responsive.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
databi/src/styles/tables.css
Normal file
75
databi/src/styles/tables.css
Normal 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);
|
||||||
|
}
|
||||||
7
databi/src/styles/tokens.css
Normal file
7
databi/src/styles/tokens.css
Normal 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;
|
||||||
|
}
|
||||||
56
databi/src/utils/format.js
Normal file
56
databi/src/utils/format.js
Normal 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")}`;
|
||||||
|
}
|
||||||
9
databi/src/utils/number.js
Normal file
9
databi/src/utils/number.js
Normal 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
27
databi/src/utils/time.js
Normal 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");
|
||||||
|
}
|
||||||
@ -2,6 +2,14 @@ import { defineConfig } from "vite";
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
css: {
|
||||||
modules: {
|
modules: {
|
||||||
generateScopedName: "hy-[name]__[local]__[hash:base64:5]",
|
generateScopedName: "hy-[name]__[local]__[hash:base64:5]",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user