@@ -90,3 +119,7 @@ function formatRankMoney(value) {
}
return formatMoney(value);
}
+
+function hasRecharge(item) {
+ return Number(item?.recharge_usd_minor) > 0;
+}
diff --git a/databi/src/components/GlobeMap.jsx b/databi/src/components/GlobeMap.jsx
index e5a8a16..55d150a 100644
--- a/databi/src/components/GlobeMap.jsx
+++ b/databi/src/components/GlobeMap.jsx
@@ -16,6 +16,20 @@ export function GlobeMap({ items }) {
const [rotation, setRotation] = useState(INITIAL_ROTATION);
const [size, setSize] = useState({ height: 0, width: 0 });
const points = useMemo(() => normalizePoints(items), [items]);
+ const featuredPoint = points[0] || null;
+ const featuredTooltip = useMemo(() => pointTooltip(featuredPoint, size, rotation), [featuredPoint, rotation, size]);
+ const visibleTooltip = dragging ? null : hovered || featuredTooltip;
+
+ useEffect(() => {
+ if (!featuredPoint?.point) {
+ return;
+ }
+ setHovered(null);
+ setRotation({
+ lat: clamp(featuredPoint.point[1], -MAX_LATITUDE, MAX_LATITUDE),
+ lon: normalizeLongitude(featuredPoint.point[0])
+ });
+ }, [featuredPoint]);
useEffect(() => {
const element = wrapRef.current;
@@ -95,12 +109,12 @@ export function GlobeMap({ items }) {
role="img"
>
- {hovered ? (
-
-
{hovered.item.country}
-
充值 {formatMoney(hovered.item.value)}
-
活跃 {formatNumber(hovered.item.activeUsers)}
-
付费 {formatNumber(hovered.item.paidUsers)}
+ {visibleTooltip ? (
+
+ {visibleTooltip.item.country}
+ 充值 {formatMoney(visibleTooltip.item.value)}
+ 活跃 {formatNumber(visibleTooltip.item.activeUsers)}
+ 付费 {formatNumber(visibleTooltip.item.paidUsers)}
) : null}
@@ -237,34 +251,35 @@ function drawRoutes(context, points, rotation, cx, cy, radius) {
function drawPoints(context, points, rotation, cx, cy, radius) {
const max = Math.max(...points.map((item) => item.value), 1);
+ const min = Math.min(...points.map((item) => item.value), max);
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 strength = normalizeStrength(item.value, min, 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)");
+ glow.addColorStop(0, colorForStrength(strength, 0.92));
+ glow.addColorStop(0.25, colorForStrength(strength, 0.42));
+ glow.addColorStop(1, colorForStrength(strength, 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.strokeStyle = colorForStrength(strength, 0.62);
context.lineWidth = 1;
context.beginPath();
context.arc(point.x, point.y, core * 2.2, 0, Math.PI * 2);
context.stroke();
- context.fillStyle = "#35f0f3";
+ context.fillStyle = colorForStrength(strength, 1);
context.shadowBlur = 16;
- context.shadowColor = "rgba(53, 240, 243, 0.78)";
+ context.shadowColor = colorForStrength(strength, 0.82);
context.beginPath();
context.arc(point.x, point.y, core, 0, Math.PI * 2);
context.fill();
@@ -397,6 +412,7 @@ function project(coordinate, rotation, cx, cy, radius) {
function normalizePoints(items = []) {
return items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
+ .filter((item) => Number(item.recharge_usd_minor) > 0)
.map((item) => ({
activeUsers: Number(item.active_users) || 0,
country: item.country,
@@ -407,6 +423,24 @@ function normalizePoints(items = []) {
.sort((left, right) => right.value - left.value);
}
+function pointTooltip(item, size, rotation) {
+ if (!item || !size.width || !size.height) {
+ return null;
+ }
+ 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));
+ const projected = project(item.point, rotation, cx, cy, radius);
+ if (!projected) {
+ return null;
+ }
+ return {
+ item,
+ x: clamp(projected.x + 14, 8, size.width - 136),
+ y: clamp(projected.y - 76, 8, size.height - 96)
+ };
+}
+
function findHoveredPoint(event, element, size, rotation, points) {
if (!element || !size.width || !size.height || !points.length) {
return null;
@@ -442,6 +476,24 @@ function findHoveredPoint(event, element, size, rotation, points) {
};
}
+function colorForStrength(strength, alpha = 1) {
+ const low = [66, 129, 255];
+ const mid = [39, 228, 245];
+ const high = [36, 215, 159];
+ const from = strength < 0.55 ? low : mid;
+ const to = strength < 0.55 ? mid : high;
+ const local = strength < 0.55 ? strength / 0.55 : (strength - 0.55) / 0.45;
+ const [red, green, blue] = from.map((channel, index) => Math.round(channel + (to[index] - channel) * local));
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
+}
+
+function normalizeStrength(value, min, max) {
+ if (max <= min) {
+ return 1;
+ }
+ return clamp((Math.sqrt(value) - Math.sqrt(min)) / (Math.sqrt(max) - Math.sqrt(min)), 0, 1);
+}
+
function extractRings(features = []) {
const rings = [];
diff --git a/databi/src/components/LuckyGiftPoolModal.jsx b/databi/src/components/LuckyGiftPoolModal.jsx
index d46649a..c4a1104 100644
--- a/databi/src/components/LuckyGiftPoolModal.jsx
+++ b/databi/src/components/LuckyGiftPoolModal.jsx
@@ -1,21 +1,60 @@
+import { useCallback, useEffect, useRef, useState } from "react";
import { formatCoin, formatPercent } from "../utils/format.js";
+const MODAL_TRANSITION_MS = 180;
+
export function LuckyGiftPoolModal({ items, onClose }) {
+ const [closing, setClosing] = useState(false);
+ const closeTimerRef = useRef(null);
const totals = items.reduce((summary, item) => ({
payout: summary.payout + item.payout,
profit: summary.profit + item.profit,
turnover: summary.turnover + item.turnover
}), { payout: 0, profit: 0, turnover: 0 });
+ const requestClose = useCallback(() => {
+ if (closing) {
+ return;
+ }
+ setClosing(true);
+ closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS);
+ }, [closing, onClose]);
+
+ useEffect(() => () => {
+ if (closeTimerRef.current) {
+ window.clearTimeout(closeTimerRef.current);
+ }
+ }, []);
+
+ useEffect(() => {
+ const closeOnEscape = (event) => {
+ if (event.key === "Escape") {
+ requestClose();
+ }
+ };
+ window.addEventListener("keydown", closeOnEscape);
+ return () => window.removeEventListener("keydown", closeOnEscape);
+ }, [requestClose]);
+
return (
-
-
+
{
+ if (event.target === event.currentTarget) {
+ requestClose();
+ }
+ }}
+ >
+
diff --git a/databi/src/components/MetricCard.jsx b/databi/src/components/MetricCard.jsx
index e98c3d5..96af163 100644
--- a/databi/src/components/MetricCard.jsx
+++ b/databi/src/components/MetricCard.jsx
@@ -27,19 +27,77 @@ export function MetricCard({ item, loading = false, onClick }) {
}
} : undefined}
>
- {Icon ?
: null}
+ {Icon ? : null}
{item.label}
{UnitIcon ? : null}
{item.unit ? {item.unit} : null}
-
{item.value}
-
- {item.caption}
- {item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}
-
+ {Array.isArray(item.subMetrics) && item.subMetrics.length ?
:
}
+
);
}
+
+function MetricMainValue({ item }) {
+ return (
+ <>
+ {item.value}
+
+ >
+ );
+}
+
+function MetricSubValues({ items }) {
+ return (
+
+ {items.map((subItem) => (
+
+ {subItem.label}
+ {subItem.value}
+
+
+ ))}
+
+ );
+}
+
+function MetricDelta({ compact = false, item }) {
+ return (
+
+ {item.caption}
+ {item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}
+
+ );
+}
+
+function MetricSparkline({ series }) {
+ const lines = Array.isArray(series) ? series.filter((item) => Array.isArray(item.data) && item.data.length > 1) : [];
+ if (!lines.length) {
+ return ;
+ }
+ const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0));
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ return (
+
+ );
+}
+
+function sparklinePoints(points, min, max) {
+ const width = 100;
+ const height = 24;
+ const count = points.length;
+ return points.map((point, index) => {
+ const x = count <= 1 ? width : (index / (count - 1)) * width;
+ const normalized = max <= min ? 0.5 : ((Number(point.value) || 0) - min) / (max - min);
+ const y = height - normalized * 18 - 3;
+ return `${x.toFixed(2)},${y.toFixed(2)}`;
+ }).join(" ");
+}
diff --git a/databi/src/components/MetricTrendModal.jsx b/databi/src/components/MetricTrendModal.jsx
new file mode 100644
index 0000000..8265371
--- /dev/null
+++ b/databi/src/components/MetricTrendModal.jsx
@@ -0,0 +1,64 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { EChart } from "../charts/EChart.jsx";
+import { createMetricTrendOption } from "../charts/options/createMetricTrendOption.js";
+
+const MODAL_TRANSITION_MS = 180;
+
+export function MetricTrendModal({ item, onClose }) {
+ const [closing, setClosing] = useState(false);
+ const closeTimerRef = useRef(null);
+ const requestClose = useCallback(() => {
+ if (closing) {
+ return;
+ }
+ setClosing(true);
+ closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS);
+ }, [closing, onClose]);
+
+ useEffect(() => () => {
+ if (closeTimerRef.current) {
+ window.clearTimeout(closeTimerRef.current);
+ }
+ }, []);
+
+ useEffect(() => {
+ const closeOnEscape = (event) => {
+ if (event.key === "Escape") {
+ requestClose();
+ }
+ };
+ window.addEventListener("keydown", closeOnEscape);
+ return () => window.removeEventListener("keydown", closeOnEscape);
+ }, [requestClose]);
+
+ return (
+ {
+ if (event.target === event.currentTarget) {
+ requestClose();
+ }
+ }}
+ >
+
+
+
+
{item.label}
+ 每日折线图
+
+
+
+
+ {hasTrend(item) ?
:
暂无趋势数据
}
+
+
+
+ );
+}
+
+function hasTrend(item) {
+ return Array.isArray(item?.trend) && item.trend.some((line) => Array.isArray(line.data) && line.data.length);
+}
diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js
index badcbc9..3b0a2f9 100644
--- a/databi/src/data/createDashboardModel.js
+++ b/databi/src/data/createDashboardModel.js
@@ -8,7 +8,9 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
const source = overview || {};
const comparisonCaption = isTodayRange(range, timeZone) ? "与昨日相比" : "近 7 日";
const recharge = effectiveRechargeUSDMinor(source);
+ const userRecharge = effectiveUserRechargeUSDMinor(source);
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
+ const newPaidUsers = readNumber(source, "new_paid_users", "newPaidUsers");
const activeUsers = readNumber(source, "active_users", "activeUsers");
const paidUsers = readNumber(source, "paid_users", "paidUsers");
const rechargeUsers = readNumber(source, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
@@ -25,25 +27,25 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
const gameProfit = readNumber(source, "game_profit", "gameProfit");
const countryBreakdown = normalizeCountries(source, countryId);
- const revenueSeries = normalizeRevenueSeries(source);
+ const dailySeries = normalizeDailySeries(source);
+ const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
const payoutDistribution = normalizeDistribution(source);
const gameRanking = normalizeGameRanking(source);
return {
appCode,
businessKpis: [
- coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption),
- coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption),
- coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }),
- coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }),
- coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }),
- coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption),
- coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption)
+ coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
+ coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商转账金币", "coin") }),
+ coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin") }),
+ coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin") }),
+ coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }),
+ coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_turnover", "游戏流水", "coin") }),
+ coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") })
],
countryBreakdown,
funnel: [
- { name: "访客", rate: 1, value: newUsers },
- { name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers },
+ { name: "活跃用户", rate: 1, value: activeUsers },
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers }
],
@@ -56,12 +58,37 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
gameRanking,
giftRanking: normalizeGiftRanking(source),
kpis: [
- { caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
- { caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(newUserRecharge) },
- { caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
- { caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
- { caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", value: formatMoney(arpu) },
- { caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", value: formatMoney(arppu) }
+ { caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", trend: metricTrend(dailySeries, "recharge_usd_minor", "总充值", "money"), trendFormat: "money", value: formatMoneyFull(recharge) },
+ {
+ icon: UserPlusIcon,
+ label: "用户充值 / 新用户充值",
+ subMetrics: [
+ { caption: comparisonCaption, delta: readDelta(source, "user_recharge_delta_rate", "userRechargeDeltaRate", "recharge_delta_rate", "rechargeDeltaRate"), label: "用户充值", value: formatMoneyFull(userRecharge) },
+ { caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), label: "新用户充值", value: formatMoneyFull(newUserRecharge) }
+ ],
+ trend: [
+ ...metricTrend(dailySeries, "user_recharge_usd_minor", "用户充值", "money"),
+ ...metricTrend(dailySeries, "new_user_recharge_usd_minor", "新用户充值", "money")
+ ],
+ trendFormat: "money"
+ },
+ { caption: comparisonCaption, delta: readDelta(source, "new_users_delta_rate", "newUsersDeltaRate"), icon: UserPlusIcon, label: "新增用户数", trend: metricTrend(dailySeries, "new_users", "新增用户数", "number"), trendFormat: "number", value: formatNumber(newUsers) },
+ { caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", trend: metricTrend(dailySeries, "active_users", "活跃用户", "number"), trendFormat: "number", value: formatNumber(activeUsers) },
+ {
+ icon: CrownIcon,
+ label: "付费用户 / 新增付费用户",
+ subMetrics: [
+ { caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), label: "付费用户", value: formatNumber(paidUsers) },
+ { caption: comparisonCaption, delta: readDelta(source, "new_paid_users_delta_rate", "newPaidUsersDeltaRate"), label: "新增付费用户", value: formatNumber(newPaidUsers) }
+ ],
+ trend: [
+ ...metricTrend(dailySeries, "paid_users", "付费用户", "number"),
+ ...metricTrend(dailySeries, "new_paid_users", "新增付费用户", "number")
+ ],
+ trendFormat: "number"
+ },
+ { caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", trend: metricTrend(dailySeries, "arpu_usd_minor", "ARPU", "money"), trendFormat: "money", value: formatMoney(arpu) },
+ { caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", trend: metricTrend(dailySeries, "arppu_usd_minor", "ARPPU", "money"), trendFormat: "money", value: formatMoney(arppu) }
],
luckyMetrics: [
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) },
@@ -140,10 +167,10 @@ function normalizeRevenueSeries(source) {
}
if (Array.isArray(source.daily_series) && source.daily_series.length) {
return source.daily_series.map((item) => {
- const coinSeller = 0;
+ const coinSeller = numberValue(item.coin_seller ?? item.coin_seller_recharge_usd_minor ?? item.coinSellerRechargeUsdMinor);
const google = numberValue(item.google ?? item.google_recharge_usd_minor ?? item.googleRechargeUsdMinor);
const mifapay = numberValue(item.mifapay ?? item.mifa_pay ?? item.mifapay_recharge_usd_minor ?? item.mifaPayRechargeUsdMinor);
- const channelTotal = google + mifapay;
+ const channelTotal = google + mifapay + coinSeller;
const total = Math.max(numberValue(item.total ?? item.recharge_usd_minor), channelTotal);
return {
coin_seller: coinSeller,
@@ -155,7 +182,7 @@ function normalizeRevenueSeries(source) {
};
});
}
- const coinSeller = 0;
+ const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
const google = numberValue(source.google_recharge_usd_minor ?? source.googleRechargeUsdMinor);
const mifapay = numberValue(source.mifapay_recharge_usd_minor ?? source.mifaPayRechargeUsdMinor);
const total = effectiveRechargeUSDMinor(source);
@@ -192,7 +219,8 @@ function normalizeCountries(source, countryId) {
function normalizeCountryRow(item, index, totalRecharge) {
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
- const country = stripCountryFlagPrefix(item.country || item.country_name || code || `国家 ${index + 1}`);
+ const countryId = numberValue(item.country_id ?? item.countryId);
+ const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
const meta = resolveCountryMeta({ code, name: country });
const recharge = effectiveRechargeUSDMinor(item);
return {
@@ -220,10 +248,73 @@ function normalizeCountryRow(item, index, totalRecharge) {
function effectiveRechargeUSDMinor(source) {
const direct = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
const channelTotal = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor") +
- readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor");
+ readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor") +
+ readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
return Math.max(direct, channelTotal);
}
+function effectiveUserRechargeUSDMinor(source) {
+ const google = readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor");
+ const mifapay = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor");
+ const channelUserRecharge = google + mifapay;
+ const direct = readNumber(source, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor");
+ const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
+ return Math.max(channelUserRecharge, Math.max(0, direct - coinSeller));
+}
+
+function normalizeDailySeries(source) {
+ if (!Array.isArray(source?.daily_series) || !source.daily_series.length) {
+ return [];
+ }
+ return source.daily_series.map((item) => {
+ const google = readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
+ const mifapay = readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay");
+ const coinSeller = readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor", "coin_seller");
+ const channelTotal = google + mifapay + coinSeller;
+ const recharge = Math.max(effectiveRechargeUSDMinor(item), channelTotal);
+ const userRecharge = Math.max(google + mifapay, Math.max(0, readNumber(item, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor") - coinSeller));
+ const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
+ const gameProfit = readNumber(item, "game_profit", "gameProfit");
+ const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
+ const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
+ return {
+ active_users: readNumber(item, "active_users", "activeUsers"),
+ arppu_usd_minor: readNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
+ arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
+ coin_seller: coinSeller,
+ coin_seller_recharge_usd_minor: coinSeller,
+ coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
+ game_profit: gameProfit,
+ game_turnover: gameTurnover,
+ gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
+ google,
+ label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
+ lineTotal: Math.max(readNumber(item, "line_total", "lineTotal", "trend_total", "trendTotal", "total"), recharge, channelTotal),
+ lucky_gift_payout: luckyGiftPayout,
+ lucky_gift_profit: readNumber(item, "lucky_gift_profit", "luckyGiftProfit") || (luckyGiftTurnover - luckyGiftPayout),
+ lucky_gift_turnover: luckyGiftTurnover,
+ mifapay,
+ new_paid_users: readNumber(item, "new_paid_users", "newPaidUsers"),
+ new_user_recharge_usd_minor: readNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
+ new_users: readNumber(item, "new_users", "newUsers"),
+ paid_users: readNumber(item, "paid_users", "paidUsers"),
+ recharge_usd_minor: recharge,
+ recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
+ stat_day: item.stat_day || item.statDay || item.day || item.label || "",
+ total: recharge,
+ user_recharge_usd_minor: userRecharge
+ };
+ });
+}
+
+function metricTrend(series, key, name, format = "number") {
+ const data = series.map((item) => ({
+ label: item.label || item.stat_day || "-",
+ value: format === "money" ? numberValue(item[key]) / 100 : numberValue(item[key])
+ }));
+ return data.length ? [{ data, name }] : [];
+}
+
function normalizeGiftRanking(source) {
if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) {
return source.gift_ranking.map((item) => ({
@@ -284,6 +375,8 @@ 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 || "-",
+ game_label: gameDisplayLabel(item),
+ game_name: item.game_name || item.gameName || item.name || "",
platform_code: item.platform_code || item.platformCode || "",
profit_rate: numberValue(item.profit_rate ?? item.profitRate),
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin)
@@ -292,6 +385,15 @@ function normalizeGameRanking(source) {
return [];
}
+function gameDisplayLabel(item) {
+ const id = String(item.game_id || item.gameId || "").trim();
+ const name = String(item.game_name || item.gameName || item.name || "").trim();
+ if (name && id && name !== id) {
+ return `${name}(${id})`;
+ }
+ return name || id || "-";
+}
+
function localizeSeriesLabel(label) {
const match = String(label).match(/^May\s+(\d{1,2})$/i);
if (match) {
diff --git a/databi/src/data/createDashboardModel.test.js b/databi/src/data/createDashboardModel.test.js
index 878fff6..66a6f51 100644
--- a/databi/src/data/createDashboardModel.test.js
+++ b/databi/src/data/createDashboardModel.test.js
@@ -22,7 +22,7 @@ test("uses lucky gift pool breakdown to aggregate business metrics", () => {
expect(model.businessKpis.filter((item) => item.detailKey === "luckyGiftPools")).toHaveLength(3);
});
-test("keeps coin seller transfer out of USD recharge and exposes coin metric", () => {
+test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => {
const model = createDashboardModel({
coin_seller_recharge_usd_minor: 200,
coin_seller_transfer_coin: 160_000,
@@ -47,10 +47,24 @@ test("keeps coin seller transfer out of USD recharge and exposes coin metric", (
recharge_usd_minor: 150
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
- expect(metricValue(model, "总充值")).toBe("$2");
+ expect(metricValue(model, "总充值")).toBe("$4");
expect(metricValue(model, "币商转账金币")).toBe("160,000");
- expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 0, google: 150, lineTotal: 150, total: 150 }));
- expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 150 }));
+ expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 }));
+ expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 350 }));
+});
+
+test("exposes new user KPI and removes visitor stage from funnel", () => {
+ const model = createDashboardModel({
+ active_users: 80,
+ new_users: 12,
+ new_users_delta_rate: 0.24,
+ paid_users: 8,
+ recharge_users: 7
+ }, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
+
+ expect(metricValue(model, "新增用户数")).toBe("12");
+ expect(model.kpis.map((item) => item.label)).toContain("新增用户数");
+ expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
});
function metricValue(model, label) {
diff --git a/databi/src/styles/cards.css b/databi/src/styles/cards.css
index 0ecdb73..3269f57 100644
--- a/databi/src/styles/cards.css
+++ b/databi/src/styles/cards.css
@@ -11,11 +11,10 @@
.metric-card {
grid-column: span 2;
display: grid;
- grid-template-columns: 44px 1fr;
- height: 128px;
- align-items: center;
- gap: 15px;
- padding: 14px 18px;
+ grid-template-columns: minmax(0, 1fr);
+ height: 140px;
+ align-items: stretch;
+ padding: 10px 14px;
border-radius: 8px;
}
@@ -49,6 +48,8 @@
}
.metric-content {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto 22px;
min-width: 0;
}
@@ -64,7 +65,26 @@
display: flex;
min-width: 0;
align-items: center;
- gap: 5px;
+ gap: 6px;
+}
+
+.metric-title-icon {
+ display: inline-grid;
+ flex: 0 0 auto;
+ width: 15px;
+ height: 15px;
+ place-items: center;
+ color: #27e4f5;
+}
+
+.metric-title-icon svg {
+ width: 15px;
+ height: 15px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 2;
}
.metric-label-row > span {
@@ -103,10 +123,11 @@
.metric-content strong {
display: block;
- margin-top: 6px;
+ align-self: center;
+ margin-top: 2px;
overflow: hidden;
color: #f6fbff;
- font-size: clamp(24px, 1.5vw, 30px);
+ font-size: clamp(20px, 1.25vw, 25px);
font-weight: 780;
line-height: 1.25;
text-overflow: ellipsis;
@@ -117,11 +138,18 @@
display: flex;
justify-content: space-between;
gap: 8px;
- margin-top: 9px;
- padding-top: 8px;
+ margin-top: 2px;
+ padding-top: 4px;
border-top: 1px solid rgba(120, 169, 205, 0.16);
color: #6f8aa4;
- font-size: 12px;
+ font-size: 11px;
+}
+
+.metric-delta--compact {
+ margin-top: 2px;
+ padding-top: 0;
+ border-top: 0;
+ font-size: 10px;
}
.metric-delta b,
@@ -143,6 +171,63 @@
color: #7f9bb7;
}
+.metric-sub-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
+ align-self: stretch;
+ margin-top: 4px;
+}
+
+.metric-sub-item {
+ display: grid;
+ min-width: 0;
+ align-content: center;
+}
+
+.metric-sub-item > span {
+ overflow: hidden;
+ color: #8aa6c2;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.metric-sub-item strong {
+ align-self: auto;
+ margin-top: 2px;
+ font-size: clamp(14px, 0.86vw, 18px);
+}
+
+.metric-sparkline {
+ display: block;
+ width: 100%;
+ height: 18px;
+ margin-top: 3px;
+ overflow: visible;
+}
+
+.metric-sparkline--empty {
+ border-bottom: 2px solid rgba(39, 228, 245, 0.22);
+}
+
+.metric-sparkline-line {
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 2.2;
+ vector-effect: non-scaling-stroke;
+}
+
+.metric-sparkline-line--1 {
+ stroke: #27e4f5;
+}
+
+.metric-sparkline-line--2 {
+ stroke: #24d79f;
+ opacity: 0.82;
+}
+
.skeleton-block {
display: block;
position: relative;
diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css
index d1f10ca..1942516 100644
--- a/databi/src/styles/layout.css
+++ b/databi/src/styles/layout.css
@@ -7,9 +7,9 @@
.databi-screen {
--analysis-row-height: 320px;
- --business-metric-row-height: 88px;
+ --business-metric-row-height: 104px;
display: grid;
- grid-template-rows: 72px 128px var(--business-metric-row-height) var(--analysis-row-height);
+ grid-template-rows: 72px 140px var(--business-metric-row-height) var(--analysis-row-height);
gap: 16px;
min-height: 0;
}
@@ -652,10 +652,14 @@
.metric-grid {
display: grid;
- grid-template-columns: repeat(12, minmax(0, 1fr));
+ grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 16px;
}
+.metric-grid .metric-card {
+ grid-column: span 1;
+}
+
.business-metric-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
@@ -673,7 +677,7 @@
.business-metric-grid .metric-content {
display: grid;
grid-template-columns: minmax(max-content, 1fr) max-content;
- grid-template-rows: auto 1fr;
+ grid-template-rows: auto 1fr 18px;
column-gap: 8px;
align-items: center;
}
@@ -745,6 +749,11 @@
color: #7f9bb7;
}
+.business-metric-grid .metric-sparkline {
+ grid-column: 1 / 3;
+ grid-row: 3;
+}
+
.databi-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
diff --git a/databi/src/styles/panels.css b/databi/src/styles/panels.css
index 4e6f460..8cd8926 100644
--- a/databi/src/styles/panels.css
+++ b/databi/src/styles/panels.css
@@ -179,8 +179,8 @@
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);
+ background: linear-gradient(180deg, #24d79f 0%, #27e4f5 48%, #4281ff 100%);
+ box-shadow: 0 0 16px rgba(39, 228, 245, 0.32);
}
.rank-list {
@@ -303,6 +303,7 @@
padding: 32px;
background: rgba(1, 9, 19, 0.72);
backdrop-filter: blur(8px);
+ animation: modal-overlay-in 180ms ease-out both;
}
.country-globe-dialog {
@@ -317,6 +318,7 @@
linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)),
rgba(5, 24, 42, 0.98);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.country-globe-head {
@@ -343,18 +345,17 @@
}
.country-globe-head button {
- display: grid;
+ display: inline-flex;
width: 34px;
height: 34px;
- place-items: center;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
border: 1px solid rgba(64, 148, 197, 0.48);
border-radius: 8px;
background: rgba(7, 29, 48, 0.9);
color: #d8ebff;
cursor: pointer;
- font: inherit;
- font-size: 22px;
- line-height: 1;
}
.country-globe-head button:hover {
@@ -376,6 +377,19 @@
padding: 32px;
background: rgba(1, 9, 19, 0.72);
backdrop-filter: blur(8px);
+ animation: modal-overlay-in 180ms ease-out both;
+}
+
+.metric-trend-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 88;
+ display: grid;
+ place-items: center;
+ padding: 32px;
+ background: rgba(1, 9, 19, 0.72);
+ backdrop-filter: blur(8px);
+ animation: modal-overlay-in 180ms ease-out both;
}
.pool-detail-dialog {
@@ -390,6 +404,22 @@
linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)),
rgba(5, 24, 42, 0.98);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+.metric-trend-dialog {
+ display: grid;
+ width: min(760px, calc(100vw - 64px));
+ height: min(480px, calc(100vh - 64px));
+ grid-template-rows: auto minmax(0, 1fr);
+ overflow: hidden;
+ border: 1px solid rgba(44, 191, 227, 0.58);
+ border-radius: 8px;
+ background:
+ linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)),
+ rgba(5, 24, 42, 0.98);
+ box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.pool-detail-head {
@@ -401,6 +431,15 @@
border-bottom: 1px solid rgba(111, 148, 183, 0.2);
}
+.metric-trend-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 16px 18px;
+ border-bottom: 1px solid rgba(111, 148, 183, 0.2);
+}
+
.pool-detail-head h2 {
margin: 0;
color: #f3f9ff;
@@ -408,6 +447,13 @@
font-weight: 780;
}
+.metric-trend-head h2 {
+ margin: 0;
+ color: #f3f9ff;
+ font-size: 20px;
+ font-weight: 780;
+}
+
.pool-detail-head span {
display: block;
margin-top: 4px;
@@ -415,19 +461,39 @@
font-size: 12px;
}
+.metric-trend-head span {
+ display: block;
+ margin-top: 4px;
+ color: #8faac5;
+ font-size: 12px;
+}
+
.pool-detail-head button {
- display: grid;
+ display: inline-flex;
width: 34px;
height: 34px;
- place-items: center;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 1px solid rgba(64, 148, 197, 0.48);
+ border-radius: 8px;
+ background: rgba(7, 29, 48, 0.9);
+ color: #d8ebff;
+ cursor: pointer;
+}
+
+.metric-trend-head button {
+ display: inline-flex;
+ width: 34px;
+ height: 34px;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
border: 1px solid rgba(64, 148, 197, 0.48);
border-radius: 8px;
background: rgba(7, 29, 48, 0.9);
color: #d8ebff;
cursor: pointer;
- font: inherit;
- font-size: 22px;
- line-height: 1;
}
.pool-detail-head button:hover {
@@ -435,6 +501,105 @@
color: #ffffff;
}
+.metric-trend-head button:hover {
+ border-color: rgba(39, 228, 245, 0.78);
+ color: #ffffff;
+}
+
+.country-globe-modal.is-closing,
+.pool-detail-modal.is-closing,
+.metric-trend-modal.is-closing {
+ animation: modal-overlay-out 160ms ease-in both;
+}
+
+.country-globe-modal.is-closing .country-globe-dialog,
+.pool-detail-modal.is-closing .pool-detail-dialog,
+.metric-trend-modal.is-closing .metric-trend-dialog {
+ animation: modal-dialog-out 160ms ease-in both;
+}
+
+.modal-close-mark {
+ position: relative;
+ display: block;
+ width: 16px;
+ height: 16px;
+}
+
+.modal-close-mark::before,
+.modal-close-mark::after {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 18px;
+ height: 2px;
+ border-radius: 999px;
+ background: currentColor;
+ content: "";
+ transform-origin: center;
+}
+
+.modal-close-mark::before {
+ transform: translate(-50%, -50%) rotate(45deg);
+}
+
+.modal-close-mark::after {
+ transform: translate(-50%, -50%) rotate(-45deg);
+}
+
+@keyframes modal-overlay-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes modal-overlay-out {
+ from {
+ opacity: 1;
+ }
+
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes modal-dialog-in {
+ from {
+ opacity: 0;
+ transform: translateY(10px) scale(0.985);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes modal-dialog-out {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateY(8px) scale(0.99);
+ }
+}
+
+.metric-trend-body {
+ min-height: 0;
+ padding: 16px 18px 18px;
+}
+
+.metric-trend-chart {
+ width: 100%;
+ height: 100%;
+}
+
.pool-detail-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
diff --git a/databi/src/styles/responsive.css b/databi/src/styles/responsive.css
index ae40dab..d0d4871 100644
--- a/databi/src/styles/responsive.css
+++ b/databi/src/styles/responsive.css
@@ -1,7 +1,5 @@
@media (max-width: 1440px) {
.metric-card {
- grid-template-columns: 34px 1fr;
- gap: 10px;
padding: 12px;
}
@@ -135,7 +133,6 @@
}
.metric-card {
- grid-template-columns: 36px 1fr;
padding-inline: 12px;
}
diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx
index 7a25e7e..a167feb 100644
--- a/src/features/dashboard/pages/OverviewPage.jsx
+++ b/src/features/dashboard/pages/OverviewPage.jsx
@@ -1,29 +1,8 @@
import { useOutletContext } from "react-router-dom";
-import { DataState } from "@/shared/ui/DataState.jsx";
-import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx";
-import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
-import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
-import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js";
export function OverviewPage() {
- const page = useDashboardPage();
const { menus = [] } = useOutletContext() || {};
- return (
- <>
-
-
-
-
-
-
-
- >
- );
+ return ;
}