diff --git a/backend/package-lock.json b/backend/package-lock.json index d752c5d..52ca0de 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,8 @@ "express-fileupload": "^1.5.2", "iconv-lite": "^0.6.3", "sequelize": "^6.32.1", - "sqlite3": "^5.1.6" + "sqlite3": "^5.1.6", + "three": "^0.182.0" }, "devDependencies": { "nodemon": "^3.0.1" @@ -2730,6 +2731,12 @@ "node": ">=8" } }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 805bcf9..c1a5ca3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "express-fileupload": "^1.5.2", "iconv-lite": "^0.6.3", "sequelize": "^6.32.1", - "sqlite3": "^5.1.6" + "sqlite3": "^5.1.6", + "three": "^0.182.0" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 0a58855..7660e8b 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -14,7 +14,7 @@ const DeviceField = require('../models/DeviceField'); // 获取所有设备(支持搜索和筛选) router.get('/', async (req, res) => { try { - const { keyword, status, type, page = 1, pageSize = 10 } = req.query; + const { keyword, status, type, rackId, page = 1, pageSize = 10 } = req.query; const offset = (page - 1) * pageSize; // 构建查询条件 @@ -44,6 +44,11 @@ router.get('/', async (req, res) => { where.type = type; } + // 机柜筛选(用于机柜可视化功能) + if (rackId) { + where.rackId = rackId; + } + // 执行查询 const { count, rows } = await Device.findAndCountAll({ where, diff --git a/backend/server.js b/backend/server.js index 1d01b38..fd475bf 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,7 +5,7 @@ const { sequelize } = require('./db'); // 创建Express应用 const app = express(); -const PORT = process.env.PORT || 3001; +const PORT = process.env.PORT || 8000; // 中间件 app.use(cors()); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3db2ec..0e66a5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,8 @@ "axios": "^1.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.15.0" + "react-router-dom": "^6.15.0", + "three": "^0.160.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.0.3", @@ -2575,6 +2576,12 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/three": { + "version": "0.160.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz", + "integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6f22dd8..1ad1e54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,8 @@ "axios": "^1.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.15.0" + "react-router-dom": "^6.15.0", + "three": "^0.160.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.0.3", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 89e6ca9..05280a9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,12 +1,13 @@ import React, { useState } from 'react'; import { Layout, Menu, theme, Button } from 'antd'; import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; -import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined } from '@ant-design/icons'; +import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined } from '@ant-design/icons'; import Dashboard from './pages/Dashboard'; import DeviceManagement from './pages/DeviceManagement'; import RackManagement from './pages/RackManagement'; import RoomManagement from './pages/RoomManagement'; import DeviceFieldManagement from './pages/DeviceFieldManagement'; +import RackVisualization from './pages/RackVisualization'; const { Content, Sider } = Layout; @@ -87,6 +88,11 @@ function App() { icon: , label: 字段管理, }, + { + key: '6', + icon: , + label: 机柜可视化, + }, ]} /> @@ -106,6 +112,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/pages/RackVisualization.jsx b/frontend/src/pages/RackVisualization.jsx new file mode 100644 index 0000000..0d55682 --- /dev/null +++ b/frontend/src/pages/RackVisualization.jsx @@ -0,0 +1,1235 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Select, Button, Space, message, Tooltip } from 'antd'; +import { + ReloadOutlined, + ZoomInOutlined, + ZoomOutOutlined, + RotateRightOutlined, + CloudServerOutlined, + SwitcherOutlined, + DatabaseOutlined, + CloudOutlined, + LaptopOutlined, + MobileOutlined, + PrinterOutlined +} from '@ant-design/icons'; +import axios from 'axios'; + +const { Option } = Select; + +// 添加动画样式 +const style = document.createElement('style'); +style.textContent = ` + @keyframes slideIn { + from { + opacity: 0; + transform: translateY(-50%) translateX(20px); + } + to { + opacity: 1; + transform: translateY(-50%) translateX(0); + } + } +`; +document.head.appendChild(style); + +// 错误边界组件 +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('机柜可视化组件错误:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+

可视化组件出错了

+

+ 错误信息: {this.state.error?.toString()} +

+ +
+ ); + } + + return this.props.children; + } +} + +function RackVisualization() { + const [racks, setRacks] = useState([]); + const [selectedRack, setSelectedRack] = useState(null); + const [loading, setLoading] = useState(true); + const [devices, setDevices] = useState([]); + const [loadingDevices, setLoadingDevices] = useState(false); + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + const [error, setError] = useState(null); + const [hoveredDevice, setHoveredDevice] = useState(null); + const [hoverTimeout, setHoverTimeout] = useState(null); + + // 获取所有机柜 + const fetchRacks = async () => { + try { + setLoading(true); + setError(null); + const response = await axios.get('/api/racks'); + + // 验证数据结构并过滤有效机柜 + const validRacks = response.data.filter(rack => + rack && + typeof rack === 'object' && + rack.rackId && + typeof rack.height === 'number' + ); + + setRacks(validRacks); + if (validRacks.length > 0) { + setSelectedRack(validRacks[0]); + fetchDevices(validRacks[0].rackId); + } else { + setSelectedRack(null); + setDevices([]); + message.warning('未找到有效的机柜数据'); + } + } catch (error) { + message.error('获取机柜列表失败'); + console.error('获取机柜列表失败:', error); + setError(error); + setRacks([]); + setSelectedRack(null); + setDevices([]); + } finally { + setLoading(false); + } + }; + + // 组件卸载时清理定时器 + useEffect(() => { + return () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + } + }; + }, [hoverTimeout]); + + // 获取机柜内的设备 + const fetchDevices = async (rackId) => { + try { + setLoadingDevices(true); + console.log(`=== 开始获取机柜 ${rackId} 的设备数据 ===`); + console.log('API URL:', `/api/devices?rackId=${rackId}`); + + const response = await axios.get(`/api/devices?rackId=${rackId}`); + + console.log('=== API响应完整信息 ==='); + console.log('HTTP状态码:', response.status); + console.log('响应头:', response.headers); + console.log('响应数据类型:', typeof response.data); + console.log('响应数据:', JSON.stringify(response.data, null, 2)); + + // 处理不同的响应格式 + let devicesData; + if (Array.isArray(response.data)) { + devicesData = response.data; + console.log('响应是数组格式'); + } else if (response.data && Array.isArray(response.data.data)) { + devicesData = response.data.data; + console.log('响应是对象格式,数组在data字段中'); + } else if (response.data && Array.isArray(response.data.devices)) { + devicesData = response.data.devices; + console.log('响应是对象格式,数组在devices字段中'); + } else { + devicesData = []; + console.log('响应不是预期格式,设置为空数组'); + } + + console.log('=== 处理后的设备数据 ==='); + console.log('设备数组长度:', devicesData.length); + console.log('设备数据:', devicesData); + + // 如果没有数据,显示警告 + if (devicesData.length === 0) { + console.warn('=== 没有找到设备数据 ==='); + console.warn('可能的原因:'); + console.warn('1. 数据库中该机柜确实没有设备'); + console.warn('2. API路由配置错误'); + console.warn('3. 数据库查询条件问题'); + message.warning(`机柜 ${rackId} 暂无设备数据`); + setDevices([]); + return; + } + + // 显示原始数据结构以供调试 + console.log('=== 数据结构分析 ==='); + if (devicesData.length > 0) { + const firstDevice = devicesData[0]; + console.log('第一个设备完整数据:', JSON.stringify(firstDevice, null, 2)); + console.log('第一个设备的字段:', Object.keys(firstDevice)); + console.log('第一个设备的字段类型:'); + Object.keys(firstDevice).forEach(key => { + console.log(` ${key}: ${typeof firstDevice[key]} = ${JSON.stringify(firstDevice[key])}`); + }); + } + + // 过滤有效的设备数据 - 放宽验证条件 + const validDevices = devicesData.filter(device => { + if (!device) { + console.log('跳过空设备'); + return false; + } + + console.log('设备数据:', device); + + // 只要求有任意ID字段或标识符 + const hasId = device.deviceId || device.id || device.device_id || device.device; + if (!hasId) { + console.log('跳过缺少ID的设备:', device); + return false; + } + + // 放宽名称验证 + const hasName = device.name || device.deviceName || device.device_name || device.title; + if (!hasName) { + console.log('跳过缺少名称的设备:', device); + return false; + } + + // 检查位置和高度,允许字符串类型并转换 + let position = device.position || device.u_position || device.uPos || device.u; + let height = device.height || device.u_height || device.uHeight || device.size || 1; + + // 如果是字符串,尝试转换为数字 + if (typeof position === 'string') { + position = parseInt(position, 10); + } + if (typeof height === 'string') { + height = parseInt(height, 10); + } + + if (typeof position !== 'number' || isNaN(position) || position <= 0) { + console.log('跳过位置无效的设备:', device); + return false; + } + + if (typeof height !== 'number' || isNaN(height) || height <= 0) { + console.log('跳过高度无效的设备:', device); + return false; + } + + // 统一化数据格式 + device.position = position; + device.height = height; + device.name = hasName; + device.deviceId = hasId; + + console.log('有效的设备:', device); + return true; + }); + + console.log('最终有效设备列表:', validDevices); + setDevices(validDevices); + + if (validDevices.length === 0) { + console.warn('未找到有效设备数据'); + message.warning('该机柜内暂无设备数据'); + } + } catch (error) { + message.error('获取设备列表失败'); + console.error('获取设备列表失败:', error); + setDevices([]); + } finally { + setLoadingDevices(false); + } + }; + + useEffect(() => { + fetchRacks(); + }, []); + + // 根据设备类型获取图标 + const getDeviceIcon = (deviceType) => { + try { + if (!deviceType) return ; + const type = deviceType.toLowerCase(); + + if (type.includes('server') || type.includes('服务器')) return ; + if (type.includes('switch') || type.includes('交换机')) return ; + if (type.includes('storage') || type.includes('存储')) return ; + if (type.includes('router') || type.includes('路由器')) return ; + if (type.includes('laptop') || type.includes('笔记本')) return ; + if (type.includes('mobile') || type.includes('手机')) return ; + if (type.includes('printer') || type.includes('打印机')) return ; + + return ; + } catch (error) { + console.error('设备图标渲染错误:', error); + return ; + } + }; + + // 根据设备类型获取背景色 + const getDeviceColor = (deviceType) => { + if (!deviceType) return '#1890ff'; + const type = deviceType.toLowerCase(); + + if (type.includes('server') || type.includes('服务器')) return '#1890ff'; // 蓝色 + if (type.includes('switch') || type.includes('交换机')) return '#52c41a'; // 绿色 + if (type.includes('storage') || type.includes('存储')) return '#faad14'; // 黄色 + if (type.includes('router') || type.includes('路由器')) return '#f5222d'; // 红色 + if (type.includes('laptop') || type.includes('笔记本')) return '#722ed1'; // 紫色 + if (type.includes('mobile') || type.includes('手机')) return '#eb2f96'; // 粉色 + if (type.includes('printer') || type.includes('打印机')) return '#13c2c2'; // 青色 + + return '#1890ff'; // 默认蓝色 + }; + + const getDeviceStyle = (device, rackHeight) => { + // 添加参数验证 + if (!device || typeof device.position !== 'number' || typeof device.height !== 'number') { + return {}; + } + + const uHeight = 18; // 每个U的高度(像素) + const deviceHeight = Math.max(1, device.height) * uHeight; + + // 设备位置从底部开始计算(U1在底部) + const position = Math.max(1, Math.min(device.position, rackHeight)); + const deviceUHeight = Math.max(1, device.height); + + // 计算设备的顶部位置(从机柜顶部算起) + // 设备占用从 position 到 position + height - 1 的U + // 机柜顶部是U0,所以设备顶部的topPosition是: + const deviceBottomU = position; // 设备底部U数 + const deviceTopU = position + deviceUHeight - 1; // 设备顶部U数 + const topPosition = (rackHeight - deviceTopU - 1) * uHeight; + + return { + height: `${deviceHeight}px`, // 确保设备高度精确等于U位高度 + top: `${topPosition}px`, // 确保设备顶部与U位网格线对齐 + // 移除任何可能影响占满U位的样式 + margin: 0, + padding: 0 + }; + }; + + // 格式化日期 + const formatDate = (dateString) => { + if (!dateString) return '未知'; + try { + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN'); + } catch (error) { + return '无效日期'; + } + }; + + // 获取设备类型中文名称 + const getDeviceTypeName = (type) => { + const typeMap = { + 'server': '服务器', + 'switch': '交换机', + 'router': '路由器', + 'storage': '存储设备', + 'ups': 'UPS', + 'firewall': '防火墙', + 'other': '其他设备' + }; + return typeMap[type?.toLowerCase()] || '未知设备'; + }; + + // 生成U数标记(每个U都有标记) + const generateUMarks = (height) => { + const marks = []; + const uHeight = 18; + + for (let i = 1; i <= height; i++) { + marks.push( +
+ + {i} + +
+ ); + } + + return marks; + }; + + // 生成左侧U数标记(从实际范围开始显示) + const generateLeftUMarks = (height) => { + const marks = []; + const uHeight = 18; + + // 从实际U位范围开始(通常是4-42U或类似范围) + const startU = Math.max(1, height - 40); // 假设机柜有40U左右,从较高位置开始显示 + + for (let i = 1; i <= height; i++) { + const actualU = startU + i - 1; + if (actualU <= 42) { // 只显示到42U + marks.push( +
+ {/* U位网格背景 */} +
+ {/* U位序号 */} +
+ {actualU} +
+
+ ); + } + } + + return marks; + }; + + // 生成右侧U数标记(从实际范围开始显示) + const generateRightUMarks = (height) => { + const marks = []; + const uHeight = 18; + + // 从实际U位范围开始(通常是4-42U或类似范围) + const startU = Math.max(1, height - 40); // 假设机柜有40U左右,从较高位置开始显示 + + for (let i = 1; i <= height; i++) { + const actualU = startU + i - 1; + if (actualU <= 42) { // 只显示到42U + marks.push( +
+ {/* U位网格背景 */} +
+ {/* U位序号 */} +
+ {actualU} +
+
+ ); + } + } + + return marks; + }; + + // 选择机柜 + const handleRackChange = (rackId) => { + const rack = racks.find(r => r.rackId === rackId); + if (rack) { + setSelectedRack(rack); + fetchDevices(rackId); + } + }; + + // 重新加载数据 + const handleReload = () => { + setLoading(true); + fetchRacks().then(() => { + message.success('数据已刷新'); + }); + }; + + // 重置视角 + const handleResetView = () => { + setZoom(1); + setRotation(0); + }; + + // 放大 + const handleZoomIn = () => { + setZoom(prev => Math.min(prev + 0.2, 2.5)); + }; + + // 缩小 + const handleZoomOut = () => { + setZoom(prev => Math.max(prev - 0.2, 0.5)); + }; + + // 无需额外的初始化,CSS 3D变换直接在JSX中实现 + + return ( +
+ + + + + } + > + + + + + + + + {loading ? ( +
+ 加载机柜数据中... +
+ ) : selectedRack ? ( +
+ {loadingDevices && ( +
+
+ 加载设备数据中... +
+
+ )} +
+ {/* 机柜主体 */} +
+ {/* 前面板 */} +
+ {/* 左侧U数标记 */} +
+ {generateLeftUMarks(selectedRack.height)} +
+ + {/* 右侧U数标记 */} +
+ {generateRightUMarks(selectedRack.height)} +
+ + {/* 原有的U数标记(内部) */} + {generateUMarks(selectedRack.height)} + + {/* 设备 */} + {/* 调试信息 */} +
+ 设备数量: {devices.length} +
+ + {devices.length > 0 ? devices.map(device => { + try { + // 处理统一化后的设备数据 + const deviceId = device?.deviceId || device?.id || device?.device_id || device?.device || `device-${Math.random()}`; + const deviceName = device?.name || device?.deviceName || device?.device_name || device?.title || '未知设备'; + const position = device?.position || 1; + const height = device?.height || 1; + + return ( +
{ + e.currentTarget.style.transform = 'translateZ(25px) scale(1.01)'; + e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.4)'; + + // 清除之前的定时器 + if (hoverTimeout) { + clearTimeout(hoverTimeout); + setHoverTimeout(null); + } + + // 延迟显示设备信息,避免快速移动时的闪烁 + const timeout = setTimeout(() => { + // 使用设备ID确保状态设置正确 + setHoveredDevice({ + ...device, + _key: device.deviceId || device.id || device.device_id || device.device + }); + }, 100); + + setHoverTimeout(timeout); + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateZ(20px) scale(1)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)'; + + // 清除之前的定时器 + if (hoverTimeout) { + clearTimeout(hoverTimeout); + setHoverTimeout(null); + } + + // 延迟隐藏设备信息,给用户一些缓冲时间 + const timeout = setTimeout(() => { + setHoveredDevice(null); + }, 200); // 增加延迟时间到200ms + + setHoverTimeout(timeout); + }} + > + {/* 左侧指示灯区域 */} +
+ {/* 状态指示灯 */} +
+
+
+
+ + {/* 中间模块/接口槽位区域 */} +
+ {/* 模拟接口槽位 */} +
+ {Array.from({length: Math.min(6, Math.max(3, Math.floor(height * 1.5)))}, (_, i) => ( +
+ ))} +
+ + {/* 设备图标和名称 - 居中显示 */} +
+ + {getDeviceIcon(device.type)} + + + {deviceName} + +
+
+ + {/* 右侧U高度标签 */} +
+ {height}U +
+
+ ); + } catch (error) { + console.error('设备渲染错误:', error, device); + // 渲染一个简单的错误显示元素 + return ( +
+ 设备加载错误 +
+ ); + } + }) : ( +
+ 暂无设备数据 +
+ )} +
+ + {/* 侧面 */} +
+ + {/* 机柜顶部 */} +
+ + {/* 机柜名称 */} +
+ {selectedRack.name} ({selectedRack.rackId}) +
+
+ + {/* 设备详情面板 */} + {hoveredDevice && ( +
{ + // 鼠标进入面板时,清除隐藏定时器 + if (hoverTimeout) { + clearTimeout(hoverTimeout); + setHoverTimeout(null); + } + e.stopPropagation(); // 防止事件冒泡 + }} + onMouseLeave={(e) => { + // 鼠标离开面板时,延迟隐藏 + const timeout = setTimeout(() => { + setHoveredDevice(null); + }, 300); // 增加延迟时间 + setHoverTimeout(timeout); + e.stopPropagation(); // 防止事件冒泡 + }} + onMouseMove={(e) => { + // 鼠标在面板内移动时,保持面板打开 + if (hoverTimeout) { + clearTimeout(hoverTimeout); + setHoverTimeout(null); + } + e.stopPropagation(); + }} + > + +
+
+ {getDeviceIcon(hoveredDevice.type)} +
+
+

+ {hoveredDevice.name || hoveredDevice.deviceName || hoveredDevice.device_name || '未知设备'} +

+

+ {getDeviceTypeName(hoveredDevice.type)} +

+
+
+ +
+
+ 设备类型: + {getDeviceTypeName(hoveredDevice.type)} +
+ +
+ U位位置: + + U{hoveredDevice.position} - U{hoveredDevice.position + (hoveredDevice.height || 1) - 1} + +
+ +
+ 设备高度: + {hoveredDevice.height || 1}U +
+ +
+ 运行状态: + + {hoveredDevice.status === 'running' ? '运行中' : '已停止'} + +
+ + {hoveredDevice.serialNumber && ( +
+ 序列号: + + {hoveredDevice.serialNumber} + +
+ )} + + {hoveredDevice.installDate && ( +
+ 安装日期: + + {formatDate(hoveredDevice.installDate)} + +
+ )} + + {hoveredDevice.model && ( +
+ 设备型号: + + {hoveredDevice.model} + +
+ )} + + {hoveredDevice.ipAddress && ( +
+ IP地址: + + {hoveredDevice.ipAddress} + +
+ )} +
+ + {/* 关闭按钮 */} +
setHoveredDevice(null)} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = '#e6f7ff'; + e.currentTarget.style.color = '#1890ff'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = '#f5f5f5'; + e.currentTarget.style.color = '#999'; + }} + > + × +
+
+ )} +
+
+ ) : ( +
+ 请选择机柜 +
+ )} + + +
+ ); +} + +export default RackVisualization; \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8fe8591..a6748bc 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -7,7 +7,7 @@ export default defineConfig({ port: 3000, proxy: { '/api': { - target: 'http://localhost:3001', + target: 'http://localhost:8000', changeOrigin: true } }