mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
[优化] 添加来源热度显示功能,更新相关数据结构和前端组件
This commit is contained in:
@@ -11,7 +11,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
|
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
|
||||||
import type { DashboardNodeHealth } from '@/features/dashboard/types';
|
import type {
|
||||||
|
DashboardNodeHealth,
|
||||||
|
DistributionItem,
|
||||||
|
} from '@/features/dashboard/types';
|
||||||
import {
|
import {
|
||||||
getNodeStatusLabel,
|
getNodeStatusLabel,
|
||||||
getOpenrestyStatusLabel,
|
getOpenrestyStatusLabel,
|
||||||
@@ -76,6 +79,7 @@ type MapNodeDatum = {
|
|||||||
|
|
||||||
type CountryRegionDatum = {
|
type CountryRegionDatum = {
|
||||||
name: string;
|
name: string;
|
||||||
|
value: number;
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
areaColor: string;
|
areaColor: string;
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
@@ -133,10 +137,9 @@ function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [
|
coordinates: [
|
||||||
number,
|
...fallbackCoordinates[index % fallbackCoordinates.length],
|
||||||
number,
|
] as [number, number],
|
||||||
],
|
|
||||||
derivedFromGeo: false,
|
derivedFromGeo: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -156,7 +159,10 @@ function ensureWorldMapRegistered() {
|
|||||||
try {
|
try {
|
||||||
const geoJson = worldGeoJson as WorldGeoJson;
|
const geoJson = worldGeoJson as WorldGeoJson;
|
||||||
|
|
||||||
if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) {
|
if (
|
||||||
|
geoJson.type !== 'FeatureCollection' ||
|
||||||
|
!Array.isArray(geoJson.features)
|
||||||
|
) {
|
||||||
throw new Error('invalid world geojson payload');
|
throw new Error('invalid world geojson payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,82 +179,22 @@ function ensureWorldMapRegistered() {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const registrationError =
|
const registrationError =
|
||||||
error instanceof Error ? error : new Error('unknown world map registration error');
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('unknown world map registration error');
|
||||||
console.error('Failed to register ECharts world map', registrationError);
|
console.error('Failed to register ECharts world map', registrationError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPointInRing(point: [number, number], ring: number[][]) {
|
|
||||||
let inside = false;
|
|
||||||
|
|
||||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
|
||||||
const [xi, yi] = ring[i] ?? [];
|
|
||||||
const [xj, yj] = ring[j] ?? [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
yi > point[1] !== yj > point[1] &&
|
|
||||||
point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi || Number.EPSILON) + xi
|
|
||||||
) {
|
|
||||||
inside = !inside;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inside;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPointInPolygon(point: [number, number], rings: number[][][]) {
|
|
||||||
if (rings.length === 0 || !isPointInRing(point, rings[0] ?? [])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 1; index < rings.length; index += 1) {
|
|
||||||
if (isPointInRing(point, rings[index] ?? [])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCountryNameByCoordinates(
|
|
||||||
coordinates: [number, number],
|
|
||||||
geoJson: WorldGeoJson,
|
|
||||||
) {
|
|
||||||
for (const feature of geoJson.features ?? []) {
|
|
||||||
const name = feature.properties?.name;
|
|
||||||
const geometryType = feature.geometry?.type;
|
|
||||||
const geometryCoordinates = feature.geometry?.coordinates;
|
|
||||||
|
|
||||||
if (!name || !geometryType || !geometryCoordinates) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
geometryType === 'Polygon' &&
|
|
||||||
isPointInPolygon(coordinates, geometryCoordinates as number[][][])
|
|
||||||
) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geometryType === 'MultiPolygon') {
|
|
||||||
for (const polygon of geometryCoordinates as number[][][][]) {
|
|
||||||
if (isPointInPolygon(coordinates, polygon)) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorldStageMap({
|
export function WorldStageMap({
|
||||||
isDark,
|
isDark,
|
||||||
nodes,
|
nodes,
|
||||||
|
sourceCountries,
|
||||||
}: {
|
}: {
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
nodes: DashboardNodeHealth[];
|
nodes: DashboardNodeHealth[];
|
||||||
|
sourceCountries: DistributionItem[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -304,10 +250,8 @@ export function WorldStageMap({
|
|||||||
areaColor: '#13233b',
|
areaColor: '#13233b',
|
||||||
borderColor: 'rgba(125,211,252,0.14)',
|
borderColor: 'rgba(125,211,252,0.14)',
|
||||||
labelColor: '#e2e8f0',
|
labelColor: '#e2e8f0',
|
||||||
healthyAreaColor: 'rgba(74, 222, 128, 0.18)',
|
sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
|
||||||
dangerAreaColor: 'rgba(251, 113, 133, 0.18)',
|
sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
|
||||||
healthyAreaBorder: 'rgba(134, 239, 172, 0.4)',
|
|
||||||
dangerAreaBorder: 'rgba(253, 164, 175, 0.42)',
|
|
||||||
healthyColor: '#34d399',
|
healthyColor: '#34d399',
|
||||||
warningColor: '#fbbf24',
|
warningColor: '#fbbf24',
|
||||||
dangerColor: '#fb7185',
|
dangerColor: '#fb7185',
|
||||||
@@ -319,10 +263,8 @@ export function WorldStageMap({
|
|||||||
areaColor: '#eaf2ff',
|
areaColor: '#eaf2ff',
|
||||||
borderColor: 'rgba(71,85,105,0.12)',
|
borderColor: 'rgba(71,85,105,0.12)',
|
||||||
labelColor: '#0f172a',
|
labelColor: '#0f172a',
|
||||||
healthyAreaColor: '#dcfce7',
|
sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
|
||||||
dangerAreaColor: '#ffe4e6',
|
sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
|
||||||
healthyAreaBorder: '#86efac',
|
|
||||||
dangerAreaBorder: '#fda4af',
|
|
||||||
healthyColor: '#10b981',
|
healthyColor: '#10b981',
|
||||||
warningColor: '#f59e0b',
|
warningColor: '#f59e0b',
|
||||||
dangerColor: '#f43f5e',
|
dangerColor: '#f43f5e',
|
||||||
@@ -362,7 +304,11 @@ export function WorldStageMap({
|
|||||||
activeEventCount: node.active_event_count,
|
activeEventCount: node.active_event_count,
|
||||||
status: node.status,
|
status: node.status,
|
||||||
openrestyStatus: node.openresty_status,
|
openrestyStatus: node.openresty_status,
|
||||||
value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)],
|
value: [
|
||||||
|
coordinates[0],
|
||||||
|
coordinates[1],
|
||||||
|
Math.max(node.request_count, 1),
|
||||||
|
],
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: toneColor,
|
color: toneColor,
|
||||||
borderColor: toneBorder,
|
borderColor: toneBorder,
|
||||||
@@ -380,54 +326,40 @@ export function WorldStageMap({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const countryRegions = useMemo<CountryRegionDatum[]>(() => {
|
const countryRegions = useMemo<CountryRegionDatum[]>(() => {
|
||||||
const geoJson = worldGeoJson as WorldGeoJson;
|
const maxRequests = Math.max(
|
||||||
const countryToneMap = new Map<string, Tone>();
|
1,
|
||||||
|
...sourceCountries.map((item) => item.value || 0),
|
||||||
|
);
|
||||||
|
|
||||||
nodes.forEach((node, index) => {
|
return sourceCountries
|
||||||
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
|
.filter((item) => item.key && item.value > 0)
|
||||||
if (!derivedFromGeo) {
|
.map((item) => {
|
||||||
return;
|
const intensity = Math.max(0.18, item.value / maxRequests);
|
||||||
}
|
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
|
||||||
|
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
|
||||||
|
const areaColor = isDark
|
||||||
|
? `rgba(56, 189, 248, ${areaOpacity})`
|
||||||
|
: `rgba(14, 165, 233, ${areaOpacity})`;
|
||||||
|
const borderColor = isDark
|
||||||
|
? `rgba(125, 211, 252, ${borderOpacity})`
|
||||||
|
: `rgba(14, 165, 233, ${borderOpacity})`;
|
||||||
|
|
||||||
const countryName = getCountryNameByCoordinates(coordinates, geoJson);
|
return {
|
||||||
if (!countryName) {
|
name: item.key,
|
||||||
return;
|
value: item.value,
|
||||||
}
|
|
||||||
|
|
||||||
const nextTone = getNodeTone(node);
|
|
||||||
const normalizedTone = nextTone === 'healthy' ? 'healthy' : 'danger';
|
|
||||||
const currentTone = countryToneMap.get(countryName);
|
|
||||||
|
|
||||||
if (!currentTone || currentTone === 'healthy') {
|
|
||||||
countryToneMap.set(countryName, normalizedTone);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(countryToneMap.entries()).map(([name, tone]) => {
|
|
||||||
const areaColor =
|
|
||||||
tone === 'healthy'
|
|
||||||
? mapPalette.healthyAreaColor
|
|
||||||
: mapPalette.dangerAreaColor;
|
|
||||||
const borderColor =
|
|
||||||
tone === 'healthy'
|
|
||||||
? mapPalette.healthyAreaBorder
|
|
||||||
: mapPalette.dangerAreaBorder;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
itemStyle: {
|
|
||||||
areaColor,
|
|
||||||
borderColor,
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
areaColor,
|
areaColor,
|
||||||
borderColor,
|
borderColor,
|
||||||
},
|
},
|
||||||
},
|
emphasis: {
|
||||||
};
|
itemStyle: {
|
||||||
});
|
areaColor,
|
||||||
}, [mapPalette, nodes]);
|
borderColor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [isDark, sourceCountries]);
|
||||||
|
|
||||||
const responsiveMapScale = useMemo(() => {
|
const responsiveMapScale = useMemo(() => {
|
||||||
const { width, height } = containerSize;
|
const { width, height } = containerSize;
|
||||||
@@ -472,8 +404,25 @@ export function WorldStageMap({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
formatter: (params: unknown) => {
|
formatter: (params: unknown) => {
|
||||||
const data = (params as { data?: MapNodeDatum }).data;
|
const payload = params as {
|
||||||
if (!data) {
|
data?: MapNodeDatum | CountryRegionDatum;
|
||||||
|
seriesType?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
const data = payload.data;
|
||||||
|
if (
|
||||||
|
payload.seriesType !== 'scatter' &&
|
||||||
|
data &&
|
||||||
|
'value' in data &&
|
||||||
|
typeof data.value === 'number'
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
|
||||||
|
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !('requestCount' in data)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,17 +461,35 @@ export function WorldStageMap({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
|
{
|
||||||
|
type: 'map',
|
||||||
|
map: 'world',
|
||||||
|
geoIndex: 0,
|
||||||
|
data: countryRegions,
|
||||||
|
silent: true,
|
||||||
|
z: 1,
|
||||||
|
emphasis: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
coordinateSystem: 'geo',
|
coordinateSystem: 'geo',
|
||||||
data: mapNodes,
|
data: mapNodes,
|
||||||
|
z: 3,
|
||||||
progressive: 64,
|
progressive: 64,
|
||||||
large: true,
|
large: true,
|
||||||
largeThreshold: 24,
|
largeThreshold: 24,
|
||||||
symbolSize: (value: unknown) => {
|
symbolSize: (value: unknown) => {
|
||||||
const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1;
|
const size =
|
||||||
|
Array.isArray(value) && typeof value[2] === 'number'
|
||||||
|
? value[2]
|
||||||
|
: 1;
|
||||||
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
|
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
|
||||||
return Math.max(7, Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)));
|
return Math.max(
|
||||||
|
7,
|
||||||
|
Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -550,7 +517,15 @@ export function WorldStageMap({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[computedLayoutSize, computedZoom, countryRegions, isDark, mapNodes, mapPalette, responsiveMapScale],
|
[
|
||||||
|
computedLayoutSize,
|
||||||
|
computedZoom,
|
||||||
|
countryRegions,
|
||||||
|
isDark,
|
||||||
|
mapNodes,
|
||||||
|
mapPalette,
|
||||||
|
responsiveMapScale,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mapReady) {
|
if (!mapReady) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '@/features/dashboard/types';
|
} from '@/features/dashboard/types';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
type Tone = 'healthy' | 'warning' | 'danger';
|
type Tone = 'healthy' | 'warning' | 'danger' | 'source';
|
||||||
|
|
||||||
const WorldStageMap = dynamic(
|
const WorldStageMap = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -61,13 +61,18 @@ function HeroMetric({
|
|||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-3 text-[30px] font-semibold leading-none',
|
'mt-3 text-[30px] leading-none font-semibold',
|
||||||
isDark ? 'text-white' : 'text-slate-950',
|
isDark ? 'text-white' : 'text-slate-950',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
<p className={cn('mt-2 text-sm', isDark ? 'text-slate-300' : 'text-slate-600')}>
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-2 text-sm',
|
||||||
|
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{hint}
|
{hint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,9 +97,13 @@ function LegendPill({
|
|||||||
? isDark
|
? isDark
|
||||||
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
|
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
|
||||||
: 'border-amber-200 bg-amber-50 text-amber-700'
|
: 'border-amber-200 bg-amber-50 text-amber-700'
|
||||||
: isDark
|
: tone === 'source'
|
||||||
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
|
? isDark
|
||||||
: 'border-rose-200 bg-rose-50 text-rose-700';
|
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
|
||||||
|
: 'border-sky-200 bg-sky-50 text-sky-700'
|
||||||
|
: isDark
|
||||||
|
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
|
||||||
|
: 'border-rose-200 bg-rose-50 text-rose-700';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
|
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
|
||||||
@@ -103,58 +112,6 @@ function LegendPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CountrySignal({
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
isDark,
|
|
||||||
}: {
|
|
||||||
item: DistributionItem;
|
|
||||||
index: number;
|
|
||||||
isDark: boolean;
|
|
||||||
}) {
|
|
||||||
const darkAccents = [
|
|
||||||
'from-sky-400/35 to-cyan-400/10',
|
|
||||||
'from-violet-400/35 to-fuchsia-400/10',
|
|
||||||
'from-emerald-400/35 to-teal-400/10',
|
|
||||||
];
|
|
||||||
const lightAccents = [
|
|
||||||
'from-sky-100 via-white to-cyan-50',
|
|
||||||
'from-indigo-100 via-white to-fuchsia-50',
|
|
||||||
'from-emerald-100 via-white to-teal-50',
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'rounded-2xl border bg-gradient-to-br px-4 py-3 backdrop-blur',
|
|
||||||
isDark
|
|
||||||
? `border-white/10 ${darkAccents[index % darkAccents.length]}`
|
|
||||||
: `border-slate-200/80 ${lightAccents[index % lightAccents.length]} shadow-[0_14px_30px_rgba(148,163,184,0.12)]`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-[11px] tracking-[0.24em] uppercase',
|
|
||||||
isDark ? 'text-slate-200' : 'text-slate-500',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.key}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'mt-2 text-lg font-semibold',
|
|
||||||
isDark ? 'text-white' : 'text-slate-950',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.value.toLocaleString('zh-CN')}
|
|
||||||
</p>
|
|
||||||
<p className={cn('mt-1 text-xs', isDark ? 'text-slate-300' : 'text-slate-600')}>
|
|
||||||
最近 24 小时来源信号
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorldStage({
|
export function WorldStage({
|
||||||
summary,
|
summary,
|
||||||
traffic,
|
traffic,
|
||||||
@@ -235,18 +192,18 @@ export function WorldStage({
|
|||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[11px] tracking-[0.34em] uppercase',
|
'text-[11px] tracking-[0.34em] uppercase',
|
||||||
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
|
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Global Stage
|
Global Stage
|
||||||
</p>
|
</p>
|
||||||
<h2
|
<h2
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-2xl font-semibold',
|
'text-2xl font-semibold',
|
||||||
isDark ? 'text-white' : 'text-slate-950',
|
isDark ? 'text-white' : 'text-slate-950',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
全球态势板
|
全球态势板
|
||||||
</h2>
|
</h2>
|
||||||
@@ -266,18 +223,22 @@ export function WorldStage({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-6 top-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
|
'absolute top-6 left-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
|
||||||
isDark
|
isDark
|
||||||
? 'bg-sky-400/20 text-sky-100'
|
? 'bg-sky-400/20 text-sky-100'
|
||||||
: 'bg-sky-100/90 text-sky-700',
|
: 'bg-sky-100/90 text-sky-700',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'}
|
{sourceCountries.length > 0
|
||||||
|
? '访客来源热度'
|
||||||
|
: geoConfiguredNodes > 0
|
||||||
|
? '节点地理坐标'
|
||||||
|
: '节点覆盖信号'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-6 top-16 z-10 rounded-full px-3 py-1 text-[11px] backdrop-blur',
|
'absolute top-16 left-6 z-10 rounded-full px-3 py-1 text-[11px] backdrop-blur',
|
||||||
isDark
|
isDark
|
||||||
? 'bg-slate-900/45 text-slate-200'
|
? 'bg-slate-900/45 text-slate-200'
|
||||||
: 'bg-white/85 text-slate-600 shadow-[0_10px_24px_rgba(148,163,184,0.16)]',
|
: 'bg-white/85 text-slate-600 shadow-[0_10px_24px_rgba(148,163,184,0.16)]',
|
||||||
@@ -286,19 +247,40 @@ export function WorldStage({
|
|||||||
拖动平移,滚轮缩放
|
拖动平移,滚轮缩放
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2">
|
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
|
||||||
<LegendPill label="绿色: 运行正常" tone="healthy" isDark={isDark} />
|
<LegendPill
|
||||||
<LegendPill label="黄色: 资源承压" tone="warning" isDark={isDark} />
|
label="国家底色: 来源热度"
|
||||||
<LegendPill label="红色: 异常待处理" tone="danger" isDark={isDark} />
|
tone="source"
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
<LegendPill
|
||||||
|
label="绿色: 运行正常"
|
||||||
|
tone="healthy"
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
<LegendPill
|
||||||
|
label="黄色: 资源承压"
|
||||||
|
tone="warning"
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
<LegendPill
|
||||||
|
label="红色: 异常待处理"
|
||||||
|
tone="danger"
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={mapViewportRef}
|
ref={mapViewportRef}
|
||||||
className="absolute inset-x-3 top-16 bottom-28 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-32"
|
className="absolute inset-x-3 top-16 bottom-6 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-8"
|
||||||
>
|
>
|
||||||
<div className="h-full w-full min-w-0 overflow-hidden">
|
<div className="h-full w-full min-w-0 overflow-hidden">
|
||||||
{shouldRenderMap ? (
|
{shouldRenderMap ? (
|
||||||
<WorldStageMap isDark={isDark} nodes={nodes} />
|
<WorldStageMap
|
||||||
|
isDark={isDark}
|
||||||
|
nodes={nodes}
|
||||||
|
sourceCountries={sourceCountries}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -311,7 +293,7 @@ export function WorldStage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldRenderMap && nodes.length === 0 ? (
|
{shouldRenderMap && nodes.length === 0 ? (
|
||||||
<div className="pointer-events-none absolute inset-x-6 bottom-28 z-10">
|
<div className="pointer-events-none absolute inset-x-6 bottom-10 z-10">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
|
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
|
||||||
@@ -324,30 +306,6 @@ export function WorldStage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="absolute bottom-6 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
|
|
||||||
{sourceCountries.length > 0 ? (
|
|
||||||
sourceCountries.slice(0, 3).map((item, index) => (
|
|
||||||
<CountrySignal
|
|
||||||
key={`${item.key}-${item.value}`}
|
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur md:col-span-3',
|
|
||||||
isDark
|
|
||||||
? 'border-white/15 bg-white/5 text-slate-300'
|
|
||||||
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
暂无可用于全球来源展示的国家分布数据。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user