feat: 添加响应式地图缩放和容器大小调整功能,优化性能页面模板样式

This commit is contained in:
ryan
2026-03-14 23:23:49 +08:00
parent 8d406f5ade
commit 0a28d7bb2a
2 changed files with 90 additions and 23 deletions
@@ -7,7 +7,7 @@ import { ScatterChart } from 'echarts/charts';
import { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
@@ -91,6 +91,9 @@ type CountryRegionDatum = {
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
const baseWorldMapLayoutSizePercent = 180;
const baseWorldMapZoom = 1;
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
@@ -248,8 +251,10 @@ export function WorldStageMap({
nodes: DashboardNodeHealth[];
}) {
const router = useRouter();
const chartContainerRef = useRef<HTMLDivElement | null>(null);
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const ready = ensureWorldMapRegistered();
@@ -257,6 +262,41 @@ export function WorldStageMap({
setMapFailed(!ready);
}, []);
useEffect(() => {
const container = chartContainerRef.current;
if (!container) {
return;
}
const updateSize = () => {
const nextWidth = container.clientWidth;
const nextHeight = container.clientHeight;
setContainerSize((previous) =>
previous.width === nextWidth && previous.height === nextHeight
? previous
: { width: nextWidth, height: nextHeight },
);
};
updateSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateSize);
return () => {
window.removeEventListener('resize', updateSize);
};
}
const observer = new ResizeObserver(() => {
updateSize();
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, []);
const mapPalette = useMemo(
() =>
isDark
@@ -389,6 +429,27 @@ export function WorldStageMap({
});
}, [mapPalette, nodes]);
const responsiveMapScale = useMemo(() => {
const { width, height } = containerSize;
if (width <= 0 || height <= 0) {
return 1;
}
const widthScale = Math.min(Math.max(width / 960, 0.76), 1.18);
const heightScale = Math.min(Math.max(height / 520, 0.82), 1.12);
return Number(Math.min(widthScale, heightScale).toFixed(3));
}, [containerSize]);
const computedLayoutSize = useMemo(
() => `${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
[responsiveMapScale],
);
const computedZoom = useMemo(
() => Number((baseWorldMapZoom * responsiveMapScale).toFixed(3)),
[responsiveMapScale],
);
const mapOption = useMemo<EChartsCoreOption>(
() => ({
animation: false,
@@ -431,8 +492,8 @@ export function WorldStageMap({
roam: false,
silent: true,
layoutCenter: ['50%', '50%'],
layoutSize: '180%',
zoom: 1,
layoutSize: computedLayoutSize,
zoom: computedZoom,
regions: countryRegions,
itemStyle: {
areaColor: mapPalette.areaColor,
@@ -453,7 +514,8 @@ export function WorldStageMap({
largeThreshold: 24,
symbolSize: (value: unknown) => {
const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1;
return Math.max(8, Math.min(18, 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)));
},
label: {
show: false,
@@ -481,7 +543,7 @@ export function WorldStageMap({
},
],
}),
[countryRegions, isDark, mapNodes, mapPalette],
[computedLayoutSize, computedZoom, countryRegions, isDark, mapNodes, mapPalette, responsiveMapScale],
);
if (!mapReady) {
@@ -500,20 +562,22 @@ export function WorldStageMap({
}
return (
<ReactEChartsCore
echarts={echarts}
option={mapOption}
notMerge
lazyUpdate
opts={{ renderer: 'canvas' }}
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{ height: '100%', width: '100%' }}
/>
<div ref={chartContainerRef} className="h-full w-full">
<ReactEChartsCore
echarts={echarts}
option={mapOption}
notMerge
lazyUpdate
opts={{ renderer: 'canvas' }}
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{ height: '100%', width: '100%' }}
/>
</div>
);
}
@@ -27,6 +27,8 @@ import {
const settingsQueryKey = ['settings', 'options'] as const;
const previewQueryKey = ['performance', 'preview'] as const;
const templatePanelClassName =
'h-[560px] w-full box-border overflow-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 font-mono text-xs leading-6';
const defaultPerformanceFields = {
OpenRestyWorkerProcesses: 'auto',
@@ -1266,7 +1268,7 @@ export function PerformancePage() {
</div>
}
>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4">
<ResourceField
label="模板原文"
@@ -1275,7 +1277,8 @@ export function PerformancePage() {
<ResourceTextarea
value={templateContent}
onChange={(event) => setTemplateContent(event.target.value)}
className="min-h-[560px] font-mono text-xs leading-6"
spellCheck={false}
className={`${templatePanelClassName} resize-none`}
placeholder="请输入受控 nginx.conf 模板"
/>
</ResourceField>
@@ -1297,7 +1300,7 @@ export function PerformancePage() {
/>
</div>
<CodeBlock
className="max-h-[560px] overflow-auto text-xs leading-6 whitespace-pre-wrap">
className={`${templatePanelClassName} whitespace-pre-wrap break-words`}>
{preview.main_config}
</CodeBlock>
</div>