Files
SenMeshNet/components/monitoring/MonitoringTopologyView.tsx
zhaolei 16dd04f746
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
Star History / update (push) Has been cancelled
fix: 拓扑位置 fallback 空对象判断 + Electron 默认页 + asar 正确打包
根因 1:monitoring-topology-positions 在 portable Local Storage 里是空对象 {}
  - {} 是 truthy,跳过 fallback
  - L2335 if (savedPositions) 也是 truthy,跳过 else 大圆圈
  - 但 savedPositions[d.id] 全 undefined → 所有设备落到 rest.forEach → 大圆圈

根因 2:手动 asar pack ./dist 只打了 dist 没打 package.json/electron → Electron 默认页

修复:
- L2311-2315: 空对象也跳过,继续 fallback 主进程 JSON
- L2339: 渲染前加 length>0 防御
- 清理 default_app.asar
- 用 electron-builder 正确打包(asar 包含 package.json + electron/ + dist/ + node_modules)
2026-10-21 17:49:01 +08:00

3813 lines
162 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 运行监控 - 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 }) => (
<div
className="flex items-center gap-2 mb-2 px-2 py-1.5 rounded-sm"
style={{
background: 'linear-gradient(90deg, rgba(58,123,213,0.35) 0%, rgba(58,123,213,0.15) 60%, transparent 100%)',
borderLeft: `2px solid ${iconColor}`,
boxShadow: 'inset 0 0 12px rgba(58,123,213,0.15)',
}}
>
<div
className="w-5 h-5 rounded-sm flex items-center justify-center flex-shrink-0"
style={{
background: iconBg,
border: `1px solid ${iconColor}88`,
boxShadow: `0 0 8px ${iconColor}55`,
}}
>
<Icon className="w-3 h-3" style={{ color: iconColor }} />
</div>
<span className="text-[13px] font-semibold text-white flex-1 tracking-wide">{title}</span>
{extra && <div className="flex-shrink-0">{extra}</div>}
<div className="flex items-center gap-0 flex-shrink-0" style={{ color: iconColor }}>
<span style={{ opacity: 0.35, fontSize: 10 }}>›</span>
<span style={{ opacity: 0.6, fontSize: 11 }}>›</span>
<span style={{ opacity: 0.9, fontSize: 12 }}>›</span>
</div>
</div>
);
// 设备类型 → 图标组件
const deviceTypeIconMap: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
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<string, string> = {
server: '服务器',
switch: '交换机',
router: '路由器',
firewall: '防火墙',
storage: '存储',
workstation: '工作站',
h3c: '交换机',
huawei: '交换机',
};
// 状态颜色
const statusColors: Record<string, number> = {
normal: 0x86efac, // 浅绿色 tailwind green-300
warning: 0xffe600,
critical: 0xff4d4f,
offline: 0xcbd5e1,
};
const statusHex: Record<string, string> = {
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<string, number> = {
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<string, DeviceType> = {
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<HTMLDivElement | null>(null);
const chartTypeRef = useRef<HTMLDivElement | null>(null);
const chartTrendRef = useRef<HTMLDivElement | null>(null);
const [selectedDevice, setSelectedDevice] = useState<any>(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<string>(() => {
// 先给默认值,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<any> =>
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<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const composerRef = useRef<EffectComposer | null>(null);
const labelRendererRef = useRef<CSS2DRenderer | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const nodesRef = useRef<THREE.Group[]>([]);
const edgesRef = useRef<Line2[]>([]);
const linkBreakRef = useRef<THREE.Group[]>([]); // 离线连线的"断开"标记叉叉
const labelsRef = useRef<HTMLDivElement[]>([]);
const deviceMapRef = useRef<Map<string, any>>(new Map());
// deviceId → THREE.Group 的快速查找表(替代 animate 里的 Array.find,O(1))
const nodeGroupMapRef = useRef<Map<string, THREE.Group>>(new Map());
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
const pointerRef = useRef<THREE.Vector2>(new THREE.Vector2());
const animIdRef = useRef<number>(0);
// 每个设备的持久 baseY(不受 animate 浮动或拖拽影响的真值)
const deviceBaseYRef = useRef<Map<string, number>>(new Map());
const positionsRef = useRef<Record<string, THREE.Vector3>>({});
const chartsRef = useRef<echarts.ECharts[]>([]);
const transformControlsRef = useRef<any>(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<CameraAnim | null>(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<boolean>(() => {
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<AudioBuffer | null>(null);
// 常驻 AudioContext(resume 一次即可,避免每次 new 都可能是 suspended 状态)
const _persistentCtxRef = useRef<AudioContext | null>(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<AudioBuffer> {
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<AudioBuffer>((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<typeof setTimeout> | 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<Link[]>(() => {
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<Link[]>(() => {
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<string, string>();
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<string, number> = {
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<string, { x: number; y: number; z: number }> = 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<string, { x: number; y: number; z: number }> = {};
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<string, THREE.Vector3> = {};
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) {
const parsed = JSON.parse(saved);
// 空对象 {} 等于没有,继续 fallback
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) return parsed;
}
// 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 && Object.keys(savedPositions).length > 0) {
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<number>(() => {
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 风格) */}
<style>{`
.monitoring-page {
font-size: 17px;
--m-bg-dark: #164172;
--m-bg-card: #1E5B96;
--m-bg-panel: #2B73B8;
--m-bg-hover: #3d4f66;
--m-border: rgba(125,211,252,0.35);
--m-border-accent: #5AB0FF;
--m-text-primary: #FFFFFF;
--m-text-secondary: #E0F2FE;
--m-text-muted: #BAE6FD;
--m-primary: #38BDF8;
--m-primary-light: #7DD3FC;
--m-accent: #22d3ee;
--m-success: #00FF66;
--m-warning: #FACC15;
--m-danger: #FF4D4F;
--m-offline: #CBD5E1;
}
.monitoring-page .text-slate-300 { color: #FFFFFF !important; }
.monitoring-page .text-slate-400 { color: #FFFFFF !important; }
.monitoring-page .text-slate-500 { color: #E0F2FE !important; }
.monitoring-page .text-muted-foreground { color: #FFFFFF !important; }
.monitoring-page .text-foreground { color: #FFFFFF !important; }
.monitoring-page .border-border\\/60 { border-color: var(--m-border) !important; }
.monitoring-page .bg-muted { background: rgba(148,163,184,0.15) !important; }
.monitoring-page .bg-muted\\/50 { background: rgba(148,163,184,0.08) !important; }
.monitoring-page .hover\:bg-slate-700:hover { background: #334155 !important; }
.monitoring-page .border { border-color: rgba(148,163,184,0.15) !important; }
.monitoring-page { color: #FFFFFF; }
/* 强制覆盖 slate/gray/zinc/stone 灰系文字为纯白 */
.monitoring-page [class*="text-slate-"] { color: #FFFFFF !important; }
.monitoring-page [class*="text-gray-"] { color: #FFFFFF !important; }
.monitoring-page [class*="text-zinc-"] { color: #FFFFFF !important; }
.monitoring-page [class*="text-stone-"] { color: #FFFFFF !important; }
/* 全屏模式:monitoring-page 直接 fixed 撑满整个视口,盖住左侧菜单和顶部 */
body.monitoring-fullscreen-active .monitoring-page {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 9999 !important;
padding: 16px !important;
gap: 12px !important;
margin: 0 !important;
}
body.monitoring-fullscreen-active {
overflow: hidden !important;
}
.three-label {
position: absolute;
pointer-events: none;
z-index: 10;
font-size: 10px;
line-height: 1.1;
padding: 1px 6px;
background: rgba(0,0,0,0.55);
border: 1px solid rgba(148,163,184,0.2);
border-radius: 3px;
color: #e2e8f0;
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
backdrop-filter: blur(3px);
white-space: nowrap;
transform: translate(-50%, -150%);
font-family: Inter, "Microsoft YaHei", sans-serif;
letter-spacing: 0.3px;
font-weight: 500;
transition: opacity 0.2s ease;
user-select: none;
}
/* 连线模式下 canvas 光标 */
.monitoring-page.monitoring-canvas-link-mode {
cursor: crosshair !important;
}
.monitoring-page.monitoring-canvas-link-mode * {
cursor: crosshair !important;
}
`}</style>
<div
className={`relative overflow-hidden monitoring-page ${linkModeUI ? 'monitoring-canvas-link-mode' : ''} ${
isFullscreen
? 'fixed inset-0 z-[9999] p-4'
: 'h-full w-full p-[clamp(6px,1vw,12px)]'
}`}
style={{
background: '#164172',
}}
>
{/* 3D 拓扑底图 — 覆盖整个页面 */}
<div ref={containerRef} className="absolute inset-0 w-full h-full z-0" />
<div
className="relative z-10 grid"
style={{
display: 'grid',
gridTemplateRows: isTopologyOnly ? '1fr' : 'auto 1fr',
gridTemplateColumns: isTopologyOnly
? 'minmax(0, 1fr)'
: isFullscreen
? 'clamp(225px, 18vw, 355px) minmax(0, 1fr) clamp(225px, 18vw, 355px)'
: 'clamp(210px, 16vw, 320px) minmax(0, 1fr) clamp(210px, 16vw, 320px)',
height: '100%',
columnGap: '8px',
rowGap: '8px',
pointerEvents: 'none',
}}
>
{/* 顶部左 — 设备状态统计 */}
{!isTopologyOnly && (
<div
className="z-50 flex items-center justify-center w-full gap-2 px-2 py-1 rounded-md whitespace-nowrap overflow-hidden min-w-0"
style={{
background: 'linear-gradient(90deg, rgba(22,65,114,0.72) 0%, rgba(30,91,150,0.55) 100%)',
borderBottom: '2px solid #5AB0FF',
borderTop: '1px solid rgba(90,176,255,0.5)',
borderLeft: '1px solid rgba(90,176,255,0.5)',
borderRight: '1px solid rgba(90,176,255,0.5)',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
boxShadow: '0 2px 10px rgba(56,189,248,0.15)',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(-120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-[#00FF66]" />
<span className="text-white">正常</span>
<span className="font-bold text-[#00FF66]">{stats.normal}</span>
</div>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
<span className="text-white">预警</span>
<span className="font-bold text-amber-300">{stats.warning}</span>
</div>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
<span className="text-white">故障</span>
<span className="font-bold text-red-400">{stats.critical}</span>
</div>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-slate-400" />
<span className="text-white">离线</span>
<span className="font-bold text-slate-200">{stats.offline}</span>
</div>
<div className="h-3 w-px bg-border flex-shrink-0" />
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<Server className="w-3 h-3 text-cyan-300" />
<span className="text-white">{stats.total} · {stats.linkCount}</span>
</div>
</div>
)}
{/* 顶部中 — 系统标题 */}
{!isTopologyOnly && (
<div
className="z-50 flex items-center justify-center select-none pointer-events-none"
style={{
padding: '2px 12px',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(-120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
<div
className="flex-1 h-px"
style={{
background: 'linear-gradient(to right, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
marginRight: '12px',
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
}}
/>
<div
className="flex items-center"
style={{
padding: '2px 28px',
borderTop: '1px solid #4A9BFF',
borderBottom: '1px solid #4A9BFF',
background: 'linear-gradient(180deg, rgba(58,123,213,0.22) 0%, rgba(58,123,213,0.06) 100%)',
boxShadow: '0 0 16px rgba(74,155,255,0.3), inset 0 0 12px rgba(74,155,255,0.15)',
}}
>
<h1
className="text-3xl font-bold"
style={{
background: 'linear-gradient(180deg, #FFFFFF 0%, #E0F2FE 50%, #7DD3FC 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
filter: 'drop-shadow(0 0 12px rgba(125,211,252,0.7))',
letterSpacing: '0.22em',
whiteSpace: 'nowrap',
}}
>
{monitoringTitle}
</h1>
</div>
<div
className="flex-1 h-px"
style={{
background: 'linear-gradient(to left, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
marginLeft: '12px',
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
}}
/>
</div>
)}
{/* 顶部右 — 告警/时间/按钮 */}
{!isTopologyOnly && (
<div
className="z-50 flex items-center justify-center w-full gap-2 px-2 py-1 rounded-md whitespace-nowrap"
style={{
background: 'linear-gradient(270deg, rgba(22,65,114,0.72) 0%, rgba(30,91,150,0.55) 100%)',
borderBottom: '2px solid #5AB0FF',
borderTop: '1px solid rgba(90,176,255,0.5)',
borderLeft: '1px solid rgba(90,176,255,0.5)',
borderRight: '1px solid rgba(90,176,255,0.5)',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
boxShadow: '0 2px 10px rgba(6,182,212,0.10)',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(-120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
{alarmCounts.total > 0 && (
<div
className="px-2 py-0.5 rounded animate-pulse flex items-center gap-1 text-[11px] font-bold cursor-pointer hover:opacity-80 transition-opacity"
style={{
background: 'linear-gradient(90deg, rgba(255,77,79,0.35) 0%, rgba(255,77,79,0.18) 100%)',
color: '#FF7875',
border: '1.5px solid rgba(255,77,79,0.75)',
boxShadow: '0 0 12px rgba(255,77,79,0.55), inset 0 0 8px rgba(255,77,79,0.25)',
textShadow: '0 0 6px rgba(255,77,79,0.6)',
}}
onClick={() => openAlertList('告警', 'all')}
>
⚠ {alarmCounts.total} 告警
</div>
)}
<div className="flex items-center gap-1 text-[11px] font-mono">
<Clock className="w-3 h-3 text-cyan-400" />
<span>{lastDataUpdate ? formatRelativeTime(lastDataUpdate) : '暂无数据'}</span>
</div>
<button
onClick={() => {
if (linkModeUI) {
setLinkMode(null);
} else {
setLinkMode({ sourceId: '' });
}
}}
className={`p-1 rounded transition-colors ${
linkModeUI
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/40'
: 'hover:bg-slate-700 text-slate-300'
}`}
title={linkModeUI ? '退出连线模式' : '连线模式'}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</button>
<button
onClick={toggleTopologyOnly}
className={`p-1 rounded transition-colors ${
isTopologyOnly
? 'bg-cyan-500/30 text-cyan-300 border border-cyan-400/50'
: 'hover:bg-slate-700 text-slate-300'
}`}
title={isTopologyOnly ? '退出大屏展示' : '3D拓扑大屏展示'}
>
<Network className="w-3.5 h-3.5" />
</button>
<button
onClick={toggleMute}
className={`p-1 rounded transition-colors ${
isMuted
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30'
: 'text-cyan-300 hover:bg-slate-700'
}`}
title={isMuted ? '已静音 — 点击开启告警提示音' : '点击静音告警提示音'}
>
{isMuted ? (
<VolumeX className="w-3 h-3" />
) : (
<Volume2 className="w-3 h-3" />
)}
</button>
<button
onClick={toggleFullscreen}
className="p-1.5 rounded hover:bg-slate-700 transition-colors"
title={isFullscreen ? '退出全屏 (ESC)' : '进入全屏'}
>
{isFullscreen ? (
<Minimize2 className="w-3.5 h-3.5 text-slate-300" />
) : (
<Expand className="w-3.5 h-3.5 text-slate-300" />
)}
</button>
</div>
)}
{/* 左栏 — 向左侧上方滑出 */}
{!isTopologyOnly && (
<aside
className="relative min-w-0 min-h-0 grid w-full h-full gap-2 p-2 overflow-hidden"
style={{
gridTemplateRows: 'minmax(0, auto) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)',
background: 'linear-gradient(to right, #164172 0%, #1E5B96 35%, #3E8BE0 100%)',
borderRight: '1px solid #5AB0FF',
boxShadow: 'inset -25px 0 50px rgba(90,176,255,0.25), inset 0 0 30px rgba(22,65,114,0.4)',
borderRadius: '0 8px 8px 0',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translate(-120%, -20%)' : 'translate(0, 0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
{/* 设备概览 */}
<div className="card rounded-xl border p-2 min-h-0 overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={Server} title="设备运行概览" iconColor="#67e8f9" iconBg="rgba(14,165,233,0.25)" />
<div className="grid grid-cols-2 gap-1.5 text-xs">
{[
{ label: '总数', value: stats.total, icon: Box, bg: 'bg-muted/50', filter: 'all', clickable: true, type: 'device' },
{ label: '正常', value: stats.normal, icon: CheckCircle2, bg: 'bg-[#00FF66]/15', color: 'text-[#00FF66]', iconColor: '#00FF66', filter: 'normal', clickable: true, type: 'device' },
{ label: '链路', value: stats.linkCount, icon: Link2, bg: 'bg-muted/50', clickable: false },
{ label: '严重', value: stats.critical, icon: AlertOctagon, bg: 'bg-red-500/10', color: 'text-red-400', iconColor: '#f87171', filter: 'critical', clickable: true, type: 'device' },
{ label: '离线', value: stats.offline, icon: WifiOff, bg: 'bg-slate-500/10', color: 'text-slate-400', iconColor: '#94a3b8', filter: 'offline', clickable: true, type: 'device' },
{ label: '警告', value: stats.warning, icon: AlertTriangle, bg: 'bg-amber-500/10', color: 'text-amber-400', iconColor: '#fbbf24', filter: 'warning', clickable: true, type: 'device' },
].map((item) => (
<div
key={item.label}
className={`p-2 rounded flex items-center justify-between ${item.bg} ${item.clickable ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
style={{ border: '1px solid rgba(125,211,252,0.08)' }}
onClick={() => {
if (item.clickable && item.filter) {
if ((item as any).type === 'alert') {
openAlertList(item.label + '告警', item.filter);
} else {
openDeviceList(item.label + '设备', item.filter);
}
}
}}
>
<div className="flex items-center gap-1 min-w-0">
<item.icon className="w-2.5 h-2.5 flex-shrink-0" style={{ color: (item as any).iconColor || '#94a3b8' }} />
<span className={`truncate text-[11px] ${item.color || 'text-slate-400'}`}>{item.label}</span>
</div>
<span className={`text-base font-bold tabular-nums ${item.color || 'text-slate-100'}`}>{item.value}</span>
</div>
))}
</div>
</div>
{/* 设备类型分布 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={PieChart} title="设备类型分布" iconColor="#67e8f9" iconBg="rgba(14,165,233,0.25)" />
<div ref={chartTypeRef} className="w-full flex-1 min-w-0 min-h-0 overflow-hidden" />
</div>
{/* CPU 负载 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={Cpu} title="CPU 负载" iconColor="#fbbf24" iconBg="rgba(245,158,11,0.22)" extra={<span className="text-[11px] text-slate-400">{cpuList.length} 台</span>} />
<div className="flex-1 min-h-0 overflow-auto space-y-1.5 pr-1">
{cpuList.length === 0 && (
<div className="text-xs text-center py-2 text-slate-300">暂无巡检数据</div>
)}
{cpuList.map((dev) => {
const DevIcon = getDeviceIcon(dev.type);
const statusDot = dev.cpuUsage > monitoring.thresholds.cpuCritical ? '#FF4D4F' : dev.cpuUsage > monitoring.thresholds.cpuWarning ? '#FACC15' : '#00FF66';
return (
<div
key={dev.id}
className="flex items-center gap-1.5 p-1.5 rounded cursor-pointer hover:bg-cyan-500/10 transition-colors -mx-1.5"
style={{ background: 'rgba(14,165,233,0.05)' }}
onClick={() => openDeviceDetail(dev.id)}
>
<div
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(14,165,233,0.18)', border: '1px solid rgba(103,232,249,0.25)' }}
>
<DevIcon className="w-2.5 h-2.5" style={{ color: '#67e8f9' }} />
</div>
<span className="text-[11px] truncate flex-shrink-0 font-medium" title={dev.name}>{dev.name}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span className="opacity-50">·</span>
<span className="truncate">{dev.ip || ''}</span>
<span className="opacity-40">{formatRelativeTime(dev.lastUpdate)}</span>
</span>
<div className="flex items-center gap-1 ml-auto flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: statusDot, boxShadow: `0 0 6px ${statusDot}` }} />
<span
className="text-xs font-bold font-mono tabular-nums leading-none"
style={{ color: dev.cpuUsage > monitoring.thresholds.cpuCritical ? '#f87171' : dev.cpuUsage > monitoring.thresholds.cpuWarning ? '#fbbf24' : '#6ee7b7' }}
>
{dev.cpuUsage}%
</span>
</div>
</div>
);
})}
</div>
</div>
{/* 内存负载 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={HardDrive} title="内存负载" iconColor="#c084fc" iconBg="rgba(168,85,247,0.22)" extra={<span className="text-[11px] text-slate-400">{memoryList.length} 台</span>} />
<div className="flex-1 min-h-0 overflow-auto space-y-1.5 pr-1">
{memoryList.length === 0 && (
<div className="text-[11px] text-center py-2 text-slate-300">暂无巡检数据</div>
)}
{memoryList.map((dev) => {
const DevIcon = getDeviceIcon(dev.type);
const statusDot = dev.memoryUsage > monitoring.thresholds.memCritical ? '#FF4D4F' : dev.memoryUsage > monitoring.thresholds.memWarning ? '#FACC15' : '#00FF66';
return (
<div
key={dev.id}
className="flex items-center gap-1.5 p-1.5 rounded cursor-pointer hover:bg-purple-500/10 transition-colors -mx-1.5"
style={{ background: 'rgba(168,85,247,0.05)' }}
onClick={() => openDeviceDetail(dev.id)}
>
<div
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(168,85,247,0.18)', border: '1px solid rgba(192,132,252,0.25)' }}
>
<DevIcon className="w-2.5 h-2.5" style={{ color: '#c084fc' }} />
</div>
<span className="text-[11px] truncate flex-shrink-0 font-medium" title={dev.name}>{dev.name}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span className="opacity-50">·</span>
<span className="truncate">{dev.ip || ''}</span>
<span className="opacity-40">{formatRelativeTime(dev.lastUpdate)}</span>
</span>
<div className="flex items-center gap-1 ml-auto flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: statusDot, boxShadow: `0 0 6px ${statusDot}` }} />
<span
className="text-xs font-bold font-mono tabular-nums leading-none"
style={{ color: dev.memoryUsage > monitoring.thresholds.memCritical ? '#f87171' : dev.memoryUsage > monitoring.thresholds.memWarning ? '#fbbf24' : '#6ee7b7' }}
>
{dev.memoryUsage}%
</span>
</div>
</div>
);
})}
</div>
</div>
</aside>
)}
{/* 中间主视区 — 操作浮层 */}
<section
className="relative overflow-hidden h-full min-h-0"
style={{
background: 'transparent',
pointerEvents: 'none',
}}
>
{/* loading 遮罩已移到页面外,3D 底图在最外层 */}
{/* Canvas container 已移到根 div,这里不再单独渲染 */}
{loading && (
<div
className="absolute inset-0 flex items-center justify-center z-10"
style={{ background: 'rgba(11,17,32,0.85)', pointerEvents: 'auto' }}
>
<div className="text-center">
<div
className="w-12 h-12 border-4 rounded-full mx-auto mb-3 animate-spin"
style={{
borderColor: 'rgba(14,165,233,0.3)',
borderTopColor: '#0ea5e9',
}}
/>
<div className="text-xs text-slate-300">加载 3D 拓扑全景中...</div>
</div>
</div>
)}
{/* 图例 */}
{legend && (
<div
className="absolute top-3 left-3 z-20 rounded-lg border p-3 pointer-events-auto"
style={{
background: 'rgba(18, 35, 60, 0.35)',
borderColor: 'rgba(96,165,250,0.30)',
backdropFilter: 'blur(8px)',
}}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-foreground">图例</span>
<button onClick={() => setLegend(false)} className="text-xs text-slate-300 hover:text-foreground">
&times;
</button>
</div>
<div className="space-y-1">
{[
{ color: 'bg-[#00FF66]', label: '正常' },
{ color: 'bg-amber-500', label: '警告' },
{ color: 'bg-red-500', label: '严重' },
{ color: 'bg-slate-500', label: '离线' },
].map((it) => (
<div key={it.label} className="flex items-center gap-1.5 text-xs text-slate-300">
<span className={`h-2 w-2 rounded-full ${it.color}`} /> {it.label}
</div>
))}
</div>
</div>
)}
{/* 连接线图例 — 右上侧,与左侧图例样式一致,大屏模式保留 */}
{showConnectionLegend && (
<div
className="absolute right-3 z-20 rounded-lg border p-3 pointer-events-auto"
style={{
top: isTopologyOnly ? '64px' : '12px',
background: 'rgba(18, 35, 60, 0.35)',
borderColor: 'rgba(96,165,250,0.30)',
backdropFilter: 'blur(8px)',
}}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-foreground">连接类型</span>
<button onClick={() => setShowConnectionLegend(false)} className="text-xs text-slate-300 hover:text-foreground">
&times;
</button>
</div>
<div className="space-y-1">
{[
{ label: 'WAN/上行', color: '#2196f3' },
{ label: 'L3 路由', color: '#4caf50' },
{ label: 'L2 交换', color: '#00bcd4' },
{ label: '存储', color: '#ffa500' },
{ label: '接入', color: '#9c27b0' },
{ label: '断开', isBreak: true },
].map((it) => (
<div key={it.label} className="flex items-center gap-1.5 text-xs text-slate-300">
{it.isBreak ? (
<span className="relative inline-flex items-center justify-center w-2.5 h-2.5 rounded-full bg-slate-500">
<span className="absolute w-2 h-[2px] bg-red-500 rotate-45" />
<span className="absolute w-2 h-[2px] bg-red-500 -rotate-45" />
</span>
) : (
<span className="h-2 w-2 rounded-full flex-shrink-0" style={{ background: it.color }} />
)}
{it.label}
</div>
))}
</div>
</div>
)}
{/* 底部控制栏 — 向下滑出 */}
{!isTopologyOnly && (
<div
className="absolute bottom-3 left-1/2 flex items-center gap-2 z-20"
style={{
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: `translateX(-50%) ${isPanelsHidden ? 'translateY(120%)' : 'translateY(0)'}`,
opacity: isPanelsHidden ? 0 : 1,
}}
>
<button
onClick={resetView}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
background: 'rgba(14,165,233,0.18)',
border: '1px solid rgba(34,211,238,0.35)',
color: '#67e8f9',
}}
>
<Maximize2 className="w-3.5 h-3.5" /> 重置视角
</button>
<button
onClick={refreshData}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
background: 'rgba(14,165,233,0.18)',
border: '1px solid rgba(34,211,238,0.35)',
color: '#67e8f9',
}}
>
<RefreshCw className={`w-3.5 h-3.5 ${autoRefreshInterval > 0 ? 'animate-spin' : ''}`} style={{ animationDuration: '3s' }} /> 刷新数据
</button>
<div className="relative">
<button
onClick={() => setShowRefreshMenu(!showRefreshMenu)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
background: autoRefreshInterval > 0 ? 'rgba(16,185,129,0.18)' : 'rgba(148,163,184,0.15)',
border: autoRefreshInterval > 0 ? '1px solid rgba(16,185,129,0.35)' : '1px solid rgba(148,163,184,0.25)',
color: autoRefreshInterval > 0 ? '#6ee7b7' : '#94a3b8',
}}
>
{autoRefreshInterval > 0
? autoRefreshInterval < 60
? `${autoRefreshInterval}秒`
: `${autoRefreshInterval / 60}分`
: '自动刷新'}
</button>
{showRefreshMenu && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setShowRefreshMenu(false)}
/>
<div
className="absolute right-0 bottom-full mb-1 z-40 rounded-lg border overflow-hidden shadow-xl"
style={{
background: 'rgba(15, 23, 42, 0.9)',
backdropFilter: 'blur(8px)',
borderColor: 'rgba(148,163,184,0.2)',
minWidth: '100px',
}}
>
{refreshOptions.map((opt) => (
<div
key={opt.value}
className="px-3 py-2 text-xs cursor-pointer transition-colors flex items-center justify-between"
style={{
color: autoRefreshInterval === opt.value ? '#67e8f9' : '#cbd5e1',
background: autoRefreshInterval === opt.value ? 'rgba(34,211,238,0.1)' : 'transparent',
}}
onClick={() => {
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}
</div>
))}
</div>
</>
)}
</div>
</div>
)}
{/* 右下角提示 — 向下滑出 */}
{!isTopologyOnly && (
<div
className="absolute bottom-3 right-3 z-20 text-xs px-2.5 py-1 rounded"
style={{
color: '#cbd5e1',
background: 'rgba(11,17,32,0.82)',
border: '1px solid rgba(148,163,184,0.2)',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
🖱️ 滚轮缩放 · 拖拽旋转 · 点击设备查看详情
</div>
)}
{/* 大屏模式 — 浮动退出按钮 */}
{isTopologyOnly && (
<button
onClick={toggleTopologyOnly}
className="absolute top-4 right-4 z-[100] px-4 py-2 rounded-lg flex items-center gap-2 transition-all hover:scale-105"
style={{
pointerEvents: 'auto',
background: 'linear-gradient(135deg, rgba(22,65,114,0.92) 0%, rgba(30,91,150,0.85) 100%)',
border: '1px solid #5AB0FF',
boxShadow: '0 4px 16px rgba(90,176,255,0.35), 0 0 20px rgba(90,176,255,0.15)',
color: '#E0F2FE',
fontWeight: 600,
fontSize: '13px',
letterSpacing: '0.05em',
}}
title="退出大屏展示模式"
>
<Maximize2 className="w-4 h-4 text-cyan-300" />
展开
</button>
)}
</section>
{/* 右栏 — 向右侧下方滑出 */}
{!isTopologyOnly && (
<aside
className="relative min-w-0 min-h-0 grid w-full h-full gap-2 p-2 overflow-hidden"
style={{
gridTemplateRows: 'minmax(0, auto) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)',
background: 'linear-gradient(to left, #164172 0%, #1E5B96 35%, #3E8BE0 100%)',
borderLeft: '1px solid #5AB0FF',
boxShadow: 'inset 25px 0 50px rgba(90,176,255,0.25), inset 0 0 30px rgba(22,65,114,0.4)',
borderRadius: '8px 0 0 8px',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translate(120%, 20%)' : 'translate(0, 0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
{/* 巡检任务状态 */}
<div className="card rounded-xl border p-2 min-h-0 overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={ListTodo} title="巡检任务" iconColor="#67e8f9" iconBg="rgba(14,165,233,0.25)" />
<div className="grid grid-cols-4 gap-1 text-center">
{[
{
icon: Loader2,
value: taskStats.running,
label: '执行中',
status: 'running',
color: '#38bdf8',
bg: 'rgba(14,165,233,0.15)',
},
{
icon: CheckCircle2,
value: taskStats.success,
label: '成功',
status: 'success',
color: '#00FF66',
bg: 'rgba(0,255,102,0.18)',
},
{
icon: XCircle,
value: taskStats.failed,
label: '失败',
status: 'failed',
color: '#f87171',
bg: 'rgba(239,68,68,0.15)',
},
{
icon: Clock,
value: taskStats.pending,
label: '待执行',
status: 'pending',
color: '#FFFFFF',
bg: 'rgba(148,163,184,0.15)',
},
].map((it) => (
<div
key={it.status}
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => openTaskList(it.label + '任务', it.status)}
>
<div
className="w-5 h-5 mx-auto rounded-full flex items-center justify-center mb-0.5"
style={{ background: it.bg }}
>
<it.icon className="w-2.5 h-2.5" style={{ color: it.color }} />
</div>
<div className="text-[11px] font-semibold leading-tight" style={{ color: it.color }}>
{it.value}
</div>
<div className="text-[10px] text-slate-300">{it.label}</div>
</div>
))}
</div>
</div>
{/* 告警趋势 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={TrendingUp} title="告警趋势 14 日" iconColor="#fbbf24" iconBg="rgba(245,158,11,0.22)" />
<div ref={chartTrendRef} className="w-full flex-1 min-w-0 min-h-0 overflow-hidden" />
</div>
{/* 离线设备 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={WifiOff} title="离线设备" iconColor="#94a3b8" iconBg="rgba(148,163,184,0.22)" extra={<span className="text-[11px] text-slate-400">{offlineDevices.length} 台</span>} />
<div className="flex-1 min-h-0 overflow-auto space-y-1 pr-1">
{offlineDevices.length === 0 && (
<div className="text-[11px] text-center py-2 text-[#00FF66]">
全部设备在线
</div>
)}
{offlineDevices.map((d) => {
const DevIcon = getDeviceIcon(d.type);
return (
<div
key={d.id}
className="flex items-center gap-1.5 p-1.5 rounded cursor-pointer transition-colors hover:bg-slate-500/15"
style={{ background: 'rgba(148,163,184,0.06)' }}
onClick={() => openDeviceDetail(d.id)}
>
<div
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(148,163,184,0.2)', border: '1px solid rgba(156,163,175,0.3)' }}
>
<DevIcon className="w-2.5 h-2.5" style={{ color: '#cbd5e1' }} />
</div>
<span className="text-[11px] truncate flex-shrink-0 text-slate-300 font-medium" title={d.name}>{d.name}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span className="opacity-50">·</span>
<span className="truncate">{d.ip || '-'}</span>
</span>
<span className="text-[10px] text-slate-500 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span>{formatRelativeTime(d.lastUpdate)}</span>
</span>
<span className="ml-auto text-[10px] px-1 py-0.5 rounded flex-shrink-0 font-semibold" style={{ color: '#fca5a5', background: 'rgba(239,68,68,0.18)', border: '1px solid rgba(239,68,68,0.55)' }}>离线</span>
</div>
);
})}
</div>
</div>
{/* 实时告警 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader
icon={activeAlarms.length > 0 ? AlertTriangle : CheckCircle2}
title="实时告警"
iconColor={activeAlarms.length > 0 ? '#f87171' : '#00FF66'}
iconBg={activeAlarms.length > 0 ? 'rgba(239,68,68,0.22)' : 'rgba(16,185,129,0.22)'}
extra={
alarmCounts.total > 0
? <span className="text-[11px] px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">{alarmCounts.total}</span>
: <span className="text-[11px] text-[#00FF66]">系统正常</span>
}
/>
{activeAlarms.length > 0 ? (
<div className="flex-1 min-h-0 overflow-auto space-y-1 pr-1">
{activeAlarms.map((alarm) => {
const lvlColor =
alarm.level === 'critical' ? '#FF4D4F'
: alarm.level === 'warning' ? '#FACC15'
: alarm.level === 'major' ? '#f97316'
: alarm.level === 'minor' ? '#fbbf24'
: '#60a5fa';
const LvlIcon =
alarm.level === 'critical' ? AlertOctagon
: (alarm.level === 'warning' || alarm.level === 'major' || alarm.level === 'minor') ? AlertTriangle
: Info;
return (
<div
key={alarm.id}
className="px-1.5 py-1 rounded text-[11px] border-l-2 -mx-1 cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => openDeviceDetail(alarm.deviceId)}
style={{
background: alarm.level === 'critical' ? 'rgba(239,68,68,0.10)'
: alarm.level === 'major' ? 'rgba(249,115,22,0.10)'
: alarm.level === 'warning' ? 'rgba(245,158,11,0.10)'
: 'rgba(96,165,250,0.08)',
borderLeftColor: lvlColor,
}}
>
<div className="flex items-center gap-1.5">
<div
className="w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0"
style={{ background: `${lvlColor}22`, border: `1px solid ${lvlColor}55` }}
>
<LvlIcon className="w-2 h-2" style={{ color: lvlColor }} />
</div>
<span
className="font-medium truncate flex-1"
style={{ color: lvlColor }}
title={`${alarm.deviceName} — ${alarm.message}`}
>
{alarm.message}
</span>
<span className="flex-shrink-0 font-mono text-[10px]" style={{ color: '#FFFFFF' }}>{alarm.time}</span>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-3">
<div
className="w-6 h-6 mx-auto rounded-full flex items-center justify-center mb-1"
style={{
background: 'rgba(0,255,102,0.18)',
border: '1px solid rgba(16,185,129,0.25)',
}}
>
<CheckCircle2 className="w-3 h-3 text-[#00FF66]" />
</div>
<div className="text-[11px] text-[#00FF66]">当前无活动告警</div>
<div className="text-[10px] mt-0.5 text-slate-300">设备运行一切正常</div>
</div>
)}
</div>
</aside>
)}
</div>{/* 内层 grid 结束 */}
{/* 设备详情弹窗 */}
{/* 连线类型选择对话框 */}
{pendingLink && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.6)' }}
onClick={() => setPendingLink(null)}
>
<div
className="rounded-lg shadow-2xl p-4 w-[280px]"
style={{
background: 'rgba(15,23,42,0.98)',
border: '1px solid rgba(148,163,184,0.25)',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs font-medium mb-3 text-slate-200">选择链路类型</div>
<div className="grid grid-cols-2 gap-2">
{[
{ 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 }) => (
<button
key={type}
className="px-3 py-2 rounded text-xs text-left hover:opacity-80 transition-opacity"
style={{
background: `${color}20`,
border: `1px solid ${color}50`,
color: color,
}}
onClick={() => {
// 创建链路
const newLink: Link = {
id: `custom-${Date.now()}`,
source: pendingLink.sourceId,
target: pendingLink.targetId,
type,
};
setCustomLinks((prev) => [...prev, newLink]);
console.log('[连线] 创建链路:', pendingLink.sourceId, '->', pendingLink.targetId, type);
setPendingLink(null);
setLinkMode(null); // 退出连线模式,恢复 cursor 为 grab
}}
>
{label}
</button>
))}
</div>
<button
className="w-full mt-3 px-3 py-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
onClick={() => setPendingLink(null)}
>
取消
</button>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
className="fixed z-[9999] rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
background: 'rgba(15,23,42,0.98)',
border: '1px solid rgba(148,163,184,0.25)',
}}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full px-3 py-1.5 text-left text-xs hover:bg-slate-700/60 text-slate-300 flex items-center gap-2"
onClick={() => {
if (linkModeUI) {
// 连线模式:取消
setLinkMode(null);
} else {
// 普通模式:开始连线
setLinkMode({ sourceId: contextMenu.deviceId });
}
setContextMenu(null);
}}
>
<svg className="w-3.5 h-3.5 text-cyan-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
{linkModeUI ? '取消连线' : '从此设备开始连线'}
</button>
<button
className="w-full px-3 py-1.5 text-left text-xs hover:bg-slate-700/60 text-slate-300 flex items-center gap-2"
onClick={() => {
// 重置位置(暂不实现)
setContextMenu(null);
}}
>
<svg className="w-3.5 h-3.5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
重置位置
</button>
<div className="my-1 border-t border-slate-700/50" />
<button
className="w-full px-3 py-1.5 text-left text-xs hover:bg-slate-700/60 text-slate-300 flex items-center gap-2"
onClick={() => {
const dev = deviceMapRef.current.get(contextMenu.deviceId);
if (dev) {
// TODO: 打开设备详情
}
setContextMenu(null);
}}
>
<svg className="w-3.5 h-3.5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
查看详情
</button>
</div>
)}
{/* 连线删除确认 */}
{deleteLinkConfirm && (
<div
className="fixed z-[9999] rounded-xl shadow-2xl py-2 min-w-[240px]"
style={{
left: deleteLinkConfirm.x,
top: deleteLinkConfirm.y,
background: 'rgba(15,23,42,0.98)',
border: '1px solid rgba(239,68,68,0.35)',
backdropFilter: 'blur(8px)',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 py-2 border-b border-slate-700/50 text-xs text-slate-200 flex items-center gap-2">
<svg className="w-4 h-4 text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
删除连线
</div>
<div className="px-3 py-2 text-xs text-slate-400 leading-relaxed">
确定删除这条连线?
{!deleteLinkConfirm.isCustom && (
<div className="mt-1 text-[12px] text-amber-400">(系统自动生成的连线,删除后将不再自动重建)</div>
)}
</div>
<div className="flex gap-1 px-2 pb-2">
<button
className="flex-1 px-2 py-1.5 rounded-md text-xs font-medium bg-red-500/90 hover:bg-red-500 text-white transition-colors"
onClick={() => {
const { sourceId, targetId, isCustom } = deleteLinkConfirm;
if (isCustom) {
setCustomLinks((prev) =>
prev.filter((l) => !(l.source === sourceId && l.target === targetId))
);
} else {
setExcludedAutoLinks((prev) => [
...prev,
{ id: `excluded-${sourceId}-${targetId}`, source: sourceId, target: targetId, type: 'excluded' },
]);
}
setDeleteLinkConfirm(null);
}}
>
确定删除
</button>
<button
className="flex-1 px-2 py-1.5 rounded-md text-xs font-medium bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors"
onClick={() => setDeleteLinkConfirm(null)}
>
取消
</button>
</div>
</div>
)}
{/* 居中设备详情小卡片 */}
<style>{`
@keyframes modalFadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes modalPop { from { opacity: 0; transform: scale(0.92) translateY(8px) } to { opacity: 1; transform: scale(1) translateY(0) } }
.detail-modal-bg { animation: modalFadeIn .18s ease-out }
.detail-modal-card { animation: modalPop .22s cubic-bezier(.2,.8,.2,1) }
.detail-scroll::-webkit-scrollbar { width: 4px }
.detail-scroll::-webkit-scrollbar-thumb { background: rgba(148,163,184,.25); border-radius: 4px }
`}</style>
{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 (
<div className="fixed inset-0 z-[9999] flex items-center justify-center detail-modal-bg"
style={{ background: 'rgba(2,6,23,0.42)' }}
onClick={closeDetailPanel}>
<div className="detail-modal-card rounded-2xl w-[440px] max-h-[82vh] overflow-hidden"
style={{
background: 'linear-gradient(165deg, rgba(22,65,114,0.88) 0%, rgba(30,91,150,0.82) 100%)',
border: '1px solid rgba(90,176,255,0.45)',
backdropFilter: 'blur(20px) saturate(1.15)',
boxShadow: '0 20px 50px rgba(22,65,114,0.5), 0 0 30px rgba(90,176,255,0.25), inset 0 1px 0 rgba(125,211,252,0.15)',
}}
onClick={(e) => e.stopPropagation()}>
<div className="h-0.5 w-full" style={{ background: `linear-gradient(90deg, transparent 10%, ${si.dot} 40%, ${si.dot} 60%, transparent 90%)`, opacity: 0.7 }} />
<div className="px-5 pt-4 pb-3 flex items-center gap-3">
<div className="relative w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: `linear-gradient(135deg, ${si.bg}, rgba(14,165,233,0.08))`, border: `1px solid rgba(125,211,252,0.18)` }}>
<svg className="w-5 h-5 text-cyan-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="2" y="3" width="20" height="14" rx="2.5" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
style={{ background: si.dot, boxShadow: `0 0 8px ${si.dot}` }}>{si.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[15px] font-semibold text-slate-100 truncate tracking-tight">{panelDeviceDetail.name}</span>
<span className="px-2 py-0.5 rounded-full text-[12px] font-semibold flex-shrink-0"
style={{ background: si.bg, color: si.fg, border: `1px solid ${si.fg}40` }}>{si.label}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400 mt-1">
<span className="px-1.5 py-0.5 rounded bg-cyan-950/40 border border-cyan-500/10">{panelDeviceDetail.type}</span>
<span className="font-mono">{panelDeviceDetail.ip || '—'}</span>
</div>
</div>
<button className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-slate-100 hover:bg-cyan-500/10 transition-all flex-shrink-0"
onClick={closeDetailPanel}>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-cyan-500/25 to-transparent" />
<div className="px-5 py-4 grid grid-cols-2 gap-3">
{[{ l: 'CPU', v: cpu, c: cpuC, i: '🖥' }, { l: '内存', v: mem, c: memC, i: '💾' }].map((m) => (
<div key={m.l} className="rounded-lg p-3"
style={{ background: 'rgba(20,40,70,0.28)', border: '1px solid rgba(125,211,252,0.14)' }}>
<div className="flex items-center gap-1.5 mb-2"><span className="text-xs">{m.i}</span>
<span className="text-xs text-slate-400 font-medium">{m.l}</span></div>
<div className="flex items-end justify-between mb-1.5">
<span className="text-lg font-bold text-slate-100 tabular-nums"
style={{ color: m.v != null ? m.c : undefined }}>{m.v ?? '—'}</span>
<span className="text-[12px] text-slate-500">%</span></div>
<div className="h-1.5 rounded-full bg-slate-700/40 overflow-hidden">
<div className="h-full rounded-full transition-all duration-500"
style={{ width: `${m.v ?? 0}%`, background: `linear-gradient(90deg, ${m.c}cc, ${m.c})`,
boxShadow: m.v != null ? `0 0 6px ${m.c}60` : undefined }} /></div></div>
))}
</div>
{panelDeviceDetail.alerts && panelDeviceDetail.alerts.length > 0 && (
<div className="px-5 pb-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-3 rounded-full bg-gradient-to-b from-amber-400 to-red-400" />
<span className="text-xs font-semibold text-slate-300 tracking-wide">最近告警</span>
<span className="text-[12px] text-slate-400 bg-cyan-950/40 border border-cyan-500/10 px-1.5 py-0.5 rounded">{panelDeviceDetail.alerts.length}</span>
</div>
<div className="detail-scroll space-y-1.5 max-h-[140px] overflow-y-auto pr-1">
{panelDeviceDetail.alerts.slice(0, 5).map((a) => {
const dot = a.level === 'critical' ? '#FF4D4F' : a.level === 'warning' ? '#FACC15' : '#06b6d4';
return (
<div key={a.id} className="flex items-start gap-2 text-xs py-2 px-2.5 rounded-lg"
style={{ background: 'rgba(20,40,70,0.22)', border: '1px solid rgba(125,211,252,0.08)' }}>
<span className="w-1.5 h-1.5 rounded-full mt-2 flex-shrink-0"
style={{ background: dot, boxShadow: `0 0 6px ${dot}` }} />
<div className="flex-1 min-w-0">
<div className="text-slate-300 truncate text-[12px] leading-relaxed">{a.message}</div>
<div className="text-slate-500 text-[12px] mt-0.5">{a.time}</div>
</div>
</div>);
})}
</div>
</div>
)}
<div className="px-5 py-3 flex items-center justify-between" style={{ borderTop: '1px solid rgba(125,211,252,0.14)' }}>
<div className="flex items-center gap-1.5 text-[12px] text-slate-500">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>
更新:{(() => { 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)} 小时前`; })()}
</div>
<button className="px-3.5 py-1.5 rounded-lg text-xs font-medium"
style={{ background: 'rgba(6,182,212,0.12)', color: '#67e8f9', border: '1px solid rgba(6,182,212,0.22)' }}
onClick={closeDetailPanel}>关闭</button>
</div>
</div>
</div>
);
})()}
{detailPanel.type !== 'deviceDetail' && (
<MonitoringDetailPanel
open={detailPanel.open}
onClose={closeDetailPanel}
panelType={detailPanel.type}
panelTitle={detailPanel.title}
deviceList={panelDeviceList}
alertList={panelAlertList}
deviceDetail={panelDeviceDetail}
taskList={panelTaskList}
onDeviceClick={(deviceId) => openDeviceDetail(deviceId)}
thresholds={monitoring.thresholds}
/>
)}
</div>
</>
);
}
export default MonitoringTopologyView;