Files
NetMesh/components/monitoring/MonitoringTopologyView.tsx

2410 lines
90 KiB
TypeScript
Raw Normal View History

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