466 lines
18 KiB
JavaScript
466 lines
18 KiB
JavaScript
// 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_config,hyapp 在区域管理)</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>
|
||
</>
|
||
);
|
||
}
|