2026-07-03 19:52:18 +08:00

466 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// RegionsView地区洞察概要条 + 世界地图(国家粒度)+ 区域卡片网格 + 区域→国家下钻表。
// 数据契约见 SocialBiApp.jsx 的 useSocialBi();金额为 USD 分、比例为 0-1渲染时统一走 metrics/format 层。
import { Fragment, useMemo, useState } from "react";
import { VisualMapComponent } from "echarts/components";
import * as echarts from "echarts/core";
import { EChart } from "../../charts/EChart.jsx";
import { resolveCountryMeta } from "../../data/countryMeta.js";
import worldMap from "../../data/world.json";
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { useSocialBi } from "../SocialBiApp.jsx";
import "./regions-view.css";
// EChart.jsx 未注册 visualMap 组件这里按需补注册echarts.use 幂等,不影响其它图表)。
echarts.use([VisualMapComponent]);
const MAP_METRICS = [
{ key: "recharge_usd_minor", label: "充值" },
{ key: "new_users", label: "新增" },
{ key: "active_users", label: "日活" }
];
const CARD_METRICS = ["new_users", "active_users", "arppu_usd_minor"];
const TABLE_METRICS = [
"recharge_usd_minor",
"new_users",
"active_users",
"paid_users",
"arppu_usd_minor",
"d1_retention_rate",
"gift_coin_spent"
];
// databi-world 的合法地名索引(小写 → 原始 feature name用于把 ISO alpha-2 / 后端国家名归一成地图地名。
const MAP_NAME_INDEX = new Map();
(worldMap.features || []).forEach((feature) => {
const name = feature?.properties?.name;
if (name) {
MAP_NAME_INDEX.set(String(name).trim().toLowerCase(), name);
}
});
// 复用 countryMeta 的 ISO alpha-2 → 备选英文/中文名列表,再校验哪一个是 databi-world 的 feature name。
function resolveMapName(countryCode, countryName) {
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
const candidates = [...(meta?.names || []), countryName];
for (const candidate of candidates) {
const hit = MAP_NAME_INDEX.get(String(candidate || "").trim().toLowerCase());
if (hit) {
return hit;
}
}
return "";
}
function countryDisplayName(countryCode, countryName) {
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
return meta?.label || countryName || countryCode || "未知国家";
}
function rechargeValue(row) {
return isBlank(row?.recharge_usd_minor) ? -1 : Number(row.recharge_usd_minor);
}
function byRechargeDesc(a, b) {
return rechargeValue(b) - rechargeValue(a);
}
export function RegionsView() {
const { appCodes, derived, isLoading } = useSocialBi();
const { appTotals, countryTotals, regionTotals } = derived;
const [mapMetric, setMapMetric] = useState("recharge_usd_minor");
const [expandedRegions, setExpandedRegions] = useState(() => new Set());
const regionRows = useMemo(() => [...regionTotals].sort(byRechargeDesc), [regionTotals]);
const regionSummary = useMemo(() => aggregateMetricMaps(regionTotals), [regionTotals]);
const regionCount = useMemo(
() => new Set(regionTotals.map((row) => `${row.app_code}:${row.region_id}`)).size,
[regionTotals]
);
const appCount = useMemo(() => new Set(regionTotals.map((row) => row.app_code)).size, [regionTotals]);
const countryCount = useMemo(() => {
const codes = new Set();
countryTotals.forEach((row) => {
const key = String(row.country_code || row.country_name || "").trim();
if (key) {
codes.add(key);
}
});
return codes.size;
}, [countryTotals]);
// 每个 App 的总充值(占比条分母):优先 appTotals 的整 App 口径,缺失时退化为可见区域行求和。
const appRechargeTotals = useMemo(() => {
const totals = new Map();
appTotals.forEach((row) => {
if (!isBlank(row.recharge_usd_minor)) {
totals.set(row.app_code, Number(row.recharge_usd_minor));
}
});
const fallback = new Map();
regionTotals.forEach((row) => {
if (!isBlank(row.recharge_usd_minor)) {
fallback.set(row.app_code, (fallback.get(row.app_code) || 0) + Number(row.recharge_usd_minor));
}
});
fallback.forEach((value, code) => {
if (!totals.has(code)) {
totals.set(code, value);
}
});
return totals;
}, [appTotals, regionTotals]);
// 下钻子行countryTotals 按 app_code + region_id 分组region_id 缺失已在数据层归一成 0对应"未划分区域")。
const countriesByRegion = useMemo(() => {
const groups = new Map();
countryTotals.forEach((row) => {
const key = `${row.app_code}:${Number(row.region_id ?? 0)}`;
const bucket = groups.get(key) || [];
bucket.push(row);
groups.set(key, bucket);
});
groups.forEach((rows) => rows.sort(byRechargeDesc));
return groups;
}, [countryTotals]);
// 地图数据:跨 App 同国家先按地图地名合桶,再用 aggregateMetricMaps 合并(比例按分子分母重算)。
const mapIndex = useMemo(() => {
const buckets = new Map();
const unmatched = new Set();
countryTotals.forEach((row) => {
const mapName = resolveMapName(row.country_code, row.country_name);
if (!mapName) {
unmatched.add(String(row.country_code || row.country_name || "?"));
return;
}
const bucket = buckets.get(mapName) || { label: countryDisplayName(row.country_code, row.country_name), rows: [] };
bucket.rows.push(row);
buckets.set(mapName, bucket);
});
const entries = new Map();
buckets.forEach((bucket, mapName) => {
entries.set(mapName, { label: bucket.label, metrics: aggregateMetricMaps(bucket.rows) });
});
return { entries, unmatchedCount: unmatched.size };
}, [countryTotals]);
const mapOption = useMemo(() => {
const data = [];
mapIndex.entries.forEach((entry, mapName) => {
const value = metricChartValue(mapMetric, entry.metrics[mapMetric]);
if (value !== null) {
data.push({ name: mapName, value });
}
});
const max = data.reduce((acc, item) => Math.max(acc, item.value), 0) || 1;
return {
series: [
{
data,
emphasis: { itemStyle: { areaColor: "#93b6f2" }, label: { show: false } },
itemStyle: { areaColor: "#e9eef5", borderColor: "#d5dfeb", borderWidth: 0.6 },
label: { show: false },
map: "databi-world",
name: metricLabel(mapMetric),
roam: false,
select: { disabled: true },
type: "map"
}
],
tooltip: {
backgroundColor: "#ffffff",
borderColor: "#e3eaf3",
extraCssText: "box-shadow: 0 4px 16px rgba(16, 34, 56, 0.12);",
formatter: (params) => {
const entry = mapIndex.entries.get(params.name);
if (!entry) {
return `${params.name}<br/>暂无数据`;
}
return `${entry.label}<br/>${metricLabel(mapMetric)}${formatMetric(mapMetric, entry.metrics[mapMetric])}`;
},
textStyle: { color: "#14263c", fontSize: 12 }
},
visualMap: {
bottom: 10,
calculable: false,
inRange: { color: ["#dbeafe", "#2557d6"] },
itemHeight: 110,
itemWidth: 10,
left: 12,
max,
min: 0,
orient: "horizontal",
text: ["高", "低"],
textStyle: { color: "#5b7089", fontSize: 11 }
}
};
}, [mapIndex, mapMetric]);
const toggleRegion = (key) => {
setExpandedRegions((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
if (isLoading && !regionTotals.length && !countryTotals.length) {
return <RegionsSkeleton />;
}
if (!regionTotals.length) {
return (
<section className="sbi-card">
<div className="sbi-empty">
<strong>暂无区域数据</strong>
<span>检查顶栏区域筛选或该 App 尚未配置区域目录legacy App 在其 Mongo sys_region_confighyapp 在区域管理</span>
</div>
</section>
);
}
return (
<>
<div className="sbi-rg-summary">
<section className="sbi-card sbi-rg-summary-card">
<span className="sbi-rg-summary-label">覆盖区域数</span>
<span className="sbi-rg-summary-value">{formatCount(regionCount)}</span>
<span className="sbi-rg-summary-sub"> App × 区域去重 · 覆盖 {formatCount(appCount)} App</span>
</section>
<section className="sbi-card sbi-rg-summary-card">
<span className="sbi-rg-summary-label">覆盖国家数</span>
<span className="sbi-rg-summary-value">{formatCount(countryCount)}</span>
<span className="sbi-rg-summary-sub">countryTotals 按国家去重 App 合并</span>
</section>
<section className="sbi-card sbi-rg-summary-card">
<span className="sbi-rg-summary-label" title={metricTooltip("recharge_usd_minor")}>
区域充值合计
</span>
<span className="sbi-rg-summary-value">{formatMoneyMinor(regionSummary.recharge_usd_minor)}</span>
<span className="sbi-rg-summary-sub">当前区间 · 可见区域行合计</span>
</section>
</div>
<section className="sbi-card sbi-rg-map-card">
<div className="sbi-card-header">
<strong>全球分布</strong>
<small>
{countryCount ? `${formatCount(countryCount)} 个国家 · 跨 App 合并` : "国家粒度"}
{mapIndex.unmatchedCount ? ` · ${mapIndex.unmatchedCount} 个国家未匹配到地图` : ""}
</small>
<div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="地图指标">
{MAP_METRICS.map((item) => (
<button
aria-checked={mapMetric === item.key}
className={mapMetric === item.key ? "is-active" : ""}
key={item.key}
onClick={() => setMapMetric(item.key)}
role="radio"
title={metricTooltip(item.key)}
type="button"
>
{item.label}
</button>
))}
</div>
</div>
</div>
<div className="sbi-rg-map-body">
{mapIndex.entries.size ? (
<EChart className="sbi-rg-map-chart" option={mapOption} />
) : (
<div className="sbi-empty">
<strong>暂无国家数据</strong>
<span>当前区间没有国家粒度数据或国家编码未能匹配到世界地图</span>
</div>
)}
</div>
</section>
<div className="sbi-rg-grid">
{regionRows.map((row) => {
const key = `${row.app_code}:${row.region_id}`;
const appTotal = appRechargeTotals.get(row.app_code);
const share = appTotal && !isBlank(row.recharge_usd_minor) ? Number(row.recharge_usd_minor) / appTotal : null;
return (
<section className="sbi-card sbi-rg-region-card" key={key}>
<div className="sbi-rg-region-head">
<span className="sbi-rg-region-name" title={row.region_name}>
{row.region_name}
</span>
{row.restricted ? (
<span className="sbi-badge" title="数据按你的负责范围裁剪">
范围裁剪
</span>
) : null}
<span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
{row.app_name}
</span>
</div>
<div className="sbi-rg-region-money">
<small className="sbi-metric-hint" title={metricTooltip("recharge_usd_minor")}>
{metricLabel("recharge_usd_minor")}
</small>
<strong>{formatMoneyMinor(row.recharge_usd_minor)}</strong>
</div>
<div className="sbi-rg-region-metrics">
{CARD_METRICS.map((metric) => (
<div className="sbi-rg-region-metric" key={metric}>
<small className="sbi-metric-hint" title={metricTooltip(metric)}>
{metricLabel(metric)}
</small>
<span>{formatMetric(metric, row[metric])}</span>
</div>
))}
</div>
<div className="sbi-rg-share">
<span className="sbi-rg-share-head">
<span> {row.app_name} 总充值</span>
<span>{share === null ? "--" : formatRatioPercent(share)}</span>
</span>
<span className="sbi-rg-share-track">
<span
className="sbi-rg-share-fill"
style={{ width: share === null ? 0 : `${Math.min(100, Math.max(0, share * 100))}%` }}
/>
</span>
</div>
</section>
);
})}
</div>
<section className="sbi-card sbi-rg-table-card">
<div className="sbi-card-header">
<strong>区域下钻</strong>
<small>点击区域行展开国家明细 · 按充值降序</small>
</div>
<div className="sbi-table-scroll">
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">地区 / 国家</th>
<th className="is-left">App</th>
{TABLE_METRICS.map((metric) => (
<th key={metric}>
<span className="sbi-metric-hint" title={metricTooltip(metric)}>
{metricLabel(metric)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
<tr className="is-total">
<td className="is-left">全部区域</td>
<td className="is-left">{formatCount(appCount)} App</td>
{TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, regionSummary[metric])}</td>
))}
</tr>
{regionRows.map((row) => {
const key = `${row.app_code}:${row.region_id}`;
const isOpen = expandedRegions.has(key);
const children = isOpen ? countriesByRegion.get(key) || [] : [];
return (
<Fragment key={key}>
<tr aria-expanded={isOpen} className="sbi-rg-row" onClick={() => toggleRegion(key)}>
<td className="is-left">
<span className="sbi-rg-region-cell">
<span aria-hidden="true" className={isOpen ? "sbi-rg-caret is-open" : "sbi-rg-caret"}>
</span>
{row.region_name}
{row.restricted ? (
<span className="sbi-badge" title="数据按你的负责范围裁剪">
范围裁剪
</span>
) : null}
</span>
</td>
<td className="is-left">
<span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
{row.app_name}
</span>
</td>
{TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, row[metric])}</td>
))}
</tr>
{isOpen && !children.length ? (
<tr className="sbi-rg-child">
<td className="is-left" colSpan={2 + TABLE_METRICS.length}>
<span className="sbi-rg-child-name">该区域暂无国家明细</span>
</td>
</tr>
) : null}
{children.map((child) => (
<tr className="sbi-rg-child" key={`${key}:${child.country_code || child.country_name}`}>
<td className="is-left">
<span className="sbi-rg-child-name">{countryDisplayName(child.country_code, child.country_name)}</span>
</td>
<td className="is-left sbi-rg-child-app">{child.app_name}</td>
{TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, child[metric])}</td>
))}
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
</div>
</section>
</>
);
}
function RegionsSkeleton() {
return (
<>
<div className="sbi-rg-summary" aria-busy="true">
{[0, 1, 2].map((index) => (
<section className="sbi-card sbi-rg-summary-card" key={index}>
<span className="sbi-skeleton" style={{ height: 12, width: 90 }} />
<span className="sbi-skeleton" style={{ height: 26, width: 140 }} />
<span className="sbi-skeleton" style={{ height: 11, width: 170 }} />
</section>
))}
</div>
<section className="sbi-card sbi-rg-map-card" aria-busy="true">
<div className="sbi-card-header">
<span className="sbi-skeleton" style={{ height: 14, width: 120 }} />
</div>
<div className="sbi-rg-map-body">
<span className="sbi-skeleton" style={{ display: "block", height: "100%", width: "100%" }} />
</div>
</section>
<div className="sbi-rg-grid" aria-busy="true">
{[0, 1, 2].map((index) => (
<section className="sbi-card sbi-rg-region-card" key={index}>
<span className="sbi-skeleton" style={{ height: 14, width: 150 }} />
<span className="sbi-skeleton" style={{ height: 24, width: 130 }} />
<span className="sbi-skeleton" style={{ height: 34, width: "100%" }} />
</section>
))}
</div>
</>
);
}