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
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立 Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心 与告警中心页增强。 拓扑大屏:MonitoringTopologyView 扩展分组/布局控制, useMonitoringData 重构数据流。 远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露 IPC,registerBridges 注册 netcatty:http:open 桥。 portable 绿色打包: - electron-builder.config.cjs 恢复原始白名单(61MB app.asar), 收集器自动追踪运行时依赖; - portableData.cjs 重写为 .portable 标记检测; - scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage 的 http://localhost:5173 origin 批量迁移为 app://netcatty; - scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
3799 lines
161 KiB
TypeScript
3799 lines
161 KiB
TypeScript
/**
|
||
* 运行监控 - 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 读
|
||
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]);
|
||
const MIN_Y = -0.5;
|
||
const MAX_Y = 20;
|
||
|
||
if (savedPositions) {
|
||
devices.forEach((d) => {
|
||
if (savedPositions[d.id]) {
|
||
positions[d.id] = new THREE.Vector3(
|
||
savedPositions[d.id].x,
|
||
Math.max(MIN_Y, Math.min(MAX_Y, savedPositions[d.id].y ?? 0)),
|
||
savedPositions[d.id].z
|
||
);
|
||
}
|
||
});
|
||
} else {
|
||
firewalls.forEach((d, i) => { positions[d.id] = new THREE.Vector3((i - firewalls.length / 2) * 10, 8, -25); });
|
||
routers.forEach((d, i) => { positions[d.id] = new THREE.Vector3((i - routers.length / 2) * 10, 7, -15); });
|
||
coreSwitches.forEach((d, i) => { positions[d.id] = new THREE.Vector3((i - coreSwitches.length / 2) * 12, 6, 0); });
|
||
accessSwitches.forEach((d, i) => {
|
||
positions[d.id] = new THREE.Vector3(
|
||
Math.cos((i / Math.max(accessSwitches.length, 1)) * Math.PI * 2) * 25 + (Math.random() - 0.5) * 6,
|
||
4,
|
||
Math.sin((i / Math.max(accessSwitches.length, 1)) * Math.PI * 2) * 25
|
||
);
|
||
});
|
||
servers.forEach((d, i) => {
|
||
positions[d.id] = new THREE.Vector3(
|
||
Math.cos((i / Math.max(servers.length, 1)) * Math.PI * 2 + 0.3) * 40 + (Math.random() - 0.5) * 8,
|
||
2 + Math.random() * 2,
|
||
Math.sin((i / Math.max(servers.length, 1)) * Math.PI * 2 + 0.3) * 40
|
||
);
|
||
});
|
||
rest.forEach((d, i) => {
|
||
positions[d.id] = new THREE.Vector3(
|
||
Math.cos((i / Math.max(rest.length, 1)) * Math.PI * 2 + 1) * 50,
|
||
1,
|
||
Math.sin((i / Math.max(rest.length, 1)) * Math.PI * 2 + 1) * 50 + 20
|
||
);
|
||
});
|
||
}
|
||
|
||
positionsRef.current = positions;
|
||
|
||
// Electron 环境:启动时自动把 localStorage 里的拓扑位置同步给主进程,供远程浏览器读取
|
||
{
|
||
const IS_ELECTRON = typeof window !== "undefined" && !!(window as any).netcatty;
|
||
if (IS_ELECTRON) {
|
||
try {
|
||
const saved = localStorage.getItem('monitoring-topology-positions');
|
||
if (saved) {
|
||
const nc = (window as any).netcatty;
|
||
nc.inspection?.saveTopologyPositions?.(JSON.parse(saved)).catch(() => {});
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
// 创建设备节点
|
||
const nodes: THREE.Group[] = [];
|
||
for (const device of devices) {
|
||
const pos = positions[device.id] || new THREE.Vector3(0, 0, 0);
|
||
const group = createDeviceMesh(device.type, device.status);
|
||
group.position.copy(pos);
|
||
deviceBaseYRef.current.set(device.id, pos.y);
|
||
group.userData = { deviceId: device.id, baseScale: 1, isCritical: device.status === 'critical', currentStatus: device.status, labelDiv: null as HTMLDivElement | null };
|
||
scene.add(group);
|
||
nodes.push(group);
|
||
|
||
const labelDiv = document.createElement('div');
|
||
labelDiv.className = 'three-label';
|
||
labelDiv.textContent = device.name;
|
||
labelDiv.dataset.deviceId = device.id;
|
||
labelDiv.dataset.status = device.status;
|
||
const labelStyle = getLabelStyle(device.status);
|
||
Object.assign(labelDiv.style, {
|
||
color: labelStyle.text, fontSize: '13px', fontWeight: 'bold', padding: '3px 10px',
|
||
background: labelStyle.background, border: labelStyle.border, borderRadius: '6px',
|
||
pointerEvents: 'none', whiteSpace: 'nowrap', transform: 'translate(-50%, -100%)',
|
||
fontFamily: 'Inter, "Microsoft YaHei", "PingFang SC", sans-serif',
|
||
letterSpacing: '0.3px', textShadow: '0 1px 3px rgba(0,0,0,0.5)',
|
||
boxShadow: `0 4px 16px ${labelStyle.glow}`, marginTop: '-8px',
|
||
});
|
||
const labelObj = new CSS2DObject(labelDiv);
|
||
labelObj.position.set(0, 3, 0);
|
||
group.add(labelObj);
|
||
group.userData.labelDiv = labelDiv;
|
||
labelsRef.current.push(labelDiv);
|
||
}
|
||
nodesRef.current = nodes;
|
||
|
||
// 创建链路
|
||
const edges: Line2[] = [];
|
||
const breaks: THREE.Group[] = [];
|
||
for (const link of links) {
|
||
const a = positions[link.source];
|
||
const b = positions[link.target];
|
||
if (!a || !b) continue;
|
||
const { mid } = computeArcMidpoint(a, b, link.source + '_' + link.target);
|
||
const color = edgeColorMap[link.type] || 0x2196f3;
|
||
const geo = new LineGeometry();
|
||
geo.setPositions([a.x, a.y, a.z, mid.x, mid.y, mid.z, b.x, b.y, b.z]);
|
||
const mat = new LineMaterial({
|
||
color, transparent: true, opacity: 1.0, linewidth: 3, worldUnits: false,
|
||
vertexColors: false, dashed: true, dashSize: 4, gapSize: 2.5,
|
||
resolution: new THREE.Vector2(window.innerWidth, window.innerHeight), depthTest: false,
|
||
});
|
||
const line = new Line2(geo, mat);
|
||
line.computeLineDistances();
|
||
line.renderOrder = 2;
|
||
// 预分配 Float32Array,animate 循环里复用避免每帧 GC
|
||
const posArray = new Float32Array(9);
|
||
line.userData = { sourceId: link.source, targetId: link.target, linkType: link.type, arcMidHash: link.source + '_' + link.target, posArray };
|
||
scene.add(line);
|
||
edges.push(line);
|
||
|
||
const breakX = createBreakMark();
|
||
breakX.position.set(mid.x, mid.y + 1.5, mid.z);
|
||
breakX.visible = false;
|
||
scene.add(breakX);
|
||
breaks.push(breakX);
|
||
}
|
||
edgesRef.current = edges;
|
||
linkBreakRef.current = breaks;
|
||
// 同步更新 deviceId → Group 查找表(给 animate 循环 O(1) 查找用)
|
||
nodeGroupMapRef.current = new Map(nodes.map((n) => [n.userData.deviceId as string, n]));
|
||
}, [devices, links]);
|
||
|
||
// 设备状态实时同步:每次 devices 变化更新 ref,动画循环读取最新 status
|
||
useEffect(() => {
|
||
deviceMapRef.current = new Map(devices.map((d) => [d.id, d]));
|
||
}, [devices]);
|
||
|
||
// ===== ECharts 初始化(页面首次加载 + 退出大屏后都要 init 一次) =====
|
||
// 大屏模式下 aside 被条件隐藏卸载,退出时 DOM 重建 → ref 拿到新节点 → 必须重新 init
|
||
useEffect(() => {
|
||
// 大屏模式下 aside 不存在,跳过
|
||
if (isTopologyOnly) {
|
||
chartsRef.current.forEach((c) => c.dispose());
|
||
chartsRef.current = [];
|
||
return;
|
||
}
|
||
const timer = setTimeout(() => {
|
||
chartsRef.current.forEach((c) => c.dispose());
|
||
chartsRef.current = [];
|
||
|
||
const commonGrid = { top: 24, left: 32, right: 10, bottom: 24, containLabel: true };
|
||
|
||
// 设备类型分布柱状图
|
||
if (chartTypeRef.current) {
|
||
const c = echarts.init(chartTypeRef.current, undefined, { renderer: 'canvas' });
|
||
chartsRef.current.push(c);
|
||
const names = ['服务器', '交换机', '路由器', '防火墙', '存储', '工作站'];
|
||
const typeData = [typeDistribution.server, typeDistribution.switch, typeDistribution.router, typeDistribution.firewall, typeDistribution.storage, typeDistribution.workstation];
|
||
c.setOption({
|
||
grid: commonGrid,
|
||
tooltip: { trigger: 'axis', backgroundColor: '#0f172a', borderColor: '#1e293b', textStyle: { color: '#e2e8f0', fontSize: 10 } },
|
||
xAxis: { type: 'category', data: names, axisLine: { lineStyle: { color: 'rgba(255,255,255,0.25)' } }, axisTick: { show: false }, axisLabel: { color: '#FFFFFF', fontSize: 8, interval: 0, rotate: 20 } },
|
||
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#FFFFFF', fontSize: 10 }, minInterval: 1 },
|
||
series: [{ type: 'bar', data: typeData, barWidth: '45%',
|
||
label: { show: true, position: 'top', color: '#38bdf8', fontSize: 9, fontWeight: 600 },
|
||
itemStyle: { borderRadius: [3, 3, 0, 0], color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#0ea5e9' }, { offset: 1, color: 'rgba(14,165,233,0.2)' }]) },
|
||
}],
|
||
});
|
||
}
|
||
|
||
// 告警趋势折线图
|
||
if (chartTrendRef.current) {
|
||
const c = echarts.init(chartTrendRef.current, undefined, { renderer: 'canvas' });
|
||
chartsRef.current.push(c);
|
||
const trend = monitoring.alarmTrend;
|
||
c.setOption({
|
||
grid: commonGrid,
|
||
tooltip: { trigger: 'axis', backgroundColor: '#0f172a', borderColor: '#1e293b', textStyle: { color: '#e2e8f0', fontSize: 10 } },
|
||
xAxis: { type: 'category', data: trend.map((t) => t.date), axisLine: { lineStyle: { color: 'rgba(255,255,255,0.25)' } }, axisTick: { show: false }, axisLabel: { color: '#FFFFFF', fontSize: 8 } },
|
||
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#FFFFFF', fontSize: 10 }, minInterval: 1 },
|
||
series: [{ type: 'line', smooth: true, symbol: 'circle', symbolSize: 5, data: trend.map((t) => t.count), lineStyle: { color: '#22d3ee', width: 2 }, itemStyle: { color: '#22d3ee' },
|
||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(34,211,238,0.4)' }, { offset: 1, color: 'rgba(34,211,238,0)' }]) },
|
||
}],
|
||
});
|
||
}
|
||
|
||
// 多次 resize 确保撑满
|
||
const resizeAll = () => chartsRef.current.forEach((c) => c.resize());
|
||
requestAnimationFrame(resizeAll);
|
||
setTimeout(resizeAll, 100);
|
||
setTimeout(resizeAll, 300);
|
||
setTimeout(resizeAll, 600);
|
||
}, 80);
|
||
|
||
return () => {
|
||
clearTimeout(timer);
|
||
chartsRef.current.forEach((c) => c.dispose());
|
||
chartsRef.current = [];
|
||
};
|
||
}, [isTopologyOnly]); // 首次加载 + 退出大屏时都重新 init
|
||
|
||
// ===== 数据更新:不 dispose 图表,只用 setOption 更新 data(无闪动)=====
|
||
useEffect(() => {
|
||
const [typeChart, trendChart] = chartsRef.current;
|
||
if (!typeChart || !trendChart) return;
|
||
|
||
const names = ['服务器', '交换机', '路由器', '防火墙', '存储', '工作站'];
|
||
const typeData = [typeDistribution.server, typeDistribution.switch, typeDistribution.router, typeDistribution.firewall, typeDistribution.storage, typeDistribution.workstation];
|
||
// notMerge=false → 只合并改动部分,保留其他配置
|
||
typeChart.setOption({ xAxis: { data: names }, series: [{ data: typeData }] }, { notMerge: false });
|
||
|
||
const trend = monitoring.alarmTrend;
|
||
trendChart.setOption({
|
||
xAxis: { data: trend.map((t) => t.date) },
|
||
series: [{ data: trend.map((t) => t.count) }],
|
||
}, { notMerge: false });
|
||
}, [typeDistribution, monitoring.alarmTrend]);
|
||
|
||
const resetView = useCallback(() => {
|
||
if (cameraRef.current && controlsRef.current) {
|
||
cameraRef.current.position.set(0, 80, 125);
|
||
controlsRef.current.target.set(-20, -40, 20);
|
||
controlsRef.current.autoRotate = true;
|
||
controlsRef.current.update();
|
||
}
|
||
}, []);
|
||
|
||
const refreshData = useCallback(() => {
|
||
monitoring.refresh();
|
||
}, [monitoring]);
|
||
|
||
// 自动刷新间隔(秒)
|
||
const [autoRefreshInterval, setAutoRefreshInterval] = useState<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">
|
||
×
|
||
</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">
|
||
×
|
||
</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;
|