/** * 运行监控 - 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 { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import { Line2 } from 'three/examples/jsm/lines/Line2.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; import * as echarts from 'echarts'; import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE } from '../../infrastructure/config/storageKeys'; import { Network, Clock, Maximize2, Minimize2, RefreshCw, Server, Activity, TrendingUp, AlertTriangle, CheckCircle2, ListTodo, XCircle, Loader2, Bell, Expand, Box, Link2, CircleDot, AlertOctagon, WifiOff, PieChart, ArrowUpRight, Cpu, HardDrive, BellRing, Volume2, VolumeX, Info, Monitor, Router, Shield, Database, } 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[]; } // ===== 全局单例:WebGLRenderer / CSS2DRenderer ===== // 浏览器 WebGL context 上限 8-16,短时间 mount/unmount 多次会丢 oldest。 // 这里挂到 window 上做真正的全局单例 —— HMR 模块替换不会重置 window 变量。 // eslint-disable-next-line @typescript-eslint/no-explicit-any const _W = window as any; function disposeMatTextures(mat: any) { if (!mat) return; // 1. 遍历材质直接属性(map / normalMap / emissiveMap 等) for (const key of Object.keys(mat)) { const v = mat[key]; if (v && v.isTexture) v.dispose?.(); } // 2. ShaderMaterial 的贴图在 uniforms 里(ditu.png 在 uniforms.uMap.value) if (mat.uniforms) { for (const uKey of Object.keys(mat.uniforms)) { const u = mat.uniforms[uKey]; if (u?.value?.isTexture) u.value.dispose?.(); } } mat.dispose?.(); } function getGlobalWebGLRenderer(): THREE.WebGLRenderer { if (!_W.__netMeshWebGLRenderer) { _W.__netMeshWebGLRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance', }); const r: THREE.WebGLRenderer = _W.__netMeshWebGLRenderer; r.setPixelRatio(Math.min(window.devicePixelRatio, 2)); r.setClearColor(0x000000, 0); r.localClippingEnabled = true; r.domElement.style.cursor = 'grab'; r.domElement.style.pointerEvents = 'auto'; r.domElement.style.display = 'block'; r.domElement.style.width = '100%'; r.domElement.style.height = '100%'; } return _W.__netMeshWebGLRenderer as THREE.WebGLRenderer; } function getGlobalCSS2DRenderer(): CSS2DRenderer { if (!_W.__netMeshCSS2DRenderer) { _W.__netMeshCSS2DRenderer = new CSS2DRenderer(); const c: CSS2DRenderer = _W.__netMeshCSS2DRenderer; c.domElement.style.position = 'absolute'; c.domElement.style.top = '0'; c.domElement.style.left = '0'; c.domElement.style.width = '100%'; c.domElement.style.height = '100%'; c.domElement.style.pointerEvents = 'none'; c.domElement.style.zIndex = '10'; } return _W.__netMeshCSS2DRenderer as CSS2DRenderer; } // 统一的卡片标题条 —— 发光图标块 + 标题 + 右侧 >>> 装饰箭头 const CardHeader: React.FC<{ icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; title: string; iconColor?: string; iconBg?: string; extra?: React.ReactNode; }> = ({ icon: Icon, title, iconColor = '#67e8f9', iconBg = 'rgba(14,165,233,0.25)', extra }) => (
{title} {extra &&
{extra}
}
› › ›
); // 设备类型 → 图标组件 const deviceTypeIconMap: Record> = { server: Server, switch: Network, router: Router, firewall: Shield, storage: Database, workstation: Monitor, h3c: Network, huawei: Network, }; const getDeviceIcon = (type?: string) => deviceTypeIconMap[type || ''] || Server; // 设备类型映射 const typeLabel: Record = { server: '服务器', switch: '交换机', router: '路由器', firewall: '防火墙', storage: '存储', workstation: '工作站', h3c: '交换机', huawei: '交换机', }; // 状态颜色 const statusColors: Record = { normal: 0x86efac, // 浅绿色 tailwind green-300 warning: 0xffe600, critical: 0xff4d4f, offline: 0xcbd5e1, }; const statusHex: Record = { normal: '#86EFAC', warning: '#FFE600', critical: '#FF4D4F', offline: '#6b7280', }; /** 标签样式唯一入口:根据设备状态返回渐变背景 + 边框色,兜底灰色 */ function getLabelStyle(status: string): { background: string; border: string; glow: string; text: string } { // normal:浅米色 #E4E4E1 — 干净清爽,区别于设备模型荧光绿 if (status === 'normal') { return { background: '#5fc06fff', border: '1px solid #B8B4AE', glow: '#E4E4E133', text: '#ffffffff', }; } // offline:浅灰底 + 红字,清晰标识又不致黑 if (status === 'offline') { return { background: 'linear-gradient(135deg, #cbd5e1ee, #b8c3d3dd)', border: '1px solid #94a3b8cc', glow: '#ff4d4f33', text: '#ff4d4f', }; } // warning / critical 保持荧光色渐变 + 白字 const hex = statusHex[status] ?? statusHex.offline; return { background: `linear-gradient(135deg, ${hex}ee, ${hex}88)`, border: `1px solid ${hex}cc`, glow: `${hex}55`, text: '#ffffff', }; } // 链路颜色(不同链路类型不同色系,在浅卫星地图上清晰可辨) const edgeColorMap: Record = { wan: 0x2196f3, // 蓝 — WAN 骨干 uplink: 0x2196f3, // 蓝 — 上行 l3: 0x4caf50, // 深绿 — L3 路由 l2: 0x00bcd4, // 深青 — L2 交换 storage: 0xffa500, // 橙 — 存储 access: 0x9c27b0, // 深紫 — 接入点 vpn: 0x2196f3, // 蓝 — VPN wireless: 0x66bb6a, // 浅绿 — 无线 default: 0x2196f3, }; // 设备安全边界常量 const MIN_Y = -0.5; const MAX_Y = 20; // 创建离线连线的"断开"叉叉标记(两条交叉细线 + 红色发光材质) function createBreakMark(): THREE.Group { const group = new THREE.Group(); const size = 0.45; const mat = new THREE.LineBasicMaterial({ color: 0xff4d4f, depthTest: false, transparent: true, opacity: 1.0, }); // 两条对角线 const pts1 = [new THREE.Vector3(-size, size, 0), new THREE.Vector3(size, -size, 0)]; const pts2 = [new THREE.Vector3(-size, -size, 0), new THREE.Vector3(size, size, 0)]; const geo1 = new THREE.BufferGeometry().setFromPoints(pts1); const geo2 = new THREE.BufferGeometry().setFromPoints(pts2); const line1 = new THREE.Line(geo1, mat); const line2 = new THREE.Line(geo2, mat); group.add(line1); group.add(line2); // 外圈小圆圈 const ringSize = size * 1.8; const ringGeo = new THREE.RingGeometry(ringSize - 0.12, ringSize, 32); // 用 LineLoop 更像"空心"圈 —— 先转成 BufferGeometry 上的点 const ringPts: THREE.Vector3[] = []; const segs = 36; for (let i = 0; i <= segs; i++) { const a = (i / segs) * Math.PI * 2; ringPts.push(new THREE.Vector3(Math.cos(a) * ringSize, Math.sin(a) * ringSize, 0)); } const ringLine = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints(ringPts), mat ); group.add(ringLine); group.renderOrder = 10; return group; } // 对每条边生成稳定 hash 的弧线中间控制点,让汇聚连线沿不同方向散开 function computeArcMidpoint(a: THREE.Vector3, b: THREE.Vector3, hashKey: string): { mid: THREE.Vector3 } { // 简单 hash → 0..1 let h = 0; for (let i = 0; i < hashKey.length; i++) h = (h * 31 + hashKey.charCodeAt(i)) >>> 0; const t = (h & 0xffff) / 0xffff; // 0..1 const angle = t * Math.PI * 2; // 0..2π 随机方向 // 连线中点 const mx = (a.x + b.x) / 2; const mz = (a.z + b.z) / 2; const my = (a.y + b.y) / 2; // 偏移:沿 xz 平面随机方向,幅值按连线长度 const dx = b.x - a.x; const dz = b.z - a.z; const len = Math.sqrt(dx * dx + dz * dz) || 1; // 偏移量 = min(连线长度 * 0.25, 7),避免过弯 const amp = Math.min(len * 0.25, 7); const ox = Math.cos(angle) * amp; const oz = Math.sin(angle) * amp; // 中间点也稍抬高一点,让弧线"拱"起来,不和地图贴平 const oy = my + 1.5; return { mid: new THREE.Vector3(mx + ox, oy, mz + oz) }; } // 相对时间格式化 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 { // 优先用直接的 type 字段(remote API 会返回 switch/server/firewall/router) const directType = (host as any).type; if (directType && ['server', 'switch', 'router', 'firewall', 'storage', 'workstation'].includes(directType)) { return directType; } const distro = (host.distro || (host as any).catalog || (host as any).catalog_id || '').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'); } }); // 默认不生成连线,只使用用户手动添加的 customLinks 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] ?? 0x22c55e; if (status === 'critical' || status === 'warning') { const halo = new THREE.PointLight(color, status === 'critical' ? 2.5 : 1.5, 14); halo.position.set(0, 0, 0); group.add(halo); } // 所有设备加状态光环(增强大屏可见性) const ring = new THREE.Mesh( new THREE.RingGeometry(0.35, 0.55, 24), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: status === 'offline' ? 0.15 : 0.35, side: THREE.DoubleSide, }) ); ring.rotation.x = -Math.PI / 2; ring.position.y = -0.02; ring.renderOrder = 3; group.add(ring); // 双面材质兜底 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 [isTopologyOnly, setIsTopologyOnly] = useState(false); // 3D拓扑大屏模式 const [isPanelsHidden, setIsPanelsHidden] = useState(false); // 面板正在滑出/已滑出(过渡动画用) const [currentTime, setCurrentTime] = useState(new Date()); const [legend, setLegend] = useState(true); const [showConnectionLegend, setShowConnectionLegend] = useState(true); const [monitoringTitle, setMonitoringTitle] = useState(() => { // 先给默认值,useEffect 里远程异步加载(时序更安全) if (typeof window !== "undefined") { const saved = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE); if (saved) return saved; } return '云启Net智能巡检监测系统'; }); // 远程环境:异步加载标题 / 位置 / 连接线(useState init 阶段 token 可能还没注入) useEffect(() => { if (typeof window === "undefined") return; const token = (window as any).__REMOTE_TOKEN__; if (!token) return; // 不是远程环境或 token 未就绪 const fetchJSON = (url: string): Promise => fetch(url).then(r => r.json()).catch(() => null); // 1. 标题 fetchJSON(`/api/dashboard-title?token=${encodeURIComponent(token)}`) .then(json => { if (json?.ok && json.title) setMonitoringTitle(json.title); }); // 2. 拓扑位置 fetchJSON(`/api/topology-positions?token=${encodeURIComponent(token)}`) .then(json => { if (json?.ok && json.positions && Object.keys(json.positions).length > 0) { setSavedPositions(json.positions); } }); // 3. 连接线 fetchJSON(`/api/topology-links?token=${encodeURIComponent(token)}`) .then(json => { if (json?.ok && Array.isArray(json.links)) setCustomLinks(json.links); }); }, []); // Electron 环境:监听 localStorage 变化 + mount 时同步 hosts/title 给主进程 useEffect(() => { const nc = (window as any).netcatty; console.log('[MonitoringTopologyView sync] nc exists:', !!nc, 'nc.inspection exists:', !!nc?.inspection, 'hosts.length:', hosts?.length); if (!nc?.inspection) { console.log('[MonitoringTopologyView sync] skip — not Electron env'); return; } // mount 时一次性同步 vault hosts + 标题 + 拓扑位置 + 拓扑连线 if (hosts?.length > 0) { console.log('[MonitoringTopologyView sync] saving vault hosts:', hosts.length); nc.inspection.saveVaultHosts(hosts).then((r: any) => console.log('saveVaultHosts result:', r)).catch((e: any) => console.warn('saveVaultHosts error:', e)); } const savedTitle = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE); console.log('[MonitoringTopologyView sync] savedTitle:', savedTitle); if (savedTitle) nc.inspection.saveDashboardTitle(savedTitle).catch(() => {}); const savedPos = localStorage.getItem('monitoring-topology-positions'); if (savedPos) nc.inspection.saveTopologyPositions(JSON.parse(savedPos)).catch(() => {}); const savedLinks = localStorage.getItem('monitoring-topology-links'); console.log('[MonitoringTopologyView sync] savedLinks exists:', !!savedLinks); if (savedLinks) { try { nc.inspection.saveTopologyLinks(JSON.parse(savedLinks)).catch(() => {}); } catch {} } if (!savedLinks && nc.inspection.getTopologyLinks) { nc.inspection.getTopologyLinks().then((linksFromMain: any) => { if (Array.isArray(linksFromMain) && linksFromMain.length > 0) { console.info('[MonitoringTopologyView] restore links from main:', linksFromMain.length); localStorage.setItem('monitoring-topology-links', JSON.stringify(linksFromMain)); try { setCustomLinks(linksFromMain); } catch {} } }).catch(() => {}); } const syncToMain = (value: string) => { if (value) nc.inspection.saveDashboardTitle(value).catch(() => {}); }; const handler = (e: StorageEvent) => { if (e.key === STORAGE_KEY_MONITORING_DASHBOARD_TITLE && e.newValue !== null) { setMonitoringTitle(e.newValue || '云启Net智能巡检监测系统'); syncToMain(e.newValue); } }; const localHandler = () => { const v = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE); setMonitoringTitle(v || '云启Net智能巡检监测系统'); syncToMain(v || ''); }; window.addEventListener('storage', handler); window.addEventListener('monitoringTitleChanged' as any, localHandler); // mount 时同步一次当前标题 const initVal = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE); if (initVal) syncToMain(initVal); return () => { window.removeEventListener('storage', handler); window.removeEventListener('monitoringTitleChanged' as any, localHandler); }; }, []); // Three.js 全局引用 const sceneRef = useRef(null); const cameraRef = useRef(null); const rendererRef = useRef(null); const composerRef = useRef(null); const labelRendererRef = useRef(null); const controlsRef = useRef(null); const nodesRef = useRef([]); const edgesRef = useRef([]); const linkBreakRef = useRef([]); // 离线连线的"断开"标记叉叉 const labelsRef = useRef([]); const deviceMapRef = useRef>(new Map()); // deviceId → THREE.Group 的快速查找表(替代 animate 里的 Array.find,O(1)) const nodeGroupMapRef = useRef>(new Map()); const raycasterRef = useRef(new THREE.Raycaster()); const pointerRef = useRef(new THREE.Vector2()); const animIdRef = useRef(0); // 每个设备的持久 baseY(不受 animate 浮动或拖拽影响的真值) const deviceBaseYRef = useRef>(new Map()); const positionsRef = useRef>({}); 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() }); // 相机参数 —— 正常模式(45°俯视)和大屏模式(正俯视) const normalCameraRef = useRef({ pos: new THREE.Vector3(0, 55, 115), target: new THREE.Vector3(-20, -25, 20), minDist: 30, maxDist: 150, autoRotate: true, autoRotateSpeed: 0.4, fov: 60 }); // 相机平滑过渡动画(Vector3.lerp 逐帧插值) interface CameraAnim { fromPos: THREE.Vector3; toPos: THREE.Vector3; fromTarget: THREE.Vector3; toTarget: THREE.Vector3; fromFov: number; toFov: number; startMs: number; durationMs: number; onComplete?: () => void; onCompleteRestoreControls?: boolean; } const cameraAnimRef = useRef(null); // easeInOutCubic:前半段慢加速,中间快,后半段慢减速(线性的太生硬) const easeInOutCubic = (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; // 告警提示音:静音状态(localStorage 持久化) const [isMuted, setIsMuted] = useState(() => { try { return localStorage.getItem('monitoring-alert-muted') === '1'; } catch { return false; } }); // 去重:避免同级别告警短时间连续响 const _lastAlertSoundRef = useRef<{ time: number; level: string }>({ time: 0, level: '' }); // 预渲染好的 AI 告警音 AudioBuffer(首次渲染时生成,之后直接复用) const _buffersRef = useRef(null); // 常驻 AudioContext(resume 一次即可,避免每次 new 都可能是 suspended 状态) const _persistentCtxRef = useRef(null); // ===== 每次用户交互都尝试 resume AudioContext ===== // Chromium 无用户交互 8 秒后会 suspend AudioContext,所以不能只监听第一次 useEffect(() => { const AC = (window.AudioContext || (window as any).webkitAudioContext); if (!AC) return; const ensureActive = () => { try { if (!_persistentCtxRef.current) { _persistentCtxRef.current = new AC() as AudioContext; } const ctx = _persistentCtxRef.current; if (ctx.state === 'suspended') ctx.resume().catch(() => {}); } catch {} }; window.addEventListener('pointerdown', ensureActive); window.addEventListener('keydown', ensureActive); window.addEventListener('touchstart', ensureActive); return () => { window.removeEventListener('pointerdown', ensureActive); window.removeEventListener('keydown', ensureActive); window.removeEventListener('touchstart', ensureActive); if (_persistentCtxRef.current) { try { _persistentCtxRef.current.close(); } catch {} } }; }, []); // ===== OfflineAudioContext 预渲染引擎 ===== // 使用 AI 合成脚本:循环 beep (880Hz square + 1200Hz sine) + lowpass 白噪背景,共 3s useEffect(() => { let cancelled = false; const AC = (window.AudioContext || (window as any).webkitAudioContext); if (!AC) return; (async () => { try { const buf = await renderAlarmBeep(); if (!cancelled) _buffersRef.current = buf; } catch { /* 静默 */ } })(); return () => { cancelled = true; }; }, []); // —— 用户提供的 AI 告警合成脚本(移植为 TypeScript + Promise)—— function renderAlarmBeep(): Promise { const SR = 44100; const dur = 3.0; // 必须用 OfflineAudioContext 构造(不是 AudioContext),只有它有 startRendering 方法 const OAC = (window as any).OfflineAudioContext || (window as any).webkitOfflineAudioContext; if (!OAC) throw new Error('OfflineAudioContext not supported'); const octx = new OAC(1, Math.floor(SR * dur), SR); const dest = octx.destination; // Helper: noise buffer const noise = (ctx: any, d: number) => { const size = Math.max(1, Math.floor(ctx.sampleRate * d)); const buf = ctx.createBuffer(1, size, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < size; i++) data[i] = Math.random() * 2 - 1; return buf; }; // Helper: play tone with linear envelope const tone = (freq: number, type: OscillatorType, d: number, vol: number, start: number) => { const osc = octx.createOscillator(); const g = octx.createGain(); osc.type = type; osc.frequency.value = freq; g.gain.setValueAtTime(0, start); g.gain.linearRampToValueAtTime(vol, start + 0.01); g.gain.setValueAtTime(vol, start + d * 0.6); g.gain.linearRampToValueAtTime(0, start + d); osc.connect(g); g.connect(dest); osc.start(start); osc.stop(start + d + 0.01); }; // Gentle slow repeating alert, not harsh or fast // Longer on/off cycle: 0.45s beep, 0.35s pause, warm sine/triangle waveforms (no sharp square) tone(620, 'triangle', 0.45, 0.18, 0.0); tone(930, 'sine', 0.45, 0.09, 0.0); tone(620, 'triangle', 0.45, 0.18, 0.8); tone(930, 'sine', 0.45, 0.09, 0.8); tone(620, 'triangle', 0.45, 0.18, 1.6); tone(930, 'sine', 0.45, 0.09, 1.6); tone(620, 'triangle', 0.45, 0.18, 2.4); tone(930, 'sine', 0.45, 0.09, 2.4); // Soft low filtered ambient tech hum background, low volume const noiseBuf = noise(octx, dur); const noiseSrc = octx.createBufferSource(); const noiseGain = octx.createGain(); const filter = octx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 1600; noiseGain.gain.setValueAtTime(0, 0); noiseGain.gain.linearRampToValueAtTime(0.04, 0.05); noiseGain.gain.linearRampToValueAtTime(0, dur); noiseSrc.buffer = noiseBuf; noiseSrc.connect(filter); filter.connect(noiseGain); noiseGain.connect(dest); noiseSrc.start(0); noiseSrc.stop(dur); // startRendering 兼容:Chromium 旧版是 callback,新版返回 Promise return new Promise((resolve, reject) => { const p = octx.startRendering(); if (p && typeof p.then === 'function') { // 新版 Promise 风格 p.then(resolve, reject); } else { // 旧版 callback 风格 try { octx.oncomplete = (e: any) => resolve(e.renderedBuffer); } catch {} try { octx.startRendering(() => {}); } catch (e) { reject(e); } } }); } // 播放一次(响完自动停)。每次 ensureActive 已经负责 resume,这里只负责 start const playAlertSoundOnce = useCallback(() => { if (isMuted) return; try { const AC = (window.AudioContext || (window as any).webkitAudioContext); if (!AC) return; const buf = _buffersRef.current; if (!buf) return; let ctx = _persistentCtxRef.current; if (!ctx) ctx = new AC() as AudioContext; // 兜底:suspended 就再 resume 一次(ensureActive 本来已经在 resume 了) if (ctx.state === 'suspended') { ctx.resume().catch(() => {}); } const src = ctx.createBufferSource(); src.buffer = buf; const g = ctx.createGain(); g.gain.value = 0.9; src.connect(g); g.connect(ctx.destination); src.start(); setTimeout(() => { try { src.stop(); } catch {} try { src.disconnect(); g.disconnect(); } catch {} }, 4000); } catch { /* 静默 */ } }, [isMuted]); // 每次 pointerdown 都会触发 ensureActive → ctx.resume(),取消静音那次点击就是一次有效 resume // 取消静音后主动响一次(等 ctx 真 active 再播),让用户立刻确认恢复 const prevMutedRef = useRef(isMuted); useEffect(() => { if (prevMutedRef.current && !isMuted) { // muted → unmuted 转换:等一帧让 ensureActive 的 resume 完成再播 setTimeout(() => playAlertSoundOnce(), 300); } prevMutedRef.current = isMuted; }, [isMuted, playAlertSoundOnce]); // 每 30 秒自动响一次(未静音时)—— 用 ref 替代 state 依赖避免定时器重建开销 const isMutedRef = useRef(isMuted); useEffect(() => { isMutedRef.current = isMuted; }, [isMuted]); useEffect(() => { const id = setInterval(() => { if (!isMutedRef.current) playAlertSoundOnce(); }, 30_000); return () => clearInterval(id); }, [playAlertSoundOnce]); // 监听告警变化 → 播放提示音(只对新产生的告警) useEffect(() => { const api = (window as any).netcatty?.inspection; if (!api?.onAlertChanged) return; let timer: ReturnType | null = null; const handler = async () => { if (timer) clearTimeout(timer); timer = setTimeout(async () => { try { const alerts = await api.listAlerts({ limit: 1 }); const latest = Array.isArray(alerts) && alerts.length > 0 ? alerts[0] : null; if (!latest) return; // 新告警才响:用 triggered_at 和 id 作为去重 const key = `${latest.id}_${latest.triggered_at}`; if ((handler as any)._lastKey === key) return; (handler as any)._lastKey = key; playAlertSoundOnce(); } catch { /* 忽略 */ } }, 800); }; const unsub = api.onAlertChanged(handler); return () => { if (timer) clearTimeout(timer); try { unsub?.(); } catch (_) {} }; }, [playAlertSoundOnce]); // 正俯视: pos = target + (0, height, 0),polar angle ≈ 0 const TOPOLOGY_TARGET = new THREE.Vector3(-20, -40, 20); const TOPOLOGY_HEIGHT = 180; // 大屏模式相机:更西南侧、拉远距离确保完整地图 + 所有设备都在视野内 const topologyOnlyCamera = { pos: new THREE.Vector3(TOPOLOGY_TARGET.x - 140, TOPOLOGY_TARGET.y + 160, TOPOLOGY_TARGET.z + 90), target: TOPOLOGY_TARGET.clone(), minDist: 80, maxDist: 600, autoRotate: true, autoRotateSpeed: 0.3, minPolarAngle: Math.PI / 5, maxPolarAngle: Math.PI / 2 - 0.01, }; // 保存原始天空/背景用于大屏模式切换恢复 const skyBackupRef = useRef<{ background: any; fog: any } | null>(null); // 切换 3D 拓扑大屏模式(面板滑出/滑入,**相机完全不动**,只开/关 autoRotate) const toggleTopologyOnly = useCallback(() => { const camera = cameraRef.current; const controls = controlsRef.current; if (!camera || !controls) { setIsTopologyOnly((v) => !v); return; } if (!isTopologyOnly) { // === 进入大屏 === // 保存当前相机参数(用户可能已手动调整过视角) normalCameraRef.current.pos.copy(camera.position); normalCameraRef.current.target.copy(controls.target); normalCameraRef.current.autoRotate = controls.autoRotate; normalCameraRef.current.fov = camera.fov; // 面板滑出 + 开启自动旋转(相机位置不变) setIsPanelsHidden(true); controls.autoRotate = true; controls.autoRotateSpeed = 0.3; controls.enablePan = false; // 禁平移,防止用户转着转出视野 // 等面板滑出后切 grid + 卸载 aside setTimeout(() => { setIsTopologyOnly(true); }, 360); } else { // === 退出大屏 === controls.enablePan = true; // autoRotate 保持不变(任何模式都自动旋转) setIsTopologyOnly(false); requestAnimationFrame(() => { requestAnimationFrame(() => setIsPanelsHidden(false)); }); } }, [isTopologyOnly]); // 退出大屏时触发 ECharts resize(aside 从条件隐藏中 remount 需要重新撑满) useEffect(() => { if (isTopologyOnly) return; const resizeAll = () => chartsRef.current.forEach((c) => { try { c.resize(); } catch { /* ignore */ } }); requestAnimationFrame(resizeAll); setTimeout(resizeAll, 100); setTimeout(resizeAll, 300); setTimeout(resizeAll, 600); }, [isTopologyOnly]); // 时钟 useEffect(() => { const id = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(id); }, []); // 右键菜单状态 const [contextMenu, setContextMenu] = useState<{ x: number; y: number; deviceId: string } | null>(null); const [deleteLinkConfirm, setDeleteLinkConfirm] = useState<{ x: number; y: number; sourceId: string; targetId: string; isCustom: boolean; } | null>(null); // 已删除的 auto 生成连线(避免刷新后又出现) const [excludedAutoLinks, setExcludedAutoLinks] = useState(() => { try { const saved = localStorage.getItem('monitoring-topology-excluded-links'); return saved ? JSON.parse(saved) : []; } catch { return []; } }); const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null); // 用户创建的连线 —— 默认空数组,远程/本地各自从 useEffect 加载 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]); // 连线变化时同步到主进程 inspection-data.json(供远程浏览器 API 读取) useEffect(() => { const nc = (window as any).netcatty; if (!nc?.inspection) return; nc.inspection.saveTopologyLinks(customLinks).catch(() => {}); }, [customLinks]); useEffect(() => { localStorage.setItem('monitoring-topology-excluded-links', JSON.stringify(excludedAutoLinks)); }, [excludedAutoLinks]); useEffect(() => { try { localStorage.setItem('monitoring-alert-muted', isMuted ? '1' : '0'); } catch {} }, [isMuted]); const toggleMute = useCallback(() => setIsMuted((v) => !v), []); // 全屏:运行监控独占大屏(覆盖左侧菜单/顶部,ESC 退出) useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFullscreen) { e.preventDefault(); exitFullscreen(); } }; // 浏览器原生全屏变化(ESC 退出),同步 React 状态 const onFsChange = () => { if (!document.fullscreenElement && isFullscreen) { setIsFullscreen(false); } }; if (isFullscreen) { document.addEventListener('keydown', onKeyDown); document.addEventListener('fullscreenchange', onFsChange); 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.removeEventListener('fullscreenchange', onFsChange); document.body.classList.remove('monitoring-fullscreen-active'); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFullscreen]); const enterFullscreen = useCallback(() => { setIsFullscreen(true); // 浏览器远程访问环境:用原生 Fullscreen API 让 body 全屏 if (typeof (document.documentElement as any).requestFullscreen === 'function') { (document.documentElement as any).requestFullscreen().catch(() => {}); } else if (typeof (document.body as any).webkitRequestFullscreen === 'function') { (document.body as any).webkitRequestFullscreen(); } }, []); const exitFullscreen = useCallback(() => { setIsFullscreen(false); const nc = (window as any).netcatty; if (nc?.windowFullscreen) { nc.windowFullscreen(false).catch(() => {}); } // 浏览器环境退出原生全屏 if (document.fullscreenElement && typeof document.exitFullscreen === 'function') { document.exitFullscreen().catch(() => {}); } else if (typeof (document as any).webkitExitFullscreen === 'function') { (document as any).webkitExitFullscreen(); } }, []); 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]); // 设备列表(status 优先取告警中心记录,兜底用本地阈值) const devices = useMemo(() => { // 从告警记录里提取每个设备的最严重告警级别(monitoring.alerts 已是 active 告警) // 统一转字符串匹配,避免 device_id 是数字但 host.id 是字符串或反过来 const alertLevelByDevice = new Map(); for (const a of monitoring.alerts) { const key = String(a.deviceId); const cur = alertLevelByDevice.get(key); const rank = (lv: string) => lv === 'critical' ? 2 : lv === 'warning' ? 1 : 0; if (!cur || rank(a.level) > rank(cur)) { alertLevelByDevice.set(key, a.level); } } return monitoring.effectiveHosts.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; // 本地阈值(与主进程 alertRuleStore 默认规则对齐) let localStatus: string; if (!online) { localStatus = 'offline'; } else if (cpuUsage > monitoring.thresholds.cpuCritical || memoryUsage > monitoring.thresholds.memCritical) { localStatus = 'critical'; } else if (cpuUsage > monitoring.thresholds.cpuWarning || memoryUsage > monitoring.thresholds.memWarning) { localStatus = 'warning'; } else { localStatus = 'normal'; } // 告警记录里的级别(统一 String 匹配) const alertLevel = alertLevelByDevice.get(String(h.id)); // 离线 > 告警 critical > 告警 warning > 本地计算 let status = localStatus; if (alertLevel === 'critical' && localStatus !== 'offline') { status = 'critical'; } else if (alertLevel === 'warning' && localStatus === 'normal') { status = 'warning'; } // label 里可能已经包含 IP(如 "外网-192.168.0.221"),去重显示 let rawName = h.label || h.id; const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/; const name = rawName.replace(ipRegex, '').trim().replace(/[-_\s]+$|^[-_\s]+/g, '') || rawName; return { id: h.id, name, ip: h.hostname, type, status, model: '', location: '', online, cpuUsage, memoryUsage, networkThroughput: 0, lastUpdate, }; }); }, [monitoring.effectiveHosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]); // 合并自动生成链路和用户自定义链路,并过滤掉源/目标已不存在的僵尸连线 const links = useMemo(() => { const deviceIds = new Set(devices.map((d) => d.id)); const autoLinks = autoGenerateLinks(devices) .filter((l) => !excludedAutoLinks.some((e) => e.source === l.source && e.target === l.target)) .filter((l) => deviceIds.has(l.source) && deviceIds.has(l.target)); const validCustom = customLinks.filter((l) => deviceIds.has(l.source) && deviceIds.has(l.target)); // 只有在 devices 至少有数据后才执行清理,避免初始空 devices 状态下误删全部 customLinks if (devices.length > 0 && validCustom.length !== customLinks.length) { queueMicrotask(() => { setCustomLinks(validCustom); localStorage.setItem('monitoring-topology-links', JSON.stringify(validCustom)); }); } return [...autoLinks, ...validCustom]; }, [devices, customLinks, excludedAutoLinks]); // 统计 const stats = useMemo(() => { const d = devices; const normal = d.filter((x) => x.status === 'normal').length; const warning = d.filter((x) => x.status === 'warning').length; const criticalOnly = d.filter((x) => x.status === 'critical').length; const offline = d.filter((x) => x.status === 'offline').length; // 严重告警 = 离线设备 + 严重告警设备(离线属于 critical 级别告警) const critical = criticalOnly + offline; return { total: d.length, online: normal + warning + criticalOnly, // 所有非离线、非严重告警设备 offline, warning, critical, normal, 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]); // 活动告警(显示全部,滚动查看) const activeAlarms = useMemo(() => { const order = ['critical', 'major', 'warning', 'minor', 'info']; return [...monitoring.alerts] .sort((a, b) => order.indexOf(a.level) - order.indexOf(b.level)); }, [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 === 'normal') 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; // 每次 setup 都 new Scene/Camera/Controls — 不复用。 // 旧 scene 在替换前必须完整 dispose 所有 GPU 资源(geometry/material/texture), // 否则每次 tab 切换都泄漏一批 GPU buffer + label DOM,累积后渲染重叠 + 卡顿。 if (animIdRef.current) { cancelAnimationFrame(animIdRef.current); animIdRef.current = 0; } if (composerRef.current) { try { composerRef.current.dispose(); } catch { /* ignore */ } composerRef.current = null; } labelsRef.current.forEach((l) => l.remove()); labelsRef.current = []; // 完整 dispose 旧 scene 的所有 GPU 资源 const oldScene = sceneRef.current; if (oldScene) { oldScene.traverse((obj) => { if ((obj as THREE.Mesh).isMesh || (obj as THREE.Line).isLine) { const m = obj as any; if (m.geometry) m.geometry.dispose?.(); const mat = m.material; if (Array.isArray(mat)) mat.forEach((x: any) => disposeMatTextures(x)); else disposeMatTextures(mat); } // dispose CSS2DObject 的 DOM(CSS2DObject.element 不在 Object3D 类型声明里) const cssEl = (obj as any).element; if (cssEl && cssEl.parentNode) { try { cssEl.parentNode.removeChild(cssEl); } catch { /* ignore */ } } }); // 断开旧 scene 与所有子节点的引用,让 GC 回收(避免 GPU buffer 泄漏) while (oldScene.children.length > 0) oldScene.remove(oldScene.children[0]); } sceneRef.current = null; nodesRef.current = []; edgesRef.current = []; linkBreakRef.current = []; cameraRef.current = null; controlsRef.current = null; transformControlsRef.current = null; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x6fa8dc); scene.fog = new THREE.Fog(0xcfe2f3, 120, 280); sceneRef.current = scene; const camera = new THREE.PerspectiveCamera( 60, container.clientWidth / container.clientHeight, 0.1, 500 ); camera.position.set(0, 55, 115); cameraRef.current = camera; // 复用全局单例 WebGLRenderer —— 整个应用只创建一个 WebGL context const renderer = getGlobalWebGLRenderer(); renderer.setSize(container.clientWidth, container.clientHeight); // 全局单例的 domElement 可能挂在别的父节点,先 detach 再挂到这个 container const glDom = renderer.domElement; if (glDom.parentNode !== container) { if (glDom.parentNode) glDom.parentNode.removeChild(glDom); container.appendChild(glDom); } rendererRef.current = renderer; // 后处理:径向模糊 — 中心清晰、两侧模糊 const RadialBlurShader = { uniforms: { tDiffuse: { value: null }, uResolution: { value: new THREE.Vector2(container.clientWidth, container.clientHeight) }, uRadius: { value: 0.95 }, // 仅最外圈模糊,大部分区域保持清晰 uMaxBlur: { value: 6.0 } // 边缘最大模糊 texel 数 }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform sampler2D tDiffuse; uniform vec2 uResolution; uniform float uRadius; uniform float uMaxBlur; varying vec2 vUv; void main() { vec2 uv = vUv; // 归一化距离中心 (0,0),最外圈是 1.0 float dist = length(uv - vec2(0.5, 0.5)) * 2.0; // 模糊因子:在 uRadius 内为 0,之外平滑过渡到 1 float blurFactor = smoothstep(uRadius, uRadius + 0.35, dist); if (blurFactor < 0.01) { gl_FragColor = texture2D(tDiffuse, uv); } else { // 9x9 box blur,半径随 blurFactor 增加 float step = blurFactor * uMaxBlur; vec4 color = vec4(0.0); float total = 0.0; for (int x = -4; x <= 4; x++) { for (int y = -4; y <= 4; y++) { vec2 offset = vec2(float(x), float(y)) * step / uResolution; color += texture2D(tDiffuse, uv + offset); total += 1.0; } } gl_FragColor = color / total; } } ` }; const composer = new EffectComposer(renderer); composer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); composer.setSize(container.clientWidth, container.clientHeight); // RenderTarget 配置:RGBA8 + depth + 不透明(匹配 WebGL 默认) composer.renderTarget1.texture.colorSpace = THREE.SRGBColorSpace; composer.renderTarget2.texture.colorSpace = THREE.SRGBColorSpace; const renderPass = new RenderPass(scene, camera); renderPass.clear = true; composer.addPass(renderPass); const radialBlurPass = new ShaderPass(RadialBlurShader); composer.addPass(radialBlurPass); // 保存到 ref 供 resize 用 (composer as any).userData = { radialBlurPass }; composerRef.current = composer; // CSS2D 标签渲染器 —— 全局单例 const labelRenderer = getGlobalCSS2DRenderer(); labelRenderer.setSize(container.clientWidth, container.clientHeight); const labelDom = labelRenderer.domElement; if (labelDom.parentNode !== container) { if (labelDom.parentNode) labelDom.parentNode.removeChild(labelDom); container.appendChild(labelDom); } labelRendererRef.current = labelRenderer; const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.enablePan = true; controls.enableRotate = true; controls.enableZoom = true; controls.target.set(-20, -25, 20); // 往上抬 target,让顶部露出更多蓝天 controls.update(); controls.minDistance = 30; controls.maxDistance = 150; controls.maxPolarAngle = Math.PI / 2 - 0.1; controls.minPolarAngle = 0; controls.autoRotate = true; controls.autoRotateSpeed = 0.4; controlsRef.current = controls; // TransformControls 拖拽 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 }; // 使用对象引用避免闭包问题 // ===== 场景初始化:灯光/网格/天空球/地图纹理 ===== // 每次 init setup 都是全新 scene,直接加 // 灯光 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: 0xa5c8ec, // 更浅的灰蓝 transparent: true, opacity: 0.025, // 更淡,几乎看不出来 side: THREE.DoubleSide, depthWrite: false, }); const ring = new THREE.Mesh(ringGeo, ringMat); ring.rotation.x = -Math.PI / 2; ring.position.y = -0.2; scene.add(ring); // === CanvasTexture 渐变蓝天背景(ITYUNWEI 原版) === const cvs = document.createElement('canvas'); cvs.width = 2; cvs.height = 512; const ctx = cvs.getContext('2d')!; const skyGrad = ctx.createLinearGradient(0, 0, 0, 512); skyGrad.addColorStop(0.00, '#1e4a7a'); skyGrad.addColorStop(0.30, '#3d7ab8'); skyGrad.addColorStop(0.65, '#6fa8dc'); skyGrad.addColorStop(0.88, '#cfe2f3'); skyGrad.addColorStop(1.00, '#e8e8d8'); ctx.fillStyle = skyGrad; ctx.fillRect(0, 0, 2, 512); const bgTex = new THREE.CanvasTexture(cvs); bgTex.colorSpace = THREE.SRGBColorSpace; scene.background = bgTex; scene.fog = new THREE.Fog(0xcfe2f3, 120, 280); // SkyDome - Shader 分段渐变穹顶(ITYUNWEI 原版色系) const skyGeo = new THREE.SphereGeometry(300, 48, 32); skyGeo.scale(-1, 1, 1); const skyMat = new THREE.ShaderMaterial({ uniforms: { uTop: { value: new THREE.Color('#1e4a7a') }, uMid: { value: new THREE.Color('#3d7ab8') }, uSky: { value: new THREE.Color('#6fa8dc') }, uHorizon: { value: new THREE.Color('#cfe2f3') }, uBottom: { value: new THREE.Color('#e8e8d8') }, }, 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.7) col = mix(uMid, uTop, smoothstep(0.7, 1.0, h)); // 70% 以上 → 深蓝天顶(窄带) else if(h > 0.35) col = mix(uSky, uMid, smoothstep(0.35, 0.7, h)); // 35%-70% → 亮蓝天空 else if(h > 0.08) col = mix(uHorizon, uSky, smoothstep(0.08, 0.35, h)); // 8%-35% → 蓝转云白 else if(h > -0.04) col = mix(uBottom, uHorizon, smoothstep(-0.04, 0.08, h)); // 地平线 ±4% → 厚云层 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'); } ); // 设备/连线创建已移到下方独立的 useEffect(依赖 [devices, links]) // 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(nodesRef.current, 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)) { const newPos = intersection.add(dragOffset); // 只更新 x/z,保留滚轮升降设置的 y draggingNode.position.x = newPos.x; draggingNode.position.z = newPos.z; draggingNode.position.y = Math.max(MIN_Y, Math.min(MAX_Y, draggingNode.position.y)); } }; // 拖拽结束:立即持久化到 positions Map 和 localStorage const commitDevicePosition = (node: THREE.Group) => { const devId = node.userData.deviceId as string; const p = node.position; const clampedY = Math.max(MIN_Y, Math.min(MAX_Y, p.y)); positionsRef.current[devId] = new THREE.Vector3(p.x, clampedY, p.z); node.position.y = clampedY; deviceBaseYRef.current.set(devId, clampedY); // 同步写入 localStorage(不需要等组件卸载) try { const saved = localStorage.getItem('monitoring-topology-positions'); const data: Record = saved ? JSON.parse(saved) : {}; data[devId] = { x: p.x, y: clampedY, z: p.z }; localStorage.setItem('monitoring-topology-positions', JSON.stringify(data)); // Electron 环境同步给主进程,让远程浏览器也能读到 const nc = (window as any).netcatty; if (nc?.inspection?.saveTopologyPositions) { nc.inspection.saveTopologyPositions(data).catch(() => {}); } } catch { /* ignore */ } }; const onPointerUp = (event: MouseEvent) => { if (isDraggingDevice && draggingNode) { console.log('[拖拽] 结束拖拽,新位置:', draggingNode.position.x, draggingNode.position.y, draggingNode.position.z); commitDevicePosition(draggingNode); isDraggingDevice = false; draggingNode = null; controls.enabled = true; controls.autoRotate = true; setIsDragging(false); } }; // 拖拽设备期间:滚轮升降高度 const onWheelDuringDrag = (event: WheelEvent) => { if (!isDraggingDevice || !draggingNode) return; event.preventDefault(); const delta = event.deltaY > 0 ? -0.5 : 0.5; // 滚轮向上抬,往下降 draggingNode.position.y = Math.max(MIN_Y, Math.min(MAX_Y, draggingNode.position.y + delta)); }; 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(nodesRef.current, 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; setDeleteLinkConfirm(null); 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(nodesRef.current, 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 nodeHits = raycaster.intersectObjects(nodesRef.current, true); if (nodeHits.length > 0) { const obj = nodeHits[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 }); return; } } // 右键命中连线 → 删除提示 const edgeHits = raycaster.intersectObjects(edgesRef.current, false); if (edgeHits.length > 0) { const line = edgeHits[0].object as THREE.Line; const sourceId = line.userData.sourceId as string; const targetId = line.userData.targetId as string; const isCustom = customLinks.some( (l) => l.source === sourceId && l.target === targetId ); setDeleteLinkConfirm({ x: event.clientX, y: event.clientY, sourceId, targetId, isCustom, }); } else { setContextMenu(null); } }; renderer.domElement.addEventListener('contextmenu', onContextMenu); // OrbitControls 内部用 pointerdown(不是 mousedown),在 target 阶段注册。 // 我们必须用 pointerdown + capture 阶段先拦截,命中设备时先禁用 controls.enabled, // OrbitControls 的 onPointerDown 第一行 if (this.enabled === false) return 就会直接跳过, // 不会调 setPointerCapture 也不会注册后续 pointermove/pointerup,地图就不会跟着转。 renderer.domElement.addEventListener('pointerdown', onPointerDown, { capture: true }); window.addEventListener('mousemove', onPointerMove); window.addEventListener('mouseup', onPointerUp); window.addEventListener('wheel', onWheelDuringDrag, { passive: false }); 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); // Line2 的 material 需要更新 resolution edgesRef.current.forEach((line) => { (line.material as LineMaterial).resolution.set(w, h); }); if (composerRef.current) { composerRef.current.setSize(w, h); const rbPass = (composerRef.current as any).userData?.radialBlurPass; if (rbPass) { rbPass.uniforms.uResolution.value.set(w, h); } } labelRenderer.setSize(w, h); chartsRef.current.forEach((c) => c.resize()); }; const resizeObs = new ResizeObserver(onResize); resizeObs.observe(container); // 动画循环 const timer = new THREE.Timer(); const animate = () => { animIdRef.current = requestAnimationFrame(animate); timer.update(); // === 相机平滑过渡动画(如果有)=== const anim = cameraAnimRef.current; if (anim) { // 过渡期间禁用 OrbitControls,避免 damping / autoRotate 和 lerp 抢 position controls.enabled = false; const now = performance.now(); let progress = Math.min(1, (now - anim.startMs) / anim.durationMs); const eased = easeInOutCubic(progress); // === 相机位置:绕 target 做球面插值(弧线),避免直线穿过 target === const fromTarget = anim.fromTarget; const toTarget = anim.toTarget; // 先插值 target(target 本身变化用直线 OK) const curTarget = new THREE.Vector3().lerpVectors(fromTarget, toTarget, eased); controls.target.copy(curTarget); // 计算相机相对 target 的方向(fromDir → toDir 的球面插值) const fromDir = new THREE.Vector3().subVectors(anim.fromPos, fromTarget).normalize(); const toDir = new THREE.Vector3().subVectors(anim.toPos, toTarget).normalize(); const fromDist = anim.fromPos.distanceTo(fromTarget); const toDist = anim.toPos.distanceTo(toTarget); const curDist = fromDist + (toDist - fromDist) * eased; // 用四元数球面插值方向(避免直线穿过 target 的奇怪透视) const fromQ = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, -1), fromDir); const toQ = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, -1), toDir); const curQ = fromQ.clone().slerp(toQ, eased); // 兼容写法 const curDir = new THREE.Vector3(0, 0, -1).applyQuaternion(curQ); camera.position.copy(curTarget).addScaledVector(curDir, curDist); // FOV 还是直线插值 camera.fov = anim.fromFov + (anim.toFov - anim.fromFov) * eased; camera.updateProjectionMatrix(); if (progress >= 1) { camera.position.copy(anim.toPos); controls.target.copy(anim.toTarget); camera.fov = anim.toFov; cameraAnimRef.current = null; controls.enabled = true; // 过渡结束恢复 anim.onComplete?.(); } else { // 过渡中:跳过所有后续 controls.update / 连线 / 节点动画帧,只保留过渡 // 但节点呼吸灯和连线 dashOffset 也会停——算了,用户要的是"轨迹平滑" // 我们只跳过 controls.update 就行,其他轻量动画继续 } } else { // 正常模式:确保 controls enabled if (!controls.enabled) controls.enabled = true; } const t = Date.now() * 0.001; const elapsed = timer.getElapsed(); // Critical 节点呼吸灯 + 选中节点高亮 + 运行时状态同步 nodesRef.current.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.style.color = s.text; 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) 轻微浮动 — 用 deviceBaseYRef 里的持久 baseY,确保不被覆盖 if (!(isDraggingDevice && draggingNode === n)) { const baseY = deviceBaseYRef.current.get(n.userData.deviceId as string) ?? 0; n.position.y = baseY + Math.sin(elapsed * 0.5 + i) * 0.15; } }); // 虚线流动 + 连线端点跟随设备位置(Line2 专用 setPositions API) const nodeGroupMap = nodeGroupMapRef.current; edgesRef.current.forEach((line, i) => { const sourceId = line.userData.sourceId; const targetId = line.userData.targetId; // 用 Map.get 替代 Array.find,O(1) 查找 const sourceNode = nodeGroupMap.get(sourceId); const targetNode = nodeGroupMap.get(targetId); if (sourceNode && targetNode) { const hashKey = line.userData.arcMidHash; const { mid } = computeArcMidpoint(sourceNode.position, targetNode.position, hashKey); // 复用预分配 Float32Array,避免每帧每条连线 new 数组 const pos = line.userData.posArray as Float32Array; pos[0] = sourceNode.position.x; pos[1] = sourceNode.position.y; pos[2] = sourceNode.position.z; pos[3] = mid.x; pos[4] = mid.y; pos[5] = mid.z; pos[6] = targetNode.position.x; pos[7] = targetNode.position.y; pos[8] = targetNode.position.z; (line.geometry as LineGeometry).setPositions(pos); line.computeLineDistances(); } const mat = line.material as LineMaterial; mat.dashOffset = -((t * 2.5 + i * 0.05) % (mat.dashSize + mat.gapSize)); // 离线连线:至少一端 offline → 显示叉叉 + 连线变灰 const breakMark = linkBreakRef.current[i]; if (breakMark) { const srcOffline = sourceNode?.userData.currentStatus === 'offline'; const tgtOffline = targetNode?.userData.currentStatus === 'offline'; const disconnected = srcOffline || tgtOffline; breakMark.visible = disconnected; if (disconnected && sourceNode && targetNode) { const hashKey = line.userData.arcMidHash; const { mid } = computeArcMidpoint(sourceNode.position, targetNode.position, hashKey); breakMark.position.set(mid.x, mid.y + 1.5, mid.z); breakMark.quaternion.copy(camera.quaternion); } // 离线连线颜色降饱和 + 更暗 if (disconnected) { mat.color.setHex(0x556677); mat.opacity = 0.55; } else { mat.color.setHex(edgeColorMap[line.userData.linkType] || 0x2196f3); mat.opacity = 1.0; } } }); 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('pointerdown', onPointerDown, { capture: true }); window.removeEventListener('mousemove', onPointerMove); window.removeEventListener('mouseup', onPointerUp); window.removeEventListener('wheel', onWheelDuringDrag); renderer.domElement.removeEventListener('click', onPointerClick); renderer.domElement.removeEventListener('click', onBackgroundClick); // 保存设备位置到 localStorage try { const posData: Record = {}; nodesRef.current.forEach((n) => { const id = n.userData.deviceId; posData[id] = { x: n.position.x, y: Math.max(-0.5, Math.min(20, n.position.y)), // 保存时也 clamp z: n.position.z, }; }); localStorage.setItem('monitoring-topology-positions', JSON.stringify(posData)); } catch (e) { console.warn('[Monitoring] Failed to save positions:', e); } nodesRef.current.forEach((g) => { g.traverse((obj) => { if ((obj as THREE.Mesh).isMesh || (obj as THREE.Line).isLine) { (obj as any).geometry?.dispose?.(); const mat = (obj as any).material; if (Array.isArray(mat)) mat.forEach((m) => disposeMatTextures(m)); else disposeMatTextures(mat); } }); }); edgesRef.current.forEach((e) => { e.geometry?.dispose(); disposeMatTextures((e as any).material); }); labelsRef.current.forEach((l) => l.remove()); labelsRef.current = []; if (composerRef.current) { composerRef.current.dispose(); composerRef.current = null; } // 全局单例 renderer —— 只 detach DOM,不 dispose WebGL context 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 }, []); // 只在 mount/unmount 时跑,renderer 全局唯一 // ===== 设备/连线创建:每次 devices/links 变化重建 scene 内容,不重建 renderer ===== useEffect(() => { const scene = sceneRef.current; if (!scene) return; // 守卫:设备 ID 集合 + 连线都没变,且 scene 里真的还有设备节点 → 跳过重建 // 注:scene 可能被 cleanup 清空过(nodesRef 变成空数组),这时即使 devices/links 没变也要重建 const sceneNeedsRebuild = nodesRef.current.length === 0; const existingIds = new Set(nodesRef.current.map((n) => String(n.userData.deviceId))); const newIds = new Set(devices.map((d) => String(d.id))); const sameDevices = !sceneNeedsRebuild && existingIds.size === newIds.size && [...newIds].every((id) => existingIds.has(id)); const edgeKey = (src: string, tgt: string) => (src < tgt ? `${src}-${tgt}` : `${tgt}-${src}`); const existingEdgeKeys = new Set(edgesRef.current.map((e) => edgeKey(e.userData.sourceId, e.userData.targetId))); const newEdgeKeys = new Set(links.map((l) => edgeKey(l.source, l.target))); const sameEdges = existingEdgeKeys.size === newEdgeKeys.size && [...newEdgeKeys].every((k) => existingEdgeKeys.has(k)); if (sameDevices && sameEdges) { // 只更新 Map 引用,让 animate 循环用最新的 device 状态 deviceMapRef.current = new Map(devices.map((d) => [d.id, d])); nodeGroupMapRef.current = new Map(nodesRef.current.map((n) => [String(n.userData.deviceId), n])); return; } // 清理旧的 nodes/edges/labels(注意:必须 dispose 材质上的贴图,否则每次 rebuild 泄漏 GPU buffer) nodesRef.current.forEach((g) => { scene.remove(g); g.traverse((obj) => { if ((obj as THREE.Mesh).isMesh || (obj as THREE.Line).isLine) { (obj as any).geometry?.dispose?.(); const mat = (obj as any).material; if (Array.isArray(mat)) mat.forEach((m) => disposeMatTextures(m)); else disposeMatTextures(mat); } }); }); edgesRef.current.forEach((e) => { scene.remove(e); e.geometry?.dispose(); disposeMatTextures((e as any).material); }); linkBreakRef.current.forEach((b) => { scene.remove(b); (b as any).geometry?.dispose?.(); disposeMatTextures((b as any).material); }); labelsRef.current.forEach((l) => l.remove()); nodesRef.current = []; edgesRef.current = []; linkBreakRef.current = []; labelsRef.current = []; deviceBaseYRef.current.clear(); if (devices.length === 0) return; // === 设备布局(分层集群)=== deviceMapRef.current = new Map(devices.map((d) => [d.id, d])); const positions: Record = {}; const savedPositions = (() => { const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__; if (IS_REMOTE) { try { const token = (window as any).__REMOTE_TOKEN__ || ""; const xhr = new XMLHttpRequest(); xhr.open("GET", `/api/topology-positions?token=${encodeURIComponent(token)}`, false); xhr.send(); if (xhr.status === 200) { const json = JSON.parse(xhr.responseText); if (json?.ok && json.positions && Object.keys(json.positions).length > 0) { return json.positions; } } } catch { /* ignore */ } } // 本地 Electron:从 localStorage 读,找不到则从主进程 inspection-data.json 读 try { let saved = localStorage.getItem('monitoring-topology-positions'); if (saved) return JSON.parse(saved); // portable 版 localStorage 为空 → fallback 主进程 JSON(sendSync 同步) try { const nc = (window as any).netcatty; const fromMain = nc?.inspection?.getTopologyPositionsSync?.(); if (fromMain && typeof fromMain === 'object' && Object.keys(fromMain).length > 0) { console.info('[Monitoring] localStorage 无位置,从主进程 inspection-data.json 加载'); return fromMain; } } catch { /* ignore */ } return 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]); const MIN_Y = -0.5; const MAX_Y = 20; if (savedPositions) { devices.forEach((d) => { if (savedPositions[d.id]) { positions[d.id] = new THREE.Vector3( savedPositions[d.id].x, Math.max(MIN_Y, Math.min(MAX_Y, savedPositions[d.id].y ?? 0)), 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 ); }); } positionsRef.current = positions; // Electron 环境:启动时自动把 localStorage 里的拓扑位置同步给主进程,供远程浏览器读取 { const IS_ELECTRON = typeof window !== "undefined" && !!(window as any).netcatty; if (IS_ELECTRON) { try { const saved = localStorage.getItem('monitoring-topology-positions'); if (saved) { const nc = (window as any).netcatty; nc.inspection?.saveTopologyPositions?.(JSON.parse(saved)).catch(() => {}); } } catch { /* ignore */ } } } // 创建设备节点 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); deviceBaseYRef.current.set(device.id, pos.y); 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); 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: labelStyle.text, 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); group.add(labelObj); group.userData.labelDiv = labelDiv; labelsRef.current.push(labelDiv); } nodesRef.current = nodes; // 创建链路 const edges: Line2[] = []; const breaks: THREE.Group[] = []; for (const link of links) { const a = positions[link.source]; const b = positions[link.target]; if (!a || !b) continue; const { mid } = computeArcMidpoint(a, b, link.source + '_' + link.target); const color = edgeColorMap[link.type] || 0x2196f3; const geo = new LineGeometry(); geo.setPositions([a.x, a.y, a.z, mid.x, mid.y, mid.z, b.x, b.y, b.z]); const mat = new LineMaterial({ color, transparent: true, opacity: 1.0, linewidth: 3, worldUnits: false, vertexColors: false, dashed: true, dashSize: 4, gapSize: 2.5, resolution: new THREE.Vector2(window.innerWidth, window.innerHeight), depthTest: false, }); const line = new Line2(geo, mat); line.computeLineDistances(); line.renderOrder = 2; // 预分配 Float32Array,animate 循环里复用避免每帧 GC const posArray = new Float32Array(9); line.userData = { sourceId: link.source, targetId: link.target, linkType: link.type, arcMidHash: link.source + '_' + link.target, posArray }; scene.add(line); edges.push(line); const breakX = createBreakMark(); breakX.position.set(mid.x, mid.y + 1.5, mid.z); breakX.visible = false; scene.add(breakX); breaks.push(breakX); } edgesRef.current = edges; linkBreakRef.current = breaks; // 同步更新 deviceId → Group 查找表(给 animate 循环 O(1) 查找用) nodeGroupMapRef.current = new Map(nodes.map((n) => [n.userData.deviceId as string, n])); }, [devices, links]); // 设备状态实时同步:每次 devices 变化更新 ref,动画循环读取最新 status useEffect(() => { deviceMapRef.current = new Map(devices.map((d) => [d.id, d])); }, [devices]); // ===== ECharts 初始化(页面首次加载 + 退出大屏后都要 init 一次) ===== // 大屏模式下 aside 被条件隐藏卸载,退出时 DOM 重建 → ref 拿到新节点 → 必须重新 init useEffect(() => { // 大屏模式下 aside 不存在,跳过 if (isTopologyOnly) { chartsRef.current.forEach((c) => c.dispose()); chartsRef.current = []; return; } const timer = setTimeout(() => { chartsRef.current.forEach((c) => c.dispose()); chartsRef.current = []; 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 typeData = [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: 10 } }, xAxis: { type: 'category', data: names, axisLine: { lineStyle: { color: 'rgba(255,255,255,0.25)' } }, axisTick: { show: false }, axisLabel: { color: '#FFFFFF', fontSize: 8, interval: 0, rotate: 20 } }, yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#FFFFFF', fontSize: 10 }, minInterval: 1 }, series: [{ type: 'bar', data: typeData, barWidth: '45%', label: { show: true, position: 'top', color: '#38bdf8', fontSize: 9, 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: 10 } }, xAxis: { type: 'category', data: trend.map((t) => t.date), axisLine: { lineStyle: { color: 'rgba(255,255,255,0.25)' } }, axisTick: { show: false }, axisLabel: { color: '#FFFFFF', fontSize: 8 } }, yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#FFFFFF', fontSize: 10 }, 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); setTimeout(resizeAll, 600); }, 80); return () => { clearTimeout(timer); chartsRef.current.forEach((c) => c.dispose()); chartsRef.current = []; }; }, [isTopologyOnly]); // 首次加载 + 退出大屏时都重新 init // ===== 数据更新:不 dispose 图表,只用 setOption 更新 data(无闪动)===== useEffect(() => { const [typeChart, trendChart] = chartsRef.current; if (!typeChart || !trendChart) return; const names = ['服务器', '交换机', '路由器', '防火墙', '存储', '工作站']; const typeData = [typeDistribution.server, typeDistribution.switch, typeDistribution.router, typeDistribution.firewall, typeDistribution.storage, typeDistribution.workstation]; // notMerge=false → 只合并改动部分,保留其他配置 typeChart.setOption({ xAxis: { data: names }, series: [{ data: typeData }] }, { notMerge: false }); const trend = monitoring.alarmTrend; trendChart.setOption({ xAxis: { data: trend.map((t) => t.date) }, series: [{ data: trend.map((t) => t.count) }], }, { notMerge: false }); }, [typeDistribution, monitoring.alarmTrend]); const resetView = useCallback(() => { if (cameraRef.current && controlsRef.current) { cameraRef.current.position.set(0, 80, 125); controlsRef.current.target.set(-20, -40, 20); 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 风格) */}
{/* 3D 拓扑底图 — 覆盖整个页面 */}
{/* 顶部左 — 设备状态统计 */} {!isTopologyOnly && (
正常 {stats.normal}
预警 {stats.warning}
故障 {stats.critical}
离线 {stats.offline}
{stats.total} · {stats.linkCount}
)} {/* 顶部中 — 系统标题 */} {!isTopologyOnly && (

{monitoringTitle}

)} {/* 顶部右 — 告警/时间/按钮 */} {!isTopologyOnly && (
{alarmCounts.total > 0 && (
openAlertList('告警', 'all')} > ⚠ {alarmCounts.total} 告警
)}
{lastDataUpdate ? formatRelativeTime(lastDataUpdate) : '暂无数据'}
)} {/* 左栏 — 向左侧上方滑出 */} {!isTopologyOnly && ( )} {/* 中间主视区 — 操作浮层 */}
{/* loading 遮罩已移到页面外,3D 底图在最外层 */} {/* Canvas container 已移到根 div,这里不再单独渲染 */} {loading && (
加载 3D 拓扑全景中...
)} {/* 图例 */} {legend && (
图例
{[ { color: 'bg-[#00FF66]', label: '正常' }, { color: 'bg-amber-500', label: '警告' }, { color: 'bg-red-500', label: '严重' }, { color: 'bg-slate-500', label: '离线' }, ].map((it) => (
{it.label}
))}
)} {/* 连接线图例 — 右上侧,与左侧图例样式一致,大屏模式保留 */} {showConnectionLegend && (
连接类型
{[ { label: 'WAN/上行', color: '#2196f3' }, { label: 'L3 路由', color: '#4caf50' }, { label: 'L2 交换', color: '#00bcd4' }, { label: '存储', color: '#ffa500' }, { label: '接入', color: '#9c27b0' }, { label: '断开', isBreak: true }, ].map((it) => (
{it.isBreak ? ( ) : ( )} {it.label}
))}
)} {/* 底部控制栏 — 向下滑出 */} {!isTopologyOnly && (
{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}
))}
)}
)} {/* 右下角提示 — 向下滑出 */} {!isTopologyOnly && (
🖱️ 滚轮缩放 · 拖拽旋转 · 点击设备查看详情
)} {/* 大屏模式 — 浮动退出按钮 */} {isTopologyOnly && ( )}
{/* 右栏 — 向右侧下方滑出 */} {!isTopologyOnly && ( )}
{/* 内层 grid 结束 */} {/* 设备详情弹窗 */} {/* 连线类型选择对话框 */} {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: '#00FF66' }, { type: 'storage', label: '存储', color: '#FACC15' }, { type: 'vpn', label: 'VPN', color: '#ec4899' }, { type: 'wireless', label: '无线', color: '#14b8a6' }, { type: 'custom', label: '自定义', color: '#6b7280' }, ].map(({ type, label, color }) => ( ))}
)} {/* 右键菜单 */} {contextMenu && (
e.stopPropagation()} >
)} {/* 连线删除确认 */} {deleteLinkConfirm && (
e.stopPropagation()} >
删除连线
确定删除这条连线? {!deleteLinkConfirm.isCustom && (
(系统自动生成的连线,删除后将不再自动重建)
)}
)} {/* 居中设备详情小卡片 */} {detailPanel.open && detailPanel.type === 'deviceDetail' && panelDeviceDetail && (() => { const s = panelDeviceDetail.status; const si = s === 'offline' ? { label: '离线', dot: '#64748b', bg: 'rgba(100,116,139,0.2)', fg: '#94a3b8', icon: '✕' } : s === 'critical' ? { label: '严重', dot: '#FF4D4F', bg: 'rgba(239,68,68,0.15)', fg: '#f87171', icon: '●' } : s === 'warning' ? { label: '警告', dot: '#FACC15', bg: 'rgba(245,158,11,0.15)', fg: '#fbbf24', icon: '!' } : { label: '正常', dot: '#00FF66', bg: 'rgba(0,255,102,0.18)', fg: '#00FF66', icon: '✓' }; const cpu = panelDeviceDetail.cpu; const cpuC = cpu != null && cpu > monitoring.thresholds.cpuCritical ? '#FF4D4F' : cpu != null && cpu > monitoring.thresholds.cpuWarning ? '#FACC15' : '#00FF66'; const mem = panelDeviceDetail.memory; const memC = mem != null && mem > monitoring.thresholds.memCritical ? '#FF4D4F' : mem != null && mem > monitoring.thresholds.memWarning ? '#FACC15' : '#00FF66'; return (
e.stopPropagation()}>
{si.icon}
{panelDeviceDetail.name} {si.label}
{panelDeviceDetail.type} {panelDeviceDetail.ip || '—'}
{[{ l: 'CPU', v: cpu, c: cpuC, i: '🖥' }, { l: '内存', v: mem, c: memC, i: '💾' }].map((m) => (
{m.i} {m.l}
{m.v ?? '—'} %
))}
{panelDeviceDetail.alerts && panelDeviceDetail.alerts.length > 0 && (
最近告警 {panelDeviceDetail.alerts.length}
{panelDeviceDetail.alerts.slice(0, 5).map((a) => { const dot = a.level === 'critical' ? '#FF4D4F' : a.level === 'warning' ? '#FACC15' : '#06b6d4'; return (
{a.message}
{a.time}
); })}
)}
更新:{(() => { const t = panelDeviceDetail.lastUpdate; if (!t) return '—'; const d = Date.now() - t; if (d < 60000) return '刚刚'; if (d < 3600000) return `${Math.floor(d / 60000)} 分钟前`; return `${Math.floor(d / 3600000)} 小时前`; })()}
); })()} {detailPanel.type !== 'deviceDetail' && ( openDeviceDetail(deviceId)} thresholds={monitoring.thresholds} /> )}
); } export default MonitoringTopologyView;