/** * 运行监控 - 3D 拓扑大屏 * * 移植自 ITYUNWEI TopologyView.vue * Three.js 场景 + ECharts 图表 + NetMesh 真实数据 */ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import * as echarts from 'echarts'; import { Network, Clock, Maximize2, Minimize2, RefreshCw, Server, Activity, TrendingUp, AlertTriangle, CheckCircle2, ListTodo, XCircle, Loader2, Bell, Expand, } from 'lucide-react'; import { useMonitoringData } from './useMonitoringData'; import MonitoringDetailPanel, { type PanelType } from './MonitoringDetailPanel'; import { createDeviceModel, type DeviceType, type DeviceStatus } from './deviceModels'; import type { Host } from '../../domain/models'; interface Props { hosts: Host[]; keys?: any[]; } // 设备类型映射 const typeLabel: Record = { server: '服务器', switch: '交换机', router: '路由器', firewall: '防火墙', storage: '存储', workstation: '工作站', h3c: '交换机', huawei: '交换机', }; // 状态颜色 const statusColors: Record = { normal: 0x10b981, warning: 0xf59e0b, critical: 0xef4444, offline: 0x6b7280, }; const statusHex: Record = { normal: '#10b981', warning: '#f59e0b', critical: '#ef4444', offline: '#6b7280', }; /** 标签样式唯一入口:根据设备状态返回渐变背景 + 边框色,兜底灰色 */ function getLabelStyle(status: string): { background: string; border: string; glow: string } { const hex = statusHex[status] ?? statusHex.offline; return { background: `linear-gradient(135deg, ${hex}ee, ${hex}88)`, border: `1px solid ${hex}cc`, glow: `${hex}55`, }; } // 链路颜色 const edgeColorMap: Record = { wan: 0x22d3ee, uplink: 0x38bdf8, l3: 0x818cf8, l2: 0x60a5fa, storage: 0x34d399, access: 0xc084fc, vpn: 0xc084fc, wireless: 0xfb7185, }; // 相对时间格式化 function formatRelativeTime(timestamp: number): string { if (!timestamp) return '未知'; const diff = Date.now() - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 30) return '刚刚'; if (seconds < 60) return `${seconds}秒前`; if (minutes < 60) return `${minutes}分钟前`; if (hours < 24) return `${hours}小时前`; if (days < 7) return `${days}天前`; const date = new Date(timestamp); return `${date.getMonth() + 1}-${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } // 扩展 Host 的设备类型信息 function classifyHostType(host: Host): string { const distro = (host.distro || '').toLowerCase(); const name = (host.name || '').toLowerCase(); const label = (host.label || '').toLowerCase(); const combined = `${distro} ${name} ${label}`; if (/h3c|huawei|vrp|comware|cisco|switch/.test(combined)) return 'switch'; if (/router|路由/.test(combined)) return 'router'; if (/firewall|防火墙|fw/.test(combined)) return 'firewall'; if (/storage|存储|nas|san/.test(combined)) return 'storage'; if (/workstation|pc|桌面/.test(combined)) return 'workstation'; return 'server'; } function classifyHostStatus(host: Host): string { // 简化:有 SSH 连接记录就当 normal if (!host) return 'offline'; return 'normal'; } // 自动生成链路 interface Link { id: string; source: string; target: string; type: string; } function autoGenerateLinks( devices: { id: string; type: string; name: string }[] ): Link[] { const links: Link[] = []; const coreSwitches = devices.filter( (d) => d.type === 'switch' && /核心|core/i.test(d.name) ); const accessSwitches = devices.filter( (d) => d.type === 'switch' && !/核心|core/i.test(d.name) ); const firewalls = devices.filter((d) => d.type === 'firewall'); const routers = devices.filter((d) => d.type === 'router'); const servers = devices.filter((d) => d.type === 'server'); let linkId = 0; const push = (source: string, target: string, type: string) => { links.push({ id: `link-${linkId++}`, source, target, type }); }; // 防火墙 → 路由/核心交换机 firewalls.forEach((f) => { routers.forEach((r) => push(f.id, r.id, 'wan')); if (coreSwitches.length === 0) { accessSwitches.slice(0, 1).forEach((a) => push(f.id, a.id, 'wan')); } }); // 路由 → 核心交换机 routers.forEach((r) => { coreSwitches.forEach((s) => push(r.id, s.id, 'l3')); }); // 核心交换机 → 接入交换机 coreSwitches.forEach((c) => { accessSwitches.forEach((a) => push(c.id, a.id, 'l2')); }); // 接入交换机 → 服务器(每个交换机连 1-3 台服务器) const serversPerSwitch = Math.max(1, Math.min(3, Math.ceil(servers.length / Math.max(accessSwitches.length, 1)))); accessSwitches.forEach((a, si) => { for (let i = 0; i < serversPerSwitch; i++) { const idx = (si * serversPerSwitch + i) % Math.max(servers.length, 1); if (servers[idx]) push(a.id, servers[idx].id, 'access'); } }); // 默认不生成连线,让用户手动添加 return []; } // 设备 3D 模型工厂(使用精细模型) function createDeviceMesh(type: string, status: string): THREE.Group { // 类型映射 const typeMap: Record = { server: 'server', switch: 'switch', router: 'router', firewall: 'firewall', storage: 'storage', workstation: 'workstation', h3c: 'switch', huawei: 'switch', }; const deviceType = typeMap[type] || 'server'; const deviceStatus: DeviceStatus = (status as DeviceStatus) || 'normal'; const group = createDeviceModel(deviceType, deviceStatus); // 关键设备加发光点 const color = statusColors[status] ?? 0x10b981; if (status === 'critical' || status === 'warning') { const halo = new THREE.PointLight(color, status === 'critical' ? 1.5 : 0.8, 10); halo.position.set(0, 0, 0); group.add(halo); } // 双面材质兜底 group.traverse((obj) => { const mesh = obj as THREE.Mesh; if (mesh.isMesh) { const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; mats.forEach((m) => { if (m) (m as THREE.Material).side = THREE.DoubleSide; }); } }); return group; } export function MonitoringTopologyView({ hosts, keys }: Props) { // 使用真实数据 hook const monitoring = useMonitoringData(hosts, keys); const containerRef = useRef(null); const chartTypeRef = useRef(null); const chartTrendRef = useRef(null); const [selectedDevice, setSelectedDevice] = useState(null); const [loading, setLoading] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); const [currentTime, setCurrentTime] = useState(new Date()); const [legend, setLegend] = useState(true); // Three.js 全局引用 const sceneRef = useRef(null); const cameraRef = useRef(null); const rendererRef = useRef(null); const labelRendererRef = useRef(null); const controlsRef = useRef(null); const nodesRef = useRef([]); const edgesRef = useRef([]); const labelsRef = useRef([]); const deviceMapRef = useRef>(new Map()); const raycasterRef = useRef(new THREE.Raycaster()); const pointerRef = useRef(new THREE.Vector2()); const animIdRef = useRef(0); const chartsRef = useRef([]); const transformControlsRef = useRef(null); // 自定义拖拽状态 const [isDragging, setIsDragging] = useState(false); const dragRef = useRef<{ node: THREE.Group | null; offset: THREE.Vector3 }>({ node: null, offset: new THREE.Vector3() }); // 时钟 useEffect(() => { const id = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(id); }, []); // 右键菜单状态 const [contextMenu, setContextMenu] = useState<{ x: number; y: number; deviceId: string } | null>(null); const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null); // 用户创建的连线(从 localStorage 加载) const [customLinks, setCustomLinks] = useState(() => { try { const saved = localStorage.getItem('monitoring-topology-links'); return saved ? JSON.parse(saved) : []; } catch { return []; } }); // 连线模式使用 ref 避免闭包问题 const linkModeRef = useRef<{ sourceId: string } | null>(null); const [linkModeUI, setLinkModeUI] = useState<{ sourceId: string } | null>(null); const autoRotateBeforeLinkRef = useRef(false); // 记录连线前的自动旋转状态 const setLinkMode = (mode: { sourceId: string } | null) => { // 如果进入连线模式,暂停自动旋转 if (mode && controlsRef.current) { autoRotateBeforeLinkRef.current = controlsRef.current.autoRotate; controlsRef.current.autoRotate = false; } // 如果退出连线模式,恢复自动旋转 if (!mode && controlsRef.current) { controlsRef.current.autoRotate = autoRotateBeforeLinkRef.current; } linkModeRef.current = mode; setLinkModeUI(mode); }; // 连线模式切换时更新 canvas 光标 useEffect(() => { if (rendererRef.current) { rendererRef.current.domElement.style.cursor = linkModeUI ? 'crosshair' : 'grab'; } }, [linkModeUI]); // 保存连线到 localStorage useEffect(() => { localStorage.setItem('monitoring-topology-links', JSON.stringify(customLinks)); }, [customLinks]); // 全屏:运行监控独占大屏(覆盖左侧菜单/顶部,ESC 退出) useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFullscreen) { e.preventDefault(); exitFullscreen(); } }; if (isFullscreen) { document.addEventListener('keydown', onKeyDown); // body 加 class,配合 CSS 让 monitoring-page 固定全屏 document.body.classList.add('monitoring-fullscreen-active'); // Electron 环境下同时让窗口也全屏 const nc = (window as any).netcatty; if (nc?.windowFullscreen) { nc.windowFullscreen(true).catch(() => {}); } } return () => { document.removeEventListener('keydown', onKeyDown); document.body.classList.remove('monitoring-fullscreen-active'); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFullscreen]); const enterFullscreen = useCallback(() => { setIsFullscreen(true); }, []); const exitFullscreen = useCallback(() => { setIsFullscreen(false); const nc = (window as any).netcatty; if (nc?.windowFullscreen) { nc.windowFullscreen(false).catch(() => {}); } }, []); const toggleFullscreen = useCallback(() => { if (isFullscreen) exitFullscreen(); else enterFullscreen(); }, [isFullscreen, enterFullscreen, exitFullscreen]); // 全屏切换时,触发所有 ECharts 重新计算尺寸(延迟几帧等布局稳定) useEffect(() => { const doResize = () => { chartsRef.current.forEach((c) => { try { c.resize(); } catch { /* 忽略 */ } }); }; // 多次触发确保动画结束后尺寸正确 requestAnimationFrame(doResize); setTimeout(doResize, 100); setTimeout(doResize, 300); setTimeout(doResize, 600); }, [isFullscreen]); // 设备列表 + 自动链路(数据来自巡检结果) const devices = useMemo(() => { return hosts.map((h) => { const type = classifyHostType(h); const online = monitoring.onlineMap[h.id] ?? true; const cpuUsage = monitoring.cpuMap[h.id] ?? 0; const memoryUsage = monitoring.memMap[h.id] ?? 0; const lastUpdate = monitoring.timestampMap[h.id] ?? 0; // 动态状态:根据 CPU/内存阈值 + 在线状态计算 let status: string; if (!online) { status = 'offline'; } else if (cpuUsage > 90 || memoryUsage > 95) { status = 'critical'; } else if (cpuUsage > 75 || memoryUsage > 80) { status = 'warning'; } else { status = 'normal'; } return { id: h.id, name: h.label || h.name || h.id, ip: h.hostname, type, status, model: '', location: '', online, cpuUsage, memoryUsage, networkThroughput: 0, lastUpdate, }; }); }, [hosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap]); // 合并自动生成链路和用户自定义链路 const links = useMemo(() => { const autoLinks = autoGenerateLinks(devices); return [...autoLinks, ...customLinks]; }, [devices, customLinks]); // 统计 const stats = useMemo(() => { const d = devices; return { total: d.length, online: d.filter((x) => x.online && x.status === 'normal').length, offline: d.filter((x) => !x.online || x.status === 'offline').length, warning: d.filter((x) => x.status === 'warning').length, critical: d.filter((x) => x.status === 'critical').length, linkCount: links.length, }; }, [devices, links]); // 设备类型分布 const typeDistribution = useMemo(() => { const counts: Record = { server: 0, switch: 0, router: 0, firewall: 0, storage: 0, workstation: 0, }; devices.forEach((d) => { counts[d.type] = (counts[d.type] || 0) + 1; }); return counts; }, [devices]); // Top5 列表 // CPU 负载列表(有 CPU 数据的设备,从高到低) const cpuList = useMemo( () => [...devices] .filter((d) => d.cpuUsage > 0) .sort((a, b) => b.cpuUsage - a.cpuUsage), [devices] ); // 内存负载列表(有内存数据的设备,从高到低) const memoryList = useMemo( () => [...devices] .filter((d) => d.memoryUsage > 0) .sort((a, b) => b.memoryUsage - a.memoryUsage), [devices] ); // 离线设备列表 const offlineDevices = useMemo( () => devices.filter((d) => d.status === 'offline'), [devices] ); // 最新数据更新时间 const lastDataUpdate = useMemo(() => { const timestamps = Object.values(monitoring.timestampMap); if (timestamps.length === 0) return 0; return Math.max(...timestamps); }, [monitoring.timestampMap]); // 活动告警(列表显示,只取前 8 条最新的) const activeAlarms = useMemo(() => { const order = ['critical', 'major', 'warning', 'minor', 'info']; return [...monitoring.alerts] .sort((a, b) => order.indexOf(a.level) - order.indexOf(b.level)) .slice(0, 8); }, [monitoring.alerts]); // 告警计数(用完整告警列表计算,确保统计准确) const alarmCounts = useMemo(() => { let critical = 0; let warning = 0; let info = 0; for (const a of monitoring.alerts) { if (a.level === 'critical') critical++; else if (a.level === 'warning') warning++; else info++; } return { critical, warning, info, total: monitoring.alerts.length }; }, [monitoring.alerts]); // 巡检任务状态 const taskStats = monitoring.taskStats; // ========== 详情面板状态 ========== const [detailPanel, setDetailPanel] = useState<{ open: boolean; type: PanelType; title: string; filter?: string; deviceId?: string; }>({ open: false, type: 'deviceList', title: '' }); const closeDetailPanel = useCallback(() => { setDetailPanel((p) => ({ ...p, open: false })); }, []); const openDeviceList = useCallback((title: string, filter: string) => { setDetailPanel({ open: true, type: 'deviceList', title, filter }); }, []); const openAlertList = useCallback((title: string, level?: string) => { setDetailPanel({ open: true, type: 'alertList', title, filter: level }); }, []); const openDeviceDetail = useCallback((deviceId: string) => { setDetailPanel({ open: true, type: 'deviceDetail', title: '设备详情', deviceId }); }, []); const openTaskList = useCallback((title: string, status: string) => { setDetailPanel({ open: true, type: 'taskList', title, filter: status }); }, []); // 详情面板 - 设备列表数据 const panelDeviceList = useMemo(() => { if (detailPanel.type !== 'deviceList' || !detailPanel.open) return []; const f = detailPanel.filter; let list = devices; if (f === 'online') list = devices.filter((d) => d.status === 'normal'); else if (f === 'offline') list = devices.filter((d) => d.status === 'offline'); else if (f === 'warning') list = devices.filter((d) => d.status === 'warning'); else if (f === 'critical') list = devices.filter((d) => d.status === 'critical'); else if (f === 'all') list = devices; return list.map((d) => ({ id: d.id, name: d.name, ip: d.ip, type: d.type, status: d.status as 'online' | 'offline' | 'warning' | 'critical', cpu: d.cpuUsage, memory: d.memoryUsage, lastUpdate: d.lastUpdate, })); }, [detailPanel, devices]); // 详情面板 - 告警列表数据 const panelAlertList = useMemo(() => { if (detailPanel.type !== 'alertList' || !detailPanel.open) return []; let list = monitoring.alerts; if (detailPanel.filter && detailPanel.filter !== 'all') { list = list.filter((a) => a.level === detailPanel.filter); } return list.map((a) => ({ id: a.id, deviceId: a.deviceId, deviceName: a.deviceName, level: a.level as 'critical' | 'warning' | 'info', message: a.message, time: a.time, metric: a.metric, metricValue: a.metricValue, threshold: a.threshold, })); }, [detailPanel, monitoring.alerts]); // 详情面板 - 单设备详情 const panelDeviceDetail = useMemo(() => { if (detailPanel.type !== 'deviceDetail' || !detailPanel.open || !detailPanel.deviceId) return undefined; const d = devices.find((x) => x.id === detailPanel.deviceId); if (!d) return undefined; const deviceAlerts = monitoring.alerts .filter((a) => a.deviceId === d.id) .slice(0, 10) .map((a) => ({ id: a.id, level: a.level, message: a.message, time: a.time, })); return { id: d.id, name: d.name, ip: d.ip, type: d.type, status: d.status as 'online' | 'offline' | 'warning' | 'critical', cpu: d.cpuUsage, memory: d.memoryUsage, lastUpdate: d.lastUpdate, alerts: deviceAlerts, }; }, [detailPanel, devices, monitoring.alerts]); // 详情面板 - 任务列表数据 const panelTaskList = useMemo(() => { if (detailPanel.type !== 'taskList' || !detailPanel.open) return []; const filter = detailPanel.filter as 'running' | 'success' | 'failed' | 'pending' | undefined; if (!filter) return monitoring.taskDevices; let filtered; if (filter === 'running') { // 执行中 = 所有启用了巡检的设备 filtered = monitoring.taskDevices.filter((d) => d.inspectionEnabled); } else { filtered = monitoring.taskDevices.filter((d) => d.status === filter); } return filtered; }, [detailPanel, monitoring.taskDevices]); // ===== Three.js 场景初始化 ===== useEffect(() => { if (!containerRef.current) return; const container = containerRef.current; // 清理旧资源 nodesRef.current.forEach((g) => { g.traverse((obj) => { if ((obj as THREE.Mesh).isMesh) { (obj as THREE.Mesh).geometry?.dispose(); const mat = (obj as THREE.Mesh).material; if (Array.isArray(mat)) mat.forEach((m) => m?.dispose()); else (mat as THREE.Material)?.dispose(); } }); }); edgesRef.current.forEach((e) => { e.geometry?.dispose(); (e.material as THREE.Material)?.dispose(); }); labelsRef.current.forEach((l) => l.remove()); const scene = new THREE.Scene(); // 深色运维蓝背景 + 雾气 scene.background = new THREE.Color(0x0a1628); scene.fog = new THREE.Fog(0x0a1628, 100, 250); sceneRef.current = scene; const camera = new THREE.PerspectiveCamera( 60, container.clientWidth / container.clientHeight, 0.1, 500 ); camera.position.set(0, 40, 60); // 调整位置以看到所有设备 cameraRef.current = camera; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(container.clientWidth, container.clientHeight); renderer.setClearColor(0x000000, 0); container.appendChild(renderer.domElement); rendererRef.current = renderer; renderer.localClippingEnabled = true; renderer.domElement.style.cursor = 'grab'; // CSS2D 标签渲染器 const labelRenderer = new CSS2DRenderer(); labelRenderer.setSize(container.clientWidth, container.clientHeight); const labelDom = labelRenderer.domElement; labelDom.style.position = 'absolute'; labelDom.style.top = '0'; labelDom.style.left = '0'; labelDom.style.width = '100%'; labelDom.style.height = '100%'; labelDom.style.pointerEvents = 'none'; labelDom.style.zIndex = '10'; container.appendChild(labelDom); labelRendererRef.current = labelRenderer; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 0, 0); // 目标设为原点,能看到设备区域 controls.update(); controls.minDistance = 30; controls.maxDistance = 150; controls.maxPolarAngle = Math.PI / 2 - 0.1; controls.autoRotate = true; controls.autoRotateSpeed = 0.4; controlsRef.current = controls; // TransformControls 拖拽(不添加到 scene,使用 ref 引用) const transformControls = new TransformControls(camera, renderer.domElement); transformControls.setMode('translate'); transformControls.setSize(0.8); transformControlsRef.current = transformControls; // 拖拽时不更新 OrbitControls transformControls.addEventListener('dragging-changed', (event) => { controls.enabled = !event.value; }); // 拖拽时禁用 autoRotate transformControls.addEventListener('dragging-changed', (event) => { controls.autoRotate = !event.value; }); // 自定义拖拽变量 let isDraggingDevice = false; let draggingNode: THREE.Group | null = null; let dragPlane = new THREE.Plane(); let dragOffset = new THREE.Vector3(); const mouseDownPosRef = { x: 0, y: 0 }; // 使用对象引用避免闭包问题 // 灯光 scene.add(new THREE.AmbientLight(0x404060, 0.6)); const keyLight = new THREE.DirectionalLight(0x60a5fa, 0.8); keyLight.position.set(30, 50, 30); scene.add(keyLight); const rimLight = new THREE.DirectionalLight(0x22d3ee, 0.4); rimLight.position.set(-30, 20, -30); scene.add(rimLight); // ===== 地图纹理 + 双网格(ITYUNWEI 原版)===== const gridSize = 320; const gridDiv = 40; let mapW = gridSize; let mapH = gridSize; // 裁剪平面工厂 const makeClipPlanes = (w: number, h: number) => [ new THREE.Plane(new THREE.Vector3(1, 0, 0), w / 2), new THREE.Plane(new THREE.Vector3(-1, 0, 0), w / 2), new THREE.Plane(new THREE.Vector3(0, 0, 1), h / 2), new THREE.Plane(new THREE.Vector3(0, 0, -1), h / 2), ]; // 地图外网格(深蓝) const gridOutside = new THREE.GridHelper(gridSize, gridDiv, 0x3d6aa0, 0x1e3a60); gridOutside.position.y = -0.45; const outsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate()); (gridOutside.material as THREE.Material).clippingPlanes = outsidePlanes; scene.add(gridOutside); // 地图上网格(极浅半透明) const gridOnMap = new THREE.GridHelper(gridSize, gridDiv, 0x0d1f30, 0x060f1a); gridOnMap.position.y = -0.44; const onMapMat = gridOnMap.material as THREE.MeshBasicMaterial; onMapMat.transparent = true; onMapMat.opacity = 0.18; scene.add(gridOnMap); // Glow ring const ringGeo = new THREE.RingGeometry(25, 30, 64); const ringMat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.06, side: THREE.DoubleSide, }); const ring = new THREE.Mesh(ringGeo, ringMat); ring.rotation.x = -Math.PI / 2; ring.position.y = -0.2; scene.add(ring); // SkyDome - 深色运维蓝渐变 const skyGeo = new THREE.SphereGeometry(300, 48, 32); skyGeo.scale(-1, 1, 1); const skyMat = new THREE.ShaderMaterial({ uniforms: { uTop: { value: new THREE.Color('#020617') }, uMid: { value: new THREE.Color('#0c4a6e') }, uSky: { value: new THREE.Color('#0369a1') }, uHorizon: { value: new THREE.Color('#0891b2') }, uBottom: { value: new THREE.Color('#0f172a') }, }, vertexShader: ` varying vec3 vWorldPos; void main(){ vWorldPos=(modelMatrix*vec4(position,1.0)).xyz; gl_Position=projectionMatrix*viewMatrix*modelMatrix*vec4(position,1.0); } `, fragmentShader: ` varying vec3 vWorldPos; uniform vec3 uTop, uMid, uSky, uHorizon, uBottom; void main(){ float h = normalize(vWorldPos).y; vec3 col; if(h > 0.5) col = mix(uMid, uTop, smoothstep(0.5, 1.0, h)); else if(h > 0.15) col = mix(uSky, uMid, smoothstep(0.15, 0.5, h)); else if(h > 0.02) col = mix(uHorizon, uSky, smoothstep(0.02, 0.15, h)); else if(h > -0.05) col = mix(uBottom, uHorizon, smoothstep(-0.05, 0.02, h)); else col = uBottom; gl_FragColor = vec4(col, 1.0); } `, side: THREE.BackSide, depthWrite: false, }); const sky = new THREE.Mesh(skyGeo, skyMat); sky.name = 'SkyDome'; scene.add(sky); // ===== ditu.png 地图纹理 + 边缘发光晕 ===== const texLoader = new THREE.TextureLoader(); texLoader.setCrossOrigin('anonymous'); texLoader.load( '/ditu.png', (texture) => { texture.colorSpace = THREE.SRGBColorSpace; const mapAspect = texture.image.width / texture.image.height; mapW = gridSize; mapH = gridSize / mapAspect; // 地图发光底层(比地图稍大,形成发光边缘) const glowW = mapW + 6; const glowH = mapH + 6; const glowGeo = new THREE.PlaneGeometry(glowW, glowH); const glowMat = new THREE.ShaderMaterial({ uniforms: { uMap: { value: texture }, uColor: { value: new THREE.Color('#38bdf8') }, uFade: { value: 0.12 }, }, vertexShader: ` varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); } `, fragmentShader: ` varying vec2 vUv; uniform sampler2D uMap; uniform vec3 uColor; uniform float uFade; void main(){ vec4 c = texture2D(uMap, vUv); float dx = min(vUv.x, 1.0 - vUv.x) * 2.0; float dy = min(vUv.y, 1.0 - vUv.y) * 2.0; float edge = min(dx, dy); float glow = 1.0 - smoothstep(0.0, uFade, edge); gl_FragColor = vec4(uColor, glow * 0.5); } `, transparent: true, side: THREE.DoubleSide, depthWrite: false, }); const glowPlane = new THREE.Mesh(glowGeo, glowMat); glowPlane.rotation.x = -Math.PI / 2; glowPlane.position.y = -0.58; scene.add(glowPlane); const mapGeo = new THREE.PlaneGeometry(mapW, mapH); const mapMat = new THREE.ShaderMaterial({ uniforms: { uMap: { value: texture }, uOpacity: { value: 0.85 }, uFade: { value: 0.08 }, }, vertexShader: ` varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); } `, fragmentShader: ` varying vec2 vUv; uniform sampler2D uMap; uniform float uOpacity; uniform float uFade; void main(){ vec4 c = texture2D(uMap, vUv); float dx = min(vUv.x, 1.0 - vUv.x) * 2.0; float dy = min(vUv.y, 1.0 - vUv.y) * 2.0; float edge = min(dx, dy); float a = smoothstep(0.0, uFade, edge); gl_FragColor = vec4(c.rgb, c.a * a * uOpacity); } `, transparent: true, side: THREE.DoubleSide, depthWrite: false, }); const mapPlane = new THREE.Mesh(mapGeo, mapMat); mapPlane.rotation.x = -Math.PI / 2; mapPlane.position.y = -0.6; scene.add(mapPlane); // 地图加载完更新网格裁剪平面 const newOnMapPlanes = makeClipPlanes(mapW, mapH); ;(gridOnMap.material as THREE.Material).clippingPlanes = newOnMapPlanes; const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate()); ;(gridOutside.material as THREE.Material).clippingPlanes = newOutsidePlanes; }, undefined, () => { // 地图加载失败,静默跳过 console.warn('[Monitoring] ditu.png load failed, skipping map texture'); } ); // 设备布局(分层集群) deviceMapRef.current = new Map(devices.map((d) => [d.id, d])); const positions: Record = {}; // 从 localStorage 加载保存的位置 const savedPositions = (() => { try { const saved = localStorage.getItem('monitoring-topology-positions'); return saved ? JSON.parse(saved) : null; } catch { return null; } })(); const coreSwitches = devices.filter((d) => d.type === 'switch' && /核心|core/i.test(d.name)); const accessSwitches = devices.filter((d) => d.type === 'switch' && !/核心|core/i.test(d.name)); const routers = devices.filter((d) => d.type === 'router'); const firewalls = devices.filter((d) => d.type === 'firewall'); const servers = devices.filter((d) => d.type === 'server'); const rest = devices.filter((d) => !positions[d.id]); // 如果有保存的位置,直接使用;否则使用默认布局 if (savedPositions) { devices.forEach((d) => { if (savedPositions[d.id]) { positions[d.id] = new THREE.Vector3( savedPositions[d.id].x, savedPositions[d.id].y, savedPositions[d.id].z ); } }); } else { // 默认分层布局 firewalls.forEach((d, i) => { positions[d.id] = new THREE.Vector3((i - firewalls.length / 2) * 10, 8, -25); }); routers.forEach((d, i) => { positions[d.id] = new THREE.Vector3((i - routers.length / 2) * 10, 7, -15); }); coreSwitches.forEach((d, i) => { positions[d.id] = new THREE.Vector3((i - coreSwitches.length / 2) * 12, 6, 0); }); accessSwitches.forEach((d, i) => { positions[d.id] = new THREE.Vector3( Math.cos((i / Math.max(accessSwitches.length, 1)) * Math.PI * 2) * 25 + (Math.random() - 0.5) * 6, 4, Math.sin((i / Math.max(accessSwitches.length, 1)) * Math.PI * 2) * 25 ); }); servers.forEach((d, i) => { positions[d.id] = new THREE.Vector3( Math.cos((i / Math.max(servers.length, 1)) * Math.PI * 2 + 0.3) * 40 + (Math.random() - 0.5) * 8, 2 + Math.random() * 2, Math.sin((i / Math.max(servers.length, 1)) * Math.PI * 2 + 0.3) * 40 ); }); rest.forEach((d, i) => { positions[d.id] = new THREE.Vector3( Math.cos((i / Math.max(rest.length, 1)) * Math.PI * 2 + 1) * 50, 1, Math.sin((i / Math.max(rest.length, 1)) * Math.PI * 2 + 1) * 50 + 20 ); }); } // 创建设备节点 const nodes: THREE.Group[] = []; for (const device of devices) { const pos = positions[device.id] || new THREE.Vector3(0, 0, 0); const group = createDeviceMesh(device.type, device.status); group.position.copy(pos); group.userData = { deviceId: device.id, baseScale: 1, isCritical: device.status === 'critical', currentStatus: device.status, labelDiv: null as HTMLDivElement | null, }; scene.add(group); nodes.push(group); // 名字 label(CSS2DObject,设备上方)— 背景色随设备状态变化 const labelDiv = document.createElement('div'); labelDiv.className = 'three-label'; labelDiv.textContent = device.name; labelDiv.dataset.deviceId = device.id; labelDiv.dataset.status = device.status; const labelStyle = getLabelStyle(device.status); Object.assign(labelDiv.style, { color: '#ffffff', fontSize: '13px', fontWeight: 'bold', padding: '3px 10px', background: labelStyle.background, border: labelStyle.border, borderRadius: '6px', pointerEvents: 'none', whiteSpace: 'nowrap', transform: 'translate(-50%, -100%)', fontFamily: 'Inter, "Microsoft YaHei", "PingFang SC", sans-serif', letterSpacing: '0.3px', textShadow: '0 1px 3px rgba(0,0,0,0.5)', boxShadow: `0 4px 16px ${labelStyle.glow}`, marginTop: '-8px', }); const labelObj = new CSS2DObject(labelDiv); labelObj.position.set(0, 3, 0); // 设备上方 3 单位 group.add(labelObj); group.userData.labelDiv = labelDiv; labelsRef.current.push(labelDiv); } nodesRef.current = nodes; // 创建链路 const edges: THREE.Line[] = []; for (const link of links) { const a = positions[link.source]; const b = positions[link.target]; if (!a || !b) continue; const points = [a.clone(), b.clone()]; const geo = new THREE.BufferGeometry().setFromPoints(points); const color = edgeColorMap[link.type] || 0x60a5fa; const mat = new THREE.LineDashedMaterial({ color, transparent: true, opacity: 0.85, dashSize: 1.5, gapSize: 0.8, }); const line = new THREE.Line(geo, mat); line.userData = { sourceId: link.source, targetId: link.target }; line.computeLineDistances(); scene.add(line); edges.push(line); } edgesRef.current = edges; // Raycaster 点击和拖拽 const raycaster = new THREE.Raycaster(); raycaster.params.Line = { threshold: 2.5 }; raycasterRef.current = raycaster; const getDeviceNodeFromIntersect = (obj: THREE.Object3D): THREE.Object3D | null => { let cur: THREE.Object3D | null = obj; while (cur && !cur.userData?.deviceId) cur = cur.parent; return cur; }; const onPointerDown = (event: MouseEvent) => { if (event.button !== 0) return; // 只处理左键 // 连线模式下不处理拖拽 if (linkModeRef.current) { // 保存点击位置(连线模式下 onPointerDown 会提前返回) mouseDownPosRef.x = event.clientX; mouseDownPosRef.y = event.clientY; return; } const rect = renderer.domElement.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; const y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pointerRef.current.set(x, y); raycaster.setFromCamera(pointerRef.current, camera); const intersects = raycaster.intersectObjects(nodes, true); // 保存点击位置(连线模式也需要记录) mouseDownPosRef.x = event.clientX; mouseDownPosRef.y = event.clientY; if (intersects.length > 0) { const cur = getDeviceNodeFromIntersect(intersects[0].object); if (cur) { isDraggingDevice = true; draggingNode = cur as THREE.Group; controls.enabled = false; // 禁用 OrbitControls controls.autoRotate = false; // 设置拖拽平面(垂直于相机方向) const normal = new THREE.Vector3(); camera.getWorldDirection(normal); dragPlane.setFromNormalAndCoplanarPoint(normal, draggingNode.position); // 计算鼠标在平面上的偏移 const intersection = new THREE.Vector3(); raycaster.ray.intersectPlane(dragPlane, intersection); dragOffset.copy(draggingNode.position).sub(intersection); setIsDragging(true); console.log('[拖拽] 开始拖拽设备:', draggingNode.userData.deviceId); } } }; const onPointerMove = (event: MouseEvent) => { if (!isDraggingDevice || !draggingNode) return; const rect = renderer.domElement.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; const y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pointerRef.current.set(x, y); raycaster.setFromCamera(pointerRef.current, camera); const intersection = new THREE.Vector3(); if (raycaster.ray.intersectPlane(dragPlane, intersection)) { draggingNode.position.copy(intersection.add(dragOffset)); } }; const onPointerUp = (event: MouseEvent) => { if (isDraggingDevice && draggingNode) { console.log('[拖拽] 结束拖拽,新位置:', draggingNode.position.x, draggingNode.position.y, draggingNode.position.z); isDraggingDevice = false; draggingNode = null; controls.enabled = true; setIsDragging(false); } }; const onPointerClick = (event: MouseEvent) => { // 检查是否是点击(而不是拖拽) const dx = event.clientX - mouseDownPosRef.x; const dy = event.clientY - mouseDownPosRef.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist > 30) return; // 移动距离太大(阈值30),跳过点击 const rect = renderer.domElement.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; const y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pointerRef.current.set(x, y); raycaster.setFromCamera(pointerRef.current, camera); const intersects = raycaster.intersectObjects(nodes, true); if (intersects.length > 0) { const cur = getDeviceNodeFromIntersect(intersects[0].object); if (cur) { const id = cur.userData.deviceId as string; const dev = deviceMapRef.current.get(id); // 连线模式 if (linkModeRef.current) { if (!linkModeRef.current.sourceId) { // 选择源设备 setLinkMode({ sourceId: id }); console.log('[连线] 已选择源设备:', dev?.name); } else if (linkModeRef.current.sourceId !== id) { // 选择目标设备,显示链路类型选择 setPendingLink({ sourceId: linkModeRef.current.sourceId, targetId: id }); setLinkMode(null); } } else { // 普通模式:选中设备并打开详情 setSelectedDevice(dev || null); openDeviceDetail(id); } } } else { // 连线模式下点击空白处,退出连线模式 if (linkModeRef.current) { setLinkMode(null); } else { setSelectedDevice(null); } } }; // 点击空白处取消选中 const onBackgroundClick = (event: MouseEvent) => { if (isDraggingDevice) return; const rect = renderer.domElement.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; const y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pointerRef.current.set(x, y); raycaster.setFromCamera(pointerRef.current, camera); const intersects = raycaster.intersectObjects(nodes, true); if (intersects.length === 0 && !linkModeUI) { setSelectedDevice(null); } }; // 右键菜单:创建连线 const onContextMenu = (event: MouseEvent) => { event.preventDefault(); const rect = renderer.domElement.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; const y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pointerRef.current.set(x, y); raycaster.setFromCamera(pointerRef.current, camera); const intersects = raycaster.intersectObjects(nodes, true); if (intersects.length > 0) { const obj = intersects[0].object; let cur: THREE.Object3D | null = obj; while (cur && !cur.userData?.deviceId) cur = cur.parent; if (cur) { const id = cur.userData.deviceId as string; setContextMenu({ x: event.clientX, y: event.clientY, deviceId: id }); } } else { setContextMenu(null); } }; renderer.domElement.addEventListener('contextmenu', onContextMenu); renderer.domElement.addEventListener('mousedown', onPointerDown); renderer.domElement.addEventListener('mousemove', onPointerMove); renderer.domElement.addEventListener('mouseup', onPointerUp); renderer.domElement.addEventListener('click', onPointerClick); renderer.domElement.addEventListener('click', onBackgroundClick); // Resize const onResize = () => { if (!container) return; const w = container.clientWidth; const h = container.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); labelRenderer.setSize(w, h); chartsRef.current.forEach((c) => c.resize()); }; const resizeObs = new ResizeObserver(onResize); resizeObs.observe(container); // 动画循环 const clock = new THREE.Clock(); const animate = () => { animIdRef.current = requestAnimationFrame(animate); const t = Date.now() * 0.001; const elapsed = clock.getElapsedTime(); // Critical 节点呼吸灯 + 选中节点高亮 + 运行时状态同步 nodes.forEach((n, i) => { const deviceId = n.userData.deviceId as string; const isSelected = selectedDevice?.id === deviceId; // 1) 运行时状态同步:从 deviceMapRef 拿最新 status const latestDev = deviceMapRef.current.get(deviceId); const latestStatus: string | undefined = latestDev?.status; const prevStatus: string = n.userData.currentStatus; const statusChanged = latestStatus && latestStatus !== prevStatus; if (statusChanged) { n.userData.currentStatus = latestStatus; n.userData.isCritical = latestStatus === 'critical'; // 同步标签颜色 const labelDiv = n.userData.labelDiv as HTMLDivElement | null; if (labelDiv) { const s = getLabelStyle(latestStatus); labelDiv.style.background = s.background; labelDiv.style.border = s.border; labelDiv.style.boxShadow = `0 4px 16px ${s.glow}`; labelDiv.dataset.status = latestStatus; } // 同步模型 body emissive 颜色(所有状态都更新) const emissiveHex = statusColors[latestStatus] ?? statusColors.offline; n.traverse((obj) => { if ( obj instanceof THREE.Mesh && obj.userData?.isBody && 'emissive' in (obj.material as any) ) { const mat = obj.material as THREE.MeshStandardMaterial; mat.emissive.setHex(emissiveHex); // 根据状态设置基础发光强度 mat.emissiveIntensity = latestStatus === 'normal' ? 0.5 : latestStatus === 'warning' ? 0.7 : latestStatus === 'critical' ? 1.0 : 0.2; } }); } // 2) 呼吸灯/脉冲效果(critical 或选中) const shouldPulse = n.userData.isCritical || isSelected; if (shouldPulse) { let intensity: number; if (isSelected) { intensity = 0.3 + Math.sin(t * 4) * 0.2; } else if (n.userData.isCritical) { intensity = 0.5 + Math.sin(t * 3 + i) * 0.3; } n.traverse((obj) => { if ( obj instanceof THREE.Mesh && obj.userData?.isBody && 'emissiveIntensity' in (obj.material as any) ) { const mat = obj.material as THREE.MeshStandardMaterial; if (isSelected) { mat.emissive.setHex(0x38bdf8); } mat.emissiveIntensity = intensity; } }); } // 3) 轻微浮动 const baseY = positions[n.userData.deviceId]?.y ?? 0; n.position.y = baseY + Math.sin(elapsed * 0.5 + i) * 0.15; }); // 虚线流动 + 连线端点跟随设备位置 edges.forEach((line, i) => { const sourceId = line.userData.sourceId; const targetId = line.userData.targetId; const sourceNode = nodes.find((n) => n.userData.deviceId === sourceId); const targetNode = nodes.find((n) => n.userData.deviceId === targetId); if (sourceNode && targetNode) { const posAttr = line.geometry.attributes.position; posAttr.setXYZ(0, sourceNode.position.x, sourceNode.position.y, sourceNode.position.z); posAttr.setXYZ(1, targetNode.position.x, targetNode.position.y, targetNode.position.z); posAttr.needsUpdate = true; line.computeLineDistances(); line.geometry.computeBoundingSphere(); } const mat = line.material as THREE.LineDashedMaterial; mat.dashOffset = -((t * 1.2 + i * 0.05) % (mat.dashSize + mat.gapSize)); }); controls.update(); transformControls.update(); renderer.render(scene, camera); labelRenderer.render(scene, camera); }; animate(); setLoading(false); return () => { cancelAnimationFrame(animIdRef.current); resizeObs.disconnect(); renderer.domElement.removeEventListener('contextmenu', onContextMenu); renderer.domElement.removeEventListener('mousedown', onPointerDown); renderer.domElement.removeEventListener('mousemove', onPointerMove); renderer.domElement.removeEventListener('mouseup', onPointerUp); renderer.domElement.removeEventListener('click', onPointerClick); renderer.domElement.removeEventListener('click', onBackgroundClick); // 保存设备位置到 localStorage try { const posData: Record = {}; nodes.forEach((n) => { const id = n.userData.deviceId; if (id) { posData[id] = { x: n.position.x, y: n.position.y, z: n.position.z, }; } }); localStorage.setItem('monitoring-topology-positions', JSON.stringify(posData)); } catch (e) { console.warn('[Monitoring] Failed to save positions:', e); } nodes.forEach((g) => { g.traverse((obj) => { if ((obj as THREE.Mesh).isMesh) { (obj as THREE.Mesh).geometry?.dispose(); const mat = (obj as THREE.Mesh).material; if (Array.isArray(mat)) mat.forEach((m) => m?.dispose()); else (mat as THREE.Material)?.dispose(); } }); }); edges.forEach((e) => { e.geometry?.dispose(); (e.material as THREE.Material)?.dispose(); }); labelsRef.current.forEach((l) => l.remove()); labelsRef.current = []; renderer.dispose(); if (renderer.domElement.parentNode === container) { container.removeChild(renderer.domElement); } if (labelRenderer.domElement.parentNode === container) { container.removeChild(labelRenderer.domElement); } chartsRef.current.forEach((c) => c.dispose()); chartsRef.current = []; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [devices.length, links.length]); // 设备状态实时同步:每次 devices 变化更新 ref,动画循环读取最新 status useEffect(() => { deviceMapRef.current = new Map(devices.map((d) => [d.id, d])); }, [devices]); // ===== ECharts 初始化 ===== useEffect(() => { // 等 DOM 挂载 + 数据就绪 const timer = setTimeout(() => { chartsRef.current.forEach((c) => c.dispose()); chartsRef.current = []; const fs = { xs: 8, sm: 10, md: 12, }; const commonGrid = { top: 24, left: 32, right: 10, bottom: 24, containLabel: true }; // 设备类型分布柱状图 if (chartTypeRef.current) { const c = echarts.init(chartTypeRef.current, undefined, { renderer: 'canvas' }); chartsRef.current.push(c); const names = ['服务器', '交换机', '路由器', '防火墙', '存储', '工作站']; const data = [ typeDistribution.server, typeDistribution.switch, typeDistribution.router, typeDistribution.firewall, typeDistribution.storage, typeDistribution.workstation, ]; c.setOption({ grid: commonGrid, tooltip: { trigger: 'axis', backgroundColor: '#0f172a', borderColor: '#1e293b', textStyle: { color: '#e2e8f0', fontSize: fs.sm }, }, xAxis: { type: 'category', data: names, axisLine: { lineStyle: { color: '#334155' } }, axisTick: { show: false }, axisLabel: { color: '#64748b', fontSize: fs.xs, interval: 0, rotate: 20 }, }, yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#64748b', fontSize: fs.sm }, minInterval: 1, }, series: [ { type: 'bar', data, barWidth: '45%', label: { show: true, position: 'top', color: '#38bdf8', fontSize: fs.xs + 1, fontWeight: 600, }, itemStyle: { borderRadius: [3, 3, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#0ea5e9' }, { offset: 1, color: 'rgba(14,165,233,0.2)' }, ]), }, }, ], }); } // 告警趋势(暂时用巡检历史生成) if (chartTrendRef.current) { const c = echarts.init(chartTrendRef.current, undefined, { renderer: 'canvas' }); chartsRef.current.push(c); const trend = monitoring.alarmTrend; c.setOption({ grid: commonGrid, tooltip: { trigger: 'axis', backgroundColor: '#0f172a', borderColor: '#1e293b', textStyle: { color: '#e2e8f0', fontSize: fs.sm }, }, xAxis: { type: 'category', data: trend.map((t) => t.date), axisLine: { lineStyle: { color: '#334155' } }, axisTick: { show: false }, axisLabel: { color: '#64748b', fontSize: fs.xs }, }, yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#64748b', fontSize: fs.sm }, minInterval: 1, }, series: [ { type: 'line', smooth: true, symbol: 'circle', symbolSize: 5, data: trend.map((t) => t.count), lineStyle: { color: '#22d3ee', width: 2 }, itemStyle: { color: '#22d3ee' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: 'rgba(34,211,238,0.4)' }, { offset: 1, color: 'rgba(34,211,238,0)' }, ]), }, }, ], }); } // 多次 resize 确保撑满 const resizeAll = () => chartsRef.current.forEach((c) => c.resize()); requestAnimationFrame(resizeAll); setTimeout(resizeAll, 100); setTimeout(resizeAll, 300); }, 80); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [typeDistribution, monitoring.alarmTrend]); const resetView = useCallback(() => { if (cameraRef.current && controlsRef.current) { cameraRef.current.position.set(0, 50, 75); controlsRef.current.target.set(0, -12, 0); controlsRef.current.autoRotate = true; controlsRef.current.update(); } }, []); const refreshData = useCallback(() => { monitoring.refresh(); }, [monitoring]); // 自动刷新间隔(秒) const [autoRefreshInterval, setAutoRefreshInterval] = useState(() => { try { const saved = localStorage.getItem('monitoring-auto-refresh'); return saved !== null ? parseInt(saved, 10) : 30; } catch { return 30; } }); const [showRefreshMenu, setShowRefreshMenu] = useState(false); const refreshOptions = [ { label: '关闭', value: 0 }, { label: '10 秒', value: 10 }, { label: '30 秒', value: 30 }, { label: '1 分钟', value: 60 }, { label: '2 分钟', value: 120 }, { label: '5 分钟', value: 300 }, ]; // 保存刷新间隔到 localStorage useEffect(() => { try { localStorage.setItem('monitoring-auto-refresh', String(autoRefreshInterval)); } catch { /* 忽略 */ } }, [autoRefreshInterval]); // 自动刷新定时器 useEffect(() => { if (autoRefreshInterval <= 0) return; const id = setInterval(() => { monitoring.refresh(); }, autoRefreshInterval * 1000); return () => clearInterval(id); }, [autoRefreshInterval, monitoring]); return ( <> {/* 3D 节点 label 样式 + 整页深色主题覆盖(ITYUNWEI 风格) */}
正常 {stats.online}
预警 {stats.warning}
故障 {stats.critical}
离线 {stats.offline}
{stats.total} 台设备 · {stats.linkCount} 链路
{alarmCounts.total > 0 && (
openAlertList('活动告警', 'all')} > ⚠ {alarmCounts.total} 活动告警
)}
数据更新于 {lastDataUpdate ? formatRelativeTime(lastDataUpdate) : '暂无数据'}
{/* 左栏 */} {/* 中间 3D 主视区 */}
{loading && (
加载 3D 拓扑全景中...
)} {/* 图例 */} {legend && (
图例
{[ { color: 'bg-emerald-500', label: '正常' }, { color: 'bg-amber-500', label: '警告' }, { color: 'bg-red-500', label: '严重' }, { color: 'bg-slate-500', label: '离线' }, ].map((it) => (
{it.label}
))}
)} {/* 底部控制栏 */}
{showRefreshMenu && ( <>
setShowRefreshMenu(false)} />
{refreshOptions.map((opt) => (
{ setAutoRefreshInterval(opt.value); setShowRefreshMenu(false); }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(34,211,238,0.1)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = autoRefreshInterval === opt.value ? 'rgba(34,211,238,0.1)' : 'transparent'; }} > {opt.label}
))}
)}
{/* 右下角提示 */}
🖱️ 滚轮缩放 · 拖拽旋转 · 点击设备查看详情
{/* 右栏 */} {/* 设备详情弹窗 */} {/* 连线类型选择对话框 */} {pendingLink && (
setPendingLink(null)} >
e.stopPropagation()} >
选择链路类型
{[ { type: 'wan', label: 'WAN', color: '#06b6d4' }, { type: 'l3', label: 'L3路由', color: '#8b5cf6' }, { type: 'l2', label: 'L2交换', color: '#3b82f6' }, { type: 'access', label: '接入', color: '#22c55e' }, { type: 'storage', label: '存储', color: '#f59e0b' }, { type: 'vpn', label: 'VPN', color: '#ec4899' }, { type: 'wireless', label: '无线', color: '#14b8a6' }, { type: 'custom', label: '自定义', color: '#6b7280' }, ].map(({ type, label, color }) => ( ))}
)} {/* 右键菜单 */} {contextMenu && (
e.stopPropagation()} >
)} {/* 详情面板 */} openDeviceDetail(deviceId)} />
); } export default MonitoringTopologyView;