feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import UserLink from './UserLink';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
import PostAuthorCard from './PostAuthorCard';
|
||||
import AsideCheckInStrip from './AsideCheckInStrip';
|
||||
import ShowcaseAsideWidget from './ShowcaseAsideWidget';
|
||||
|
||||
export type PostDetailAside = {
|
||||
author?: User | null;
|
||||
@@ -114,6 +115,8 @@ export default function RightPanel({
|
||||
|
||||
const renderWidget = (widget: AsideWidget) => {
|
||||
switch (widget.id) {
|
||||
case 'showcase':
|
||||
return <ShowcaseAsideWidget key="showcase" />;
|
||||
case 'friend_links':
|
||||
return (
|
||||
<div key="friend_links" className="widget-card widget-card--friend-links">
|
||||
|
||||
72
frontend/src/components/ShowcaseAsideWidget.tsx
Normal file
72
frontend/src/components/ShowcaseAsideWidget.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Globe2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { CommunityShowcaseItem } from '../api/types';
|
||||
|
||||
/** 右侧栏:开源展柜精简列表 */
|
||||
export default function ShowcaseAsideWidget() {
|
||||
const nav = useNavigate();
|
||||
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.communityShowcase()
|
||||
.then((r) => {
|
||||
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="widget-card widget-card--showcase">
|
||||
<div className="widget-card-head widget-card-head--split">
|
||||
<span className="widget-card-head-main">
|
||||
<Globe2 className="widget-card-icon widget-card-icon--showcase" aria-hidden />
|
||||
<button type="button" className="widget-friend-links-title" onClick={() => nav('/showcase')}>
|
||||
开源展柜
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
<div className="widget-card-body widget-card-body--friend-links">
|
||||
{loading ? (
|
||||
<div className="widget-empty">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="widget-empty">
|
||||
暂无精选实例
|
||||
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
|
||||
查看展柜
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="widget-friend-links-list">
|
||||
{items.slice(0, 8).map((item) => (
|
||||
<li key={item.site_url}>
|
||||
<a href={item.site_url} target="_blank" rel="noopener noreferrer" title={item.site_name || item.site_url}>
|
||||
{item.site_name || '未命名站点'}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{items.length > 8 && (
|
||||
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
|
||||
查看全部 {items.length} 个
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2,
|
||||
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
@@ -30,6 +30,7 @@ function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): st
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/projects')) return 'projects';
|
||||
if (pathname.startsWith('/links')) return 'links';
|
||||
if (pathname.startsWith('/showcase')) return 'showcase';
|
||||
if (pathname.startsWith('/page/')) return 'pages';
|
||||
if (pathname.startsWith('/admin')) return 'admin';
|
||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||
@@ -67,7 +68,8 @@ export default function Sidebar({
|
||||
const { navPages } = useSitePages();
|
||||
const { limits } = useForumLimits();
|
||||
const showFriendLinksNav = limits.nav_show_friend_links !== false;
|
||||
const showSiteSection = navPages.length > 0 || showFriendLinksNav;
|
||||
const showShowcaseNav = !!limits.nav_show_showcase;
|
||||
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
|
||||
|
||||
const keyword = params.get('keyword') || '';
|
||||
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
|
||||
@@ -197,6 +199,7 @@ export default function Sidebar({
|
||||
<div className="sidebar-section sidebar-section--spaced">站点</div>
|
||||
<nav className="sidebar-nav">
|
||||
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
|
||||
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => nav('/showcase'))}
|
||||
{navPages.map(p => (
|
||||
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ function FooterSep() {
|
||||
return <span className="site-footer__sep" aria-hidden>·</span>;
|
||||
}
|
||||
|
||||
/** 站点页脚:版权、Sitemap、备案号 */
|
||||
/** 站点页脚:版权、友链/展柜入口、单页、备案号 */
|
||||
export default function SiteFooter() {
|
||||
const { branding } = useSiteBranding();
|
||||
const { footerPages } = useSitePages();
|
||||
@@ -17,6 +17,10 @@ export default function SiteFooter() {
|
||||
const year = new Date().getFullYear();
|
||||
const icp = branding.icp_beian?.trim() || '';
|
||||
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
|
||||
const showFriendLinks = limits.footer_show_friend_links !== false;
|
||||
const showShowcase = !!limits.footer_show_showcase;
|
||||
const hasNavBeforePages = showFriendLinks || showShowcase;
|
||||
const hasNavBeforeIcp = hasNavBeforePages || footerPages.length > 0;
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
@@ -34,20 +38,26 @@ export default function SiteFooter() {
|
||||
</div>
|
||||
|
||||
<nav className="site-footer__nav" aria-label="站点链接">
|
||||
{limits.footer_show_friend_links !== false && (
|
||||
{showFriendLinks && (
|
||||
<span className="site-footer__friend">
|
||||
<Link to="/links">友情链接</Link>
|
||||
</span>
|
||||
)}
|
||||
{showShowcase && (
|
||||
<span className="site-footer__friend">
|
||||
{showFriendLinks && <FooterSep />}
|
||||
<Link to="/showcase">开源展柜</Link>
|
||||
</span>
|
||||
)}
|
||||
{footerPages.map((p, i) => (
|
||||
<span key={p.slug} className="site-footer__friend">
|
||||
{(limits.footer_show_friend_links !== false || i > 0) && <FooterSep />}
|
||||
{(hasNavBeforePages || i > 0) && <FooterSep />}
|
||||
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
|
||||
</span>
|
||||
))}
|
||||
{icp && (
|
||||
<>
|
||||
{(limits.footer_show_friend_links !== false || footerPages.length > 0) && <FooterSep />}
|
||||
{hasNavBeforeIcp && <FooterSep />}
|
||||
<a
|
||||
href={icpURL}
|
||||
target="_blank"
|
||||
|
||||
@@ -18,6 +18,10 @@ const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
|
||||
label: '友情链接',
|
||||
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
|
||||
},
|
||||
showcase: {
|
||||
label: '开源展柜',
|
||||
hint: '展示精选公网部署;关闭后仍可直接访问 /showcase',
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -12,7 +12,7 @@ const EMPTY_COMMUNITY: CommunityConfig = {
|
||||
instance_id: '',
|
||||
};
|
||||
|
||||
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存) */
|
||||
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存;枢纽站不显示) */
|
||||
export default function CommunitySupportStrip() {
|
||||
const [community, setCommunity] = useState<CommunityConfig>(EMPTY_COMMUNITY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -58,6 +58,11 @@ export default function CommunitySupportStrip() {
|
||||
}
|
||||
};
|
||||
|
||||
// 官网 / 枢纽站本身就是收报方,无需「支持开源」上报条
|
||||
if (!ready || community.hub_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源">
|
||||
<div className="admin-community-support-strip-main">
|
||||
|
||||
72
frontend/src/components/admin/MonitorChinaMap.tsx
Normal file
72
frontend/src/components/admin/MonitorChinaMap.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import china from '@svg-maps/china';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildChinaRegionCountMap, heatFill, emptyFill } from './monitorMapUtils';
|
||||
|
||||
type Loc = { id: string; name: string; path: string };
|
||||
|
||||
type RegionStat = {
|
||||
country: string;
|
||||
region?: string;
|
||||
region_iso?: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
regions: RegionStat[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 中国省区地图:按 BIN 解析出的省/区填色 */
|
||||
export default function MonitorChinaMap({ regions, className }: Props) {
|
||||
const [hover, setHover] = useState<{ name: string; count: number } | null>(null);
|
||||
const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]);
|
||||
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
|
||||
const hasData = max > 0;
|
||||
const locations = (china as { locations: Loc[]; viewBox: string }).locations;
|
||||
const viewBox = (china as { viewBox: string }).viewBox;
|
||||
|
||||
return (
|
||||
<div className={cn('admin-monitor-svg-wrap', className)}>
|
||||
<svg
|
||||
className="admin-monitor-svg-map"
|
||||
viewBox={viewBox}
|
||||
role="img"
|
||||
aria-label="中国访客地图"
|
||||
>
|
||||
{locations.map((loc) => {
|
||||
const count = counts[loc.id] || 0;
|
||||
return (
|
||||
<path
|
||||
key={loc.id}
|
||||
d={loc.path}
|
||||
fill={heatFill(count, max, hasData)}
|
||||
stroke="hsl(var(--background, 0 0% 100%))"
|
||||
strokeWidth={0.6}
|
||||
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
|
||||
onMouseEnter={() => setHover({ name: loc.name, count })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<title>
|
||||
{loc.name}
|
||||
{count > 0 ? ` · ${count}` : ''}
|
||||
</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{hover && (
|
||||
<div className="admin-monitor-map-tip" role="status">
|
||||
<strong>{hover.name}</strong>
|
||||
<span>{hover.count > 0 ? hover.count : '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasData && (
|
||||
<div className="admin-monitor-map-empty-overlay">
|
||||
<p>暂无省级访问数据</p>
|
||||
<p>放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/admin/MonitorWorldMap.tsx
Normal file
121
frontend/src/components/admin/MonitorWorldMap.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import world from '@svg-maps/world';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
buildCountMap,
|
||||
countryLabelZh,
|
||||
emptyFill,
|
||||
heatFill,
|
||||
type GeoCountMap,
|
||||
} from './monitorMapUtils';
|
||||
|
||||
type Loc = { id: string; name: string; path: string };
|
||||
|
||||
type Props = {
|
||||
items: { country: string; count: number }[];
|
||||
className?: string;
|
||||
/** 裁切到东亚(中国模式) */
|
||||
focusChina?: boolean;
|
||||
};
|
||||
|
||||
const CHINA_VIEWBOX = '680 260 220 200';
|
||||
|
||||
export default function MonitorWorldMap({ items, className, focusChina }: Props) {
|
||||
const [hover, setHover] = useState<{ code: string; name: string; count: number } | null>(null);
|
||||
|
||||
const counts = useMemo(() => buildCountMap(items), [items]);
|
||||
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
|
||||
const hasData = max > 0;
|
||||
|
||||
const locations = (world as { locations: Loc[]; viewBox: string }).locations;
|
||||
const viewBox = focusChina ? CHINA_VIEWBOX : (world as { viewBox: string }).viewBox;
|
||||
|
||||
const filteredCounts: GeoCountMap = useMemo(() => {
|
||||
if (!focusChina) return counts;
|
||||
const allow = new Set(['CN', 'HK', 'MO', 'TW']);
|
||||
const m: GeoCountMap = {};
|
||||
for (const [k, v] of Object.entries(counts)) {
|
||||
if (allow.has(k)) m[k] = v;
|
||||
}
|
||||
return m;
|
||||
}, [counts, focusChina]);
|
||||
|
||||
const focusMax = useMemo(
|
||||
() => Math.max(0, ...Object.values(filteredCounts)),
|
||||
[filteredCounts],
|
||||
);
|
||||
const focusHas = focusMax > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('admin-monitor-svg-wrap', className)}>
|
||||
<svg
|
||||
className="admin-monitor-svg-map"
|
||||
viewBox={viewBox}
|
||||
role="img"
|
||||
aria-label={focusChina ? '中国及周边访客地图' : '世界访客地图'}
|
||||
>
|
||||
{locations.map((loc) => {
|
||||
const code = loc.id.toUpperCase();
|
||||
if (focusChina) {
|
||||
// 中国模式下非本区域保持极淡灰,本区域按数据着色
|
||||
const inRegion = code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW'
|
||||
|| code === 'JP' || code === 'KR' || code === 'MN' || code === 'KP'
|
||||
|| code === 'RU' || code === 'IN' || code === 'VN' || code === 'LA'
|
||||
|| code === 'MM' || code === 'BT' || code === 'NP' || code === 'KZ'
|
||||
|| code === 'KG' || code === 'TJ' || code === 'UZ' || code === 'AF'
|
||||
|| code === 'PK' || code === 'PH' || code === 'MY' || code === 'TH'
|
||||
|| code === 'KH' || code === 'BD' || code === 'LK';
|
||||
if (!inRegion) return null;
|
||||
}
|
||||
const count = filteredCounts[code] || (focusChina ? 0 : counts[code]) || 0;
|
||||
const useMax = focusChina ? focusMax : max;
|
||||
const useHas = focusChina ? focusHas : hasData;
|
||||
const isCore = !focusChina || code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW';
|
||||
const fill = isCore
|
||||
? heatFill(count, useMax, useHas)
|
||||
: emptyFill(useHas);
|
||||
|
||||
return (
|
||||
<path
|
||||
key={loc.id}
|
||||
d={loc.path}
|
||||
data-code={code}
|
||||
fill={fill}
|
||||
stroke="hsl(var(--background, 0 0% 100%))"
|
||||
strokeWidth={focusChina ? 0.35 : 0.4}
|
||||
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
|
||||
onMouseEnter={() => setHover({
|
||||
code,
|
||||
name: countryLabelZh(code, loc.name),
|
||||
count: focusChina && !isCore ? 0 : count,
|
||||
})}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<title>
|
||||
{countryLabelZh(code, loc.name)}
|
||||
{count > 0 ? ` · ${count}` : ''}
|
||||
</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{hover && (
|
||||
<div className="admin-monitor-map-tip" role="status">
|
||||
<strong>{hover.name}</strong>
|
||||
<span>{hover.count > 0 ? hover.count : '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasData && !focusChina && (
|
||||
<div className="admin-monitor-map-empty-overlay">
|
||||
<p>暂无国家数据</p>
|
||||
<p>配置 GeoIP 或 CDN 国家头后可见</p>
|
||||
</div>
|
||||
)}
|
||||
{focusChina && !focusHas && (
|
||||
<div className="admin-monitor-map-empty-overlay">
|
||||
<p>暂无中国地区访问数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend/src/components/admin/monitorMapUtils.ts
Normal file
130
frontend/src/components/admin/monitorMapUtils.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/** 监控地图共用:国家码 → 数量色阶(本站绿色) */
|
||||
|
||||
export type GeoCountMap = Record<string, number>;
|
||||
|
||||
const EMPTY = 'var(--j13-border, #e5e7eb)';
|
||||
const BASE = 'hsl(var(--muted, 210 20% 94%))';
|
||||
|
||||
/** 由浅到深的绿色阶 */
|
||||
const GREEN_STEPS = [
|
||||
'color-mix(in srgb, var(--j13-green) 28%, hsl(var(--card)))',
|
||||
'color-mix(in srgb, var(--j13-green) 45%, hsl(var(--card)))',
|
||||
'color-mix(in srgb, var(--j13-green) 62%, hsl(var(--card)))',
|
||||
'color-mix(in srgb, var(--j13-green) 80%, hsl(var(--card)))',
|
||||
'var(--j13-green)',
|
||||
];
|
||||
|
||||
export function buildCountMap(items: { country: string; count: number }[]): GeoCountMap {
|
||||
const m: GeoCountMap = {};
|
||||
for (const it of items) {
|
||||
const code = (it.country || '').toUpperCase();
|
||||
if (!code) continue;
|
||||
m[code] = (m[code] || 0) + it.count;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** MaxMind region_iso / 中英文省名 → @svg-maps/china 的 location id */
|
||||
const CN_ISO_TO_SVG: Record<string, string> = {
|
||||
AH: 'anhui', BJ: 'beijing', CQ: 'chongqing', FJ: 'fujian', GS: 'gansu',
|
||||
GD: 'guangdong', GX: 'guangxi-zhuang', GZ: 'guizhou', HI: 'hainan', HE: 'hebei',
|
||||
HL: 'heilongjiang', HA: 'henan', HB: 'hubei', HN: 'hunan', JS: 'jiangsu',
|
||||
JX: 'jiangxi', JL: 'jilin', LN: 'liaoning', NM: 'nei-mongol', NX: 'ningxia-hui',
|
||||
QH: 'quinghai', SN: 'shaanxi', SD: 'shandong', SH: 'shanghai', SX: 'shanxi',
|
||||
SC: 'sichuan', TJ: 'tianjin', XJ: 'xinjiang-uygur', XZ: 'xizang', YN: 'yunnan',
|
||||
ZJ: 'zhejiang', HK: 'hong-kong', MO: 'macau',
|
||||
};
|
||||
|
||||
const CN_NAME_TO_SVG: Record<string, string> = {
|
||||
安徽: 'anhui', anhui: 'anhui',
|
||||
北京: 'beijing', beijing: 'beijing',
|
||||
重庆: 'chongqing', chongqing: 'chongqing',
|
||||
福建: 'fujian', fujian: 'fujian',
|
||||
甘肃: 'gansu', gansu: 'gansu',
|
||||
广东: 'guangdong', guangdong: 'guangdong',
|
||||
广西: 'guangxi-zhuang', '广西壮族自治区': 'guangxi-zhuang', 'guangxi zhuang': 'guangxi-zhuang', guangxi: 'guangxi-zhuang',
|
||||
贵州: 'guizhou', guizhou: 'guizhou',
|
||||
海南: 'hainan', hainan: 'hainan',
|
||||
河北: 'hebei', hebei: 'hebei',
|
||||
黑龙江: 'heilongjiang', heilongjiang: 'heilongjiang',
|
||||
河南: 'henan', henan: 'henan',
|
||||
湖北: 'hubei', hubei: 'hubei',
|
||||
湖南: 'hunan', hunan: 'hunan',
|
||||
江苏: 'jiangsu', jiangsu: 'jiangsu',
|
||||
江西: 'jiangxi', jiangxi: 'jiangxi',
|
||||
吉林: 'jilin', jilin: 'jilin',
|
||||
辽宁: 'liaoning', liaoning: 'liaoning',
|
||||
内蒙古: 'nei-mongol', '内蒙古自治区': 'nei-mongol', 'nei mongol': 'nei-mongol', 'inner mongolia': 'nei-mongol',
|
||||
宁夏: 'ningxia-hui', '宁夏回族自治区': 'ningxia-hui', 'ningxia hui': 'ningxia-hui', ningxia: 'ningxia-hui',
|
||||
青海: 'quinghai', qinghai: 'quinghai', quinghai: 'quinghai',
|
||||
陕西: 'shaanxi', shaanxi: 'shaanxi',
|
||||
山东: 'shandong', shandong: 'shandong',
|
||||
上海: 'shanghai', shanghai: 'shanghai',
|
||||
山西: 'shanxi', shanxi: 'shanxi',
|
||||
四川: 'sichuan', sichuan: 'sichuan',
|
||||
天津: 'tianjin', tianjin: 'tianjin',
|
||||
新疆: 'xinjiang-uygur', '新疆维吾尔自治区': 'xinjiang-uygur', 'xinjiang uygur': 'xinjiang-uygur', xinjiang: 'xinjiang-uygur',
|
||||
西藏: 'xizang', '西藏自治区': 'xizang', xizang: 'xizang', tibet: 'xizang',
|
||||
云南: 'yunnan', yunnan: 'yunnan',
|
||||
浙江: 'zhejiang', zhejiang: 'zhejiang',
|
||||
香港: 'hong-kong', 'hong kong': 'hong-kong',
|
||||
澳门: 'macau', macau: 'macau', macao: 'macau',
|
||||
};
|
||||
|
||||
export function chinaRegionToSvgId(regionISO?: string, regionName?: string): string | null {
|
||||
const iso = (regionISO || '').trim().toUpperCase().replace(/^CN-/, '');
|
||||
if (iso && CN_ISO_TO_SVG[iso]) return CN_ISO_TO_SVG[iso];
|
||||
const name = (regionName || '').trim().toLowerCase();
|
||||
if (!name) return null;
|
||||
if (CN_NAME_TO_SVG[name]) return CN_NAME_TO_SVG[name];
|
||||
// 尝试去掉「省/市/自治区」后缀后再匹配中文键
|
||||
const zh = (regionName || '').trim()
|
||||
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
|
||||
.replace(/(省|市)$/, '');
|
||||
if (CN_NAME_TO_SVG[zh]) return CN_NAME_TO_SVG[zh];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 将省级统计聚合为 svg-maps/china 的 id → count */
|
||||
export function buildChinaRegionCountMap(
|
||||
regions: { country: string; region?: string; region_iso?: string; count: number }[],
|
||||
): GeoCountMap {
|
||||
const m: GeoCountMap = {};
|
||||
for (const it of regions) {
|
||||
const c = (it.country || '').toUpperCase();
|
||||
if (c && c !== 'CN' && c !== 'HK' && c !== 'MO' && c !== 'TW') continue;
|
||||
const id = chinaRegionToSvgId(it.region_iso, it.region);
|
||||
if (!id) continue;
|
||||
m[id] = (m[id] || 0) + it.count;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
export function heatFill(count: number, max: number, hasAnyData: boolean): string {
|
||||
if (!hasAnyData) return BASE;
|
||||
if (!count || max <= 0) return BASE;
|
||||
const t = Math.log1p(count) / Math.log1p(max);
|
||||
const idx = Math.min(GREEN_STEPS.length - 1, Math.floor(t * GREEN_STEPS.length));
|
||||
return GREEN_STEPS[Math.max(0, idx)];
|
||||
}
|
||||
|
||||
export function emptyFill(hasAnyData: boolean): string {
|
||||
return hasAnyData ? BASE : EMPTY;
|
||||
}
|
||||
|
||||
export const COUNTRY_NAMES_ZH: Record<string, string> = {
|
||||
CN: '中国', US: '美国', JP: '日本', KR: '韩国', HK: '中国香港', TW: '中国台湾', MO: '中国澳门',
|
||||
SG: '新加坡', DE: '德国', GB: '英国', FR: '法国', RU: '俄罗斯', AU: '澳大利亚',
|
||||
CA: '加拿大', IN: '印度', BR: '巴西', NL: '荷兰', IT: '意大利', ES: '西班牙',
|
||||
MY: '马来西亚', TH: '泰国', VN: '越南', ID: '印尼', PH: '菲律宾', MX: '墨西哥',
|
||||
TR: '土耳其', SA: '沙特', AE: '阿联酋', ZA: '南非', NZ: '新西兰', SE: '瑞典',
|
||||
NO: '挪威', FI: '芬兰', DK: '丹麦', PL: '波兰', CH: '瑞士', AT: '奥地利',
|
||||
BE: '比利时', IE: '爱尔兰', PT: '葡萄牙', AR: '阿根廷', CL: '智利', CO: '哥伦比亚',
|
||||
PK: '巴基斯坦', BD: '孟加拉', EG: '埃及', NG: '尼日利亚', KE: '肯尼亚',
|
||||
UA: '乌克兰', CZ: '捷克', RO: '罗马尼亚', HU: '匈牙利', GR: '希腊', IL: '以色列',
|
||||
};
|
||||
|
||||
export function countryLabelZh(code: string, fallbackName?: string) {
|
||||
const c = code.toUpperCase();
|
||||
return COUNTRY_NAMES_ZH[c] || fallbackName || c;
|
||||
}
|
||||
Reference in New Issue
Block a user