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 { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { useRouter } from 'next/navigation'; 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 { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json'; import worldGeoJson from '@/features/dashboard/data/world-geo.json';
@@ -91,6 +91,9 @@ type CountryRegionDatum = {
let worldMapRegistrationAttempted = false; let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false; let worldMapRegistrationSucceeded = false;
const baseWorldMapLayoutSizePercent = 180;
const baseWorldMapZoom = 1;
function buildNodeDetailHref(id?: number | null) { function buildNodeDetailHref(id?: number | null) {
if (!id) { if (!id) {
return '/node'; return '/node';
@@ -248,8 +251,10 @@ export function WorldStageMap({
nodes: DashboardNodeHealth[]; nodes: DashboardNodeHealth[];
}) { }) {
const router = useRouter(); const router = useRouter();
const chartContainerRef = useRef<HTMLDivElement | null>(null);
const [mapReady, setMapReady] = useState(false); const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false); const [mapFailed, setMapFailed] = useState(false);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
useEffect(() => { useEffect(() => {
const ready = ensureWorldMapRegistered(); const ready = ensureWorldMapRegistered();
@@ -257,6 +262,41 @@ export function WorldStageMap({
setMapFailed(!ready); 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( const mapPalette = useMemo(
() => () =>
isDark isDark
@@ -389,6 +429,27 @@ export function WorldStageMap({
}); });
}, [mapPalette, nodes]); }, [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>( const mapOption = useMemo<EChartsCoreOption>(
() => ({ () => ({
animation: false, animation: false,
@@ -431,8 +492,8 @@ export function WorldStageMap({
roam: false, roam: false,
silent: true, silent: true,
layoutCenter: ['50%', '50%'], layoutCenter: ['50%', '50%'],
layoutSize: '180%', layoutSize: computedLayoutSize,
zoom: 1, zoom: computedZoom,
regions: countryRegions, regions: countryRegions,
itemStyle: { itemStyle: {
areaColor: mapPalette.areaColor, areaColor: mapPalette.areaColor,
@@ -453,7 +514,8 @@ export function WorldStageMap({
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;
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: { label: {
show: false, show: false,
@@ -481,7 +543,7 @@ export function WorldStageMap({
}, },
], ],
}), }),
[countryRegions, isDark, mapNodes, mapPalette], [computedLayoutSize, computedZoom, countryRegions, isDark, mapNodes, mapPalette, responsiveMapScale],
); );
if (!mapReady) { if (!mapReady) {
@@ -500,20 +562,22 @@ export function WorldStageMap({
} }
return ( return (
<ReactEChartsCore <div ref={chartContainerRef} className="h-full w-full">
echarts={echarts} <ReactEChartsCore
option={mapOption} echarts={echarts}
notMerge option={mapOption}
lazyUpdate notMerge
opts={{ renderer: 'canvas' }} lazyUpdate
onEvents={{ opts={{ renderer: 'canvas' }}
click: (params: { data?: MapNodeDatum }) => { onEvents={{
if (params.data?.route) { click: (params: { data?: MapNodeDatum }) => {
router.push(params.data.route); if (params.data?.route) {
} router.push(params.data.route);
}, }
}} },
style={{ height: '100%', width: '100%' }} }}
/> style={{ height: '100%', width: '100%' }}
/>
</div>
); );
} }
@@ -27,6 +27,8 @@ import {
const settingsQueryKey = ['settings', 'options'] as const; const settingsQueryKey = ['settings', 'options'] as const;
const previewQueryKey = ['performance', 'preview'] 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 = { const defaultPerformanceFields = {
OpenRestyWorkerProcesses: 'auto', OpenRestyWorkerProcesses: 'auto',
@@ -1266,7 +1268,7 @@ export function PerformancePage() {
</div> </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"> <div className="space-y-4">
<ResourceField <ResourceField
label="模板原文" label="模板原文"
@@ -1275,7 +1277,8 @@ export function PerformancePage() {
<ResourceTextarea <ResourceTextarea
value={templateContent} value={templateContent}
onChange={(event) => setTemplateContent(event.target.value)} 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 模板" placeholder="请输入受控 nginx.conf 模板"
/> />
</ResourceField> </ResourceField>
@@ -1297,7 +1300,7 @@ export function PerformancePage() {
/> />
</div> </div>
<CodeBlock <CodeBlock
className="max-h-[560px] overflow-auto text-xs leading-6 whitespace-pre-wrap"> className={`${templatePanelClassName} whitespace-pre-wrap break-words`}>
{preview.main_config} {preview.main_config}
</CodeBlock> </CodeBlock>
</div> </div>