feat: 优化3D可视化性能并更新文档

- 实现3D可视化性能优化(降低DPR、阴影贴图、简化光源)
- 实现LOD多级细节系统,根据相机距离自动切换
- 添加设备弹出动画开关,默认关闭
- 修复设备缩放时位置偏移问题
- 更新README添加项目截图占位符
- 精简DEPLOYMENT.md文档,添加Gitee仓库支持
- 更新CHANGELOG.md记录v1.1.0版本
This commit is contained in:
zhang1106
2026-01-26 17:21:20 +08:00
parent 6e57e0b74c
commit a0ad9ad604
44 changed files with 4212 additions and 5448 deletions
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IDC设备管理系统</title>
<!-- 预加载字体,确保本地资源可用 -->
<link rel="preload" href="/fonts/Inter-Regular.woff2" as="font" type="font/woff2" crossorigin>
</head>
<body>
<div id="root"></div>
+836 -95
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"antd": "^5.8.6",
"axios": "^1.5.0",
"dayjs": "^1.11.19",
@@ -17,7 +19,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"three": "^0.160.0",
"three": "^0.160.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
Binary file not shown.
Binary file not shown.
+8 -7
View File
@@ -1,6 +1,6 @@
import React, { useState, Suspense, lazy } from 'react';
import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider, ConfigProvider as AntdConfigProvider } from 'antd';
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined, ApiOutlined, PartitionOutlined } from '@ant-design/icons';
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined, ApiOutlined, PartitionOutlined, CodepenOutlined } from '@ant-design/icons';
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import { ConfigProvider, useConfig } from './context/ConfigContext';
@@ -11,7 +11,7 @@ const DeviceManagement = lazy(() => import('./pages/DeviceManagement'));
const RackManagement = lazy(() => import('./pages/RackManagement'));
const RoomManagement = lazy(() => import('./pages/RoomManagement'));
const DeviceFieldManagement = lazy(() => import('./pages/DeviceFieldManagement'));
const RackVisualization = lazy(() => import('./pages/RackVisualization'));
const Rack3DVisualization = lazy(() => import('./pages/Rack3DVisualization'));
const ConsumableManagement = lazy(() => import('./pages/ConsumableManagement'));
const ConsumableStatistics = lazy(() => import('./pages/ConsumableStatistics'));
const ConsumableLogs = lazy(() => import('./pages/ConsumableLogs'));
@@ -145,7 +145,8 @@ const AppLayout = ({ children }) => {
const getSelectedKey = () => {
const path = location.pathname;
if (path === '/') return 'dashboard';
if (path.startsWith('/rooms') || path.startsWith('/racks') || path.startsWith('/visualization')) return 'room-management';
if (path.startsWith('/visualization-3d')) return 'visualization-3d';
if (path.startsWith('/rooms') || path.startsWith('/racks')) return 'room-management';
if (path.startsWith('/devices') || path.startsWith('/fields') || path.startsWith('/cables') || path.startsWith('/ports')) return 'asset-management';
if (path.startsWith('/consumables')) return 'consumables-management';
if (path.startsWith('/users') || path.startsWith('/login-history') || path.startsWith('/operation-logs') || path.startsWith('/settings')) return 'system-management';
@@ -225,9 +226,9 @@ const AppLayout = ({ children }) => {
label: <Link to="/racks">机柜管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/visualization">机柜可视化</Link>,
key: 'visualization-3d',
icon: <CodepenOutlined style={{ fontSize: '16px' }} />,
label: <Link to="/visualization-3d">3D机柜可视化</Link>,
},
],
},
@@ -579,7 +580,7 @@ const ThemeConfig = () => {
<Route path="/racks" element={<PrivateRoute><RackManagement /></PrivateRoute>} />
<Route path="/rooms" element={<PrivateRoute><RoomManagement /></PrivateRoute>} />
<Route path="/fields" element={<PrivateRoute><DeviceFieldManagement /></PrivateRoute>} />
<Route path="/visualization" element={<PrivateRoute><RackVisualization /></PrivateRoute>} />
<Route path="/visualization-3d" element={<PrivateRoute><Rack3DVisualization /></PrivateRoute>} />
<Route path="/consumables" element={<PrivateRoute><ConsumableManagement /></PrivateRoute>} />
<Route path="/consumables-categories" element={<PrivateRoute><CategoryManagement /></PrivateRoute>} />
<Route path="/consumables-stats" element={<PrivateRoute><ConsumableStatistics /></PrivateRoute>} />
Binary file not shown.
Binary file not shown.
+837
View File
@@ -0,0 +1,837 @@
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
const PERFORMANCE_MODE = true;
const InstancedStatusLights = ({ count, positions, colors: statusColors, zOffset }) => {
const meshRef = useRef();
const colorArray = useMemo(() => {
const arr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const color = new THREE.Color(statusColors[i] || '#22c55e');
arr[i * 3] = color.r;
arr[i * 3 + 1] = color.g;
arr[i * 3 + 2] = color.b;
}
return arr;
}, [count, statusColors]);
const dummy = useMemo(() => new THREE.Object3D(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y, positions[i].z + zOffset);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
}
}, [count, positions, zOffset, dummy]);
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<circleGeometry args={[0.0015, 8]} />
<meshBasicMaterial toneMapped={false} />
</instancedMesh>
);
};
const InstancedDriveBays = ({ count, positions, color, hasDetail = true }) => {
const meshRef = useRef();
const detailRef = useRef();
const dummy = useMemo(() => new THREE.Object3D(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y, positions[i].z);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
}
if (detailRef.current && hasDetail) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x + 0.04, positions[i].y, positions[i].z + 0.003);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
detailRef.current.setMatrixAt(i, dummy.matrix);
}
detailRef.current.instanceMatrix.needsUpdate = true;
}
}, [count, positions, hasDetail, dummy]);
return (
<group>
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.07, 0.035, 0.004]} />
<meshStandardMaterial color={color || '#334155'} roughness={0.6} metalness={0.4} />
</instancedMesh>
{hasDetail && (
<instancedMesh ref={detailRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.015, 0.025, 0.002]} />
<meshStandardMaterial color="#1e293b" />
</instancedMesh>
)}
</group>
);
};
const InstancedStorageBays = ({ count, positions, color }) => {
const meshRef = useRef();
const detailRef = useRef();
const dummy = useMemo(() => new THREE.Object3D(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y, positions[i].z);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
}
if (detailRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x + 0.03, positions[i].y, positions[i].z + 0.003);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
detailRef.current.setMatrixAt(i, dummy.matrix);
}
detailRef.current.instanceMatrix.needsUpdate = true;
}
}, [count, positions, dummy]);
return (
<group>
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.095, 0.028, 0.005]} />
<meshStandardMaterial color={color || '#334155'} metalness={0.6} roughness={0.4} />
</instancedMesh>
<instancedMesh ref={detailRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.02, 0.015, 0.002]} />
<meshStandardMaterial color="#000" />
</instancedMesh>
</group>
);
};
const InstancedRJ45Ports = ({ count, positions, statuses, frontZ }) => {
const meshRef = useRef();
const innerRef = useRef();
const tabRef = useRef();
const ledRef = useRef();
const dummy = useMemo(() => new THREE.Object3D(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y + 0.012, frontZ + 0.006);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
}
if (innerRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y + 0.012, frontZ + 0.007);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
innerRef.current.setMatrixAt(i, dummy.matrix);
}
innerRef.current.instanceMatrix.needsUpdate = true;
}
if (tabRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y + 0.015, frontZ + 0.008);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
tabRef.current.setMatrixAt(i, dummy.matrix);
}
tabRef.current.instanceMatrix.needsUpdate = true;
}
if (ledRef.current) {
for (let i = 0; i < count; i++) {
const status = statuses[i] || 'disconnected';
const ledY = positions[i].y + (i % 2 === 0 ? 0.021 : -0.002);
dummy.position.set(positions[i].x - 0.004, ledY, frontZ + 0.006);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
ledRef.current.setMatrixAt(i, dummy.matrix);
}
ledRef.current.instanceMatrix.needsUpdate = true;
}
}, [count, positions, statuses, frontZ, dummy]);
const ledColors = useMemo(() => {
return statuses.map(s => s !== 'disconnected' ? (s === 'fault' ? '#ef4444' : '#22c55e') : '#475569');
}, [statuses]);
return (
<group>
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.011, 0.011, 0.004]} />
<meshStandardMaterial color="#cbd5e1" metalness={0.8} />
</instancedMesh>
<instancedMesh ref={innerRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.009, 0.009, 0.002]} />
<meshStandardMaterial color="#000" />
</instancedMesh>
<instancedMesh ref={tabRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.007, 0.001, 0.001]} />
<meshBasicMaterial color="#facc15" />
</instancedMesh>
<instancedMesh ref={ledRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.002, 0.002, 0.001]} />
<meshBasicMaterial toneMapped={false} />
<instancedBufferAttribute attach="geometry-attributes-color" args={[new Float32Array(count * 3), 3]} />
</instancedMesh>
</group>
);
};
const InstancedSFPports = ({ count, positions, statuses, frontZ }) => {
const meshRef = useRef();
const innerRef = useRef();
const connectorRef = useRef();
const ledRef = useRef();
const dummy = useMemo(() => new THREE.Object3D(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.006);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
}
if (innerRef.current) {
for (let i = 0; i < count; i++) {
dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.009);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
innerRef.current.setMatrixAt(i, dummy.matrix);
}
innerRef.current.instanceMatrix.needsUpdate = true;
}
if (connectorRef.current) {
for (let i = 0; i < count; i++) {
const isConnected = statuses[i] !== 'disconnected';
dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.012);
dummy.scale.set(isConnected ? 1 : 0.001, 1, 1);
dummy.updateMatrix();
connectorRef.current.setMatrixAt(i, dummy.matrix);
}
connectorRef.current.instanceMatrix.needsUpdate = true;
}
if (ledRef.current) {
for (let i = 0; i < count; i++) {
const isConnected = statuses[i] !== 'disconnected';
dummy.position.set(positions[i].x, positions[i].y + 0.01, frontZ + 0.009);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
ledRef.current.setMatrixAt(i, dummy.matrix);
}
ledRef.current.instanceMatrix.needsUpdate = true;
}
}, [count, positions, statuses, frontZ, dummy]);
const ledColors = useMemo(() => {
return positions.map((_, i) => statuses[i] !== 'disconnected' ? '#22c55e' : '#475569');
}, [positions, statuses]);
return (
<group>
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.02, 0.015, 0.005]} />
<meshStandardMaterial color="#cbd5e1" metalness={0.9} roughness={0.3} />
</instancedMesh>
<instancedMesh ref={innerRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.016, 0.011, 0.002]} />
<meshStandardMaterial color="#1e293b" />
</instancedMesh>
<instancedMesh ref={connectorRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.01, 0.002, 0.008]} />
<meshStandardMaterial color="#3b82f6" />
</instancedMesh>
<instancedMesh ref={ledRef} args={[undefined, undefined, count]}>
<coneGeometry args={[0.002, 0.004, 3]} />
<meshBasicMaterial />
</instancedMesh>
</group>
);
};
const FirewallFace = ({ device, height, frontZ, isSelected }) => {
const halfWidth = 0.4826 / 2;
return (
<group>
<mesh position={[-halfWidth + 0.02, 0, frontZ + 0.006]}>
<boxGeometry args={[0.01, height, 0.003]} />
<meshStandardMaterial color="#ef4444" emissive="#ef4444" emissiveIntensity={0.3} />
</mesh>
<mesh position={[0, 0, frontZ + 0.0055]}>
<boxGeometry args={[0.46, height - 0.004, 0.002]} />
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.6} />
</mesh>
<group position={[-0.08, 0, frontZ + 0.007]}>
<mesh>
<boxGeometry args={[0.12, 0.03, 0.002]} />
<meshStandardMaterial color="#000000" roughness={0.1} metalness={0.8} />
</mesh>
<group position={[0, 0, 0.002]}>
<mesh>
<boxGeometry args={[0.10, 0.001, 0.001]} />
<meshBasicMaterial color="#10b981" transparent opacity={0.5} />
</mesh>
</group>
</group>
<group position={[0.1, 0, frontZ + 0.007]}>
{!PERFORMANCE_MODE && Array.from({ length: 4 }).map((_, col) => (
<mesh key={col} position={[-0.03 + col * 0.02, 0, 0]}>
<circleGeometry args={[0.008, 6]} />
<meshBasicMaterial color="#1a202c" />
</mesh>
))}
{!PERFORMANCE_MODE && (
<pointLight position={[0, 0, -0.01]} color="#f97316" intensity={0.8} distance={0.2} />
)}
</group>
<group position={[0.2, 0.01, frontZ + 0.008]}>
{['#22c55e', '#22c55e', device.status === 'error' ? '#ef4444' : '#4b5563'].map((color, i) => (
<mesh key={i} position={[0, -i * 0.01, 0]}>
<circleGeometry args={[0.002, 8]} />
<meshBasicMaterial color={color} />
{i === 2 && device.status === 'error' && (
<pointLight color="#ef4444" intensity={1} distance={0.05} />
)}
</mesh>
))}
</group>
</group>
);
};
const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight: propUHeight, position, rackDepth, slideEnabled = true }) => {
const mesh = useRef();
const [hovered, setHover] = useState(false);
const [isExtended, setIsExtended] = useState(false);
const [currentZ, setCurrentZ] = useState(0);
// 使用传入的 uHeight (默认为 0.04445m)
const uHeight = propUHeight || 0.04445;
const depth = rackDepth || 1.0; // 机柜深度,默认1米
// 滑轨动画 - 仅在展开时运行且slideEnabled为true
useFrame(() => {
if (!slideEnabled || (!isExtended && Math.abs(currentZ) < 0.01)) {
if (currentZ !== 0) setCurrentZ(0);
return;
}
const targetZ = isExtended ? 0.6 : 0;
const lerpFactor = 0.1;
setCurrentZ(prev => prev + (targetZ - prev) * lerpFactor);
});
// 尺寸定义 (适配标准 0.6m 机柜)
// 标准19英寸机柜内部净宽约 0.45m,设备面板宽 0.4826m (19inch)
const chassisWidth = 0.44; // 机身宽度 440mm
const chassisDepth = 0.8; // 机身深度 800mm
const panelWidth = 0.4826; // 前面板宽度 482.6mm (19英寸)
const panelDepth = 0.02; // 前面板厚度 20mm
// 计算实际位置(防止超出机柜)
// 优先使用父组件传入的 position,如果未传入则内部计算(作为 fallback)
// 注意:父组件 RackModel 已经计算好了 position=[0, yPos, 0]
// 我们只需要处理设备的高度
const dHeight = device.height || device.u_height || 1;
const height = dHeight * uHeight;
// 间隙调整:减少设备上下间隙,使其更饱满,减少视觉偏差
const gap = 0.002; // 总间隙 2mm
// Z轴定位
// 假设机柜中心为0,前沿为 depth/2
// 设备前面板应该贴近前沿
const frontZ = depth / 2 - 0.02;
const panelZ = frontZ - panelDepth / 2;
const chassisZ = frontZ - panelDepth - chassisDepth / 2;
// 颜色定义 (参考 CSS 变量)
const colors = {
server: '#3b82f6', // --color-device-server
switch: '#22c55e', // --color-device-switch
router: '#f59e0b', // --color-device-router
firewall: '#ef4444', // --color-device-firewall
storage: '#8b5cf6', // --color-device-storage
default: '#3b82f6',
status: {
running: '#10b981', // --color-status-running
warning: '#f59e0b', // --color-status-warning
error: '#ef4444', // --color-status-error
offline: '#6b7280' // --color-status-offline
},
panelBg: '#1e293b', // 深色面板背景
panelLight: '#334155',
text: '#f1f5f9'
};
const getDeviceColor = (type) => {
const t = type?.toLowerCase() || '';
if (t.includes('server') || t.includes('服务器')) return colors.server;
if (t.includes('switch') || t.includes('交换机')) return colors.switch;
if (t.includes('router') || t.includes('路由器')) return colors.router;
if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall;
if (t.includes('storage') || t.includes('存储')) return colors.storage;
return colors.default;
};
const deviceColor = getDeviceColor(device.type);
const statusColor = colors.status[device.status] || colors.status.running;
// 渲染服务器前面板细节
const renderServerFace = () => {
const is2U = height > 0.08;
const driveRows = is2U ? 2 : 1;
const driveCols = PERFORMANCE_MODE ? 3 : 4;
const driveWidth = 0.07;
const driveHeight = 0.035;
const drivePositions = [];
for (let row = 0; row < driveRows; row++) {
for (let col = 0; col < driveCols; col++) {
if (!is2U && row > 0) continue;
const xPos = (col - 1) * (driveWidth + 0.005);
const yPos = is2U ? (row === 0 ? 0.02 : -0.02) : 0;
drivePositions.push({ x: xPos, y: yPos, z: 0 });
}
}
return (
<group>
<mesh position={[0, 0, frontZ + 0.0055]}>
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
<meshStandardMaterial color="#1e293b" roughness={0.7} metalness={0.5} />
</mesh>
<group position={[-0.18, 0, frontZ + 0.006]}>
<mesh position={[-0.02, 0, 0]}>
<boxGeometry args={[0.04, height - 0.01, 0.002]} />
<meshStandardMaterial color="#000000" roughness={0.2} />
</mesh>
<group position={[-0.025, 0.01, 0.002]}>
<mesh rotation={[Math.PI/2, 0, 0]}>
<cylinderGeometry args={[0.004, 0.004, 0.002, 16]} />
<meshStandardMaterial color="#cbd5e1" metalness={0.8} />
</mesh>
{!PERFORMANCE_MODE && (
<pointLight color="#22c55e" intensity={0.5} distance={0.05} />
)}
<mesh position={[0, 0, 0.0011]} rotation={[Math.PI/2, 0, 0]}>
<cylinderGeometry args={[0.002, 0.002, 0.001, 16]} />
<meshBasicMaterial color={device.status === 'offline' ? '#4b5563' : '#22c55e'} />
</mesh>
</group>
<mesh position={[-0.015, 0.01, 0.002]} rotation={[Math.PI/2, 0, 0]}>
<cylinderGeometry args={[0.002, 0.002, 0.002, 16]} />
<meshStandardMaterial color="#3b82f6" />
</mesh>
<mesh position={[-0.02, -0.01, 0.002]}>
<boxGeometry args={[0.006, 0.012, 0.002]} />
<meshStandardMaterial color="#475569" />
</mesh>
<mesh position={[-0.02, -0.01, 0.001]}>
<boxGeometry args={[0.004, 0.01, 0.001]} />
<meshBasicMaterial color="#000" />
</mesh>
</group>
<group position={[0.05, 0, frontZ + 0.006]}>
<InstancedDriveBays
count={drivePositions.length}
positions={drivePositions}
color="#334155"
hasDetail={!PERFORMANCE_MODE}
/>
</group>
<group position={[0.2, 0, frontZ + 0.006]}>
<mesh position={[0, 0, 0.002]} rotation={[0, 0, Math.PI/2]}>
<cylinderGeometry args={[0.005, 0.005, 0.002, 6]} />
<meshStandardMaterial color="#3b82f6" />
</mesh>
<mesh position={[0, 0, 0.003]}>
<boxGeometry args={[0.012, 0.006, 0.001]} />
<meshBasicMaterial color="#000" />
</mesh>
</group>
</group>
);
};
// 渲染存储设备前面板细节
const renderStorageFace = () => {
const rows = PERFORMANCE_MODE ? 2 : 3;
const cols = PERFORMANCE_MODE ? 2 : 4;
const bayWidth = 0.10;
const bayHeight = 0.028;
const bayPositions = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const xPos = (col - 1) * (bayWidth + 0.002);
const yStep = (height - 0.02) / rows;
const yPos = (rows - 1 - row) * yStep - (rows - 1) * yStep / 2;
bayPositions.push({ x: xPos, y: yPos, z: 0 });
}
}
return (
<group>
<mesh position={[0, 0, frontZ + 0.0055]}>
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
<meshStandardMaterial color="#0f172a" roughness={0.8} />
</mesh>
<group position={[0, 0, frontZ + 0.008]}>
<InstancedStorageBays
count={bayPositions.length}
positions={bayPositions}
color="#334155"
/>
</group>
</group>
);
};
// 渲染交换机前面板细节
const renderSwitchFace = () => {
const getPortStatus = (portName) => {
if (!device.cables || !Array.isArray(device.cables)) return 'disconnected';
const cable = device.cables.find(c =>
(c.sourceDeviceId === device.deviceId && c.sourcePort === portName) ||
(c.targetDeviceId === device.deviceId && c.targetPort === portName)
);
return cable ? (cable.status || 'normal') : 'disconnected';
};
const sfpCount = PERFORMANCE_MODE ? 2 : 4;
const rj45GroupCount = PERFORMANCE_MODE ? 2 : 4;
const rj45ColCount = PERFORMANCE_MODE ? 4 : 6;
const sfpPositions = [];
const sfpStatuses = [];
for (let i = 0; i < sfpCount; i++) {
sfpPositions.push({ x: -0.13 + i * 0.025, y: -0.005, z: 0 });
sfpStatuses.push(getPortStatus(`SFP+ ${i + 1}`));
}
const rj45PortCount = rj45GroupCount * rj45ColCount * 2;
const rj45Positions = [];
const rj45Statuses = [];
for (let groupIndex = 0; groupIndex < rj45GroupCount; groupIndex++) {
const groupX = -0.02 + groupIndex * 0.09;
for (let colIndex = 0; colIndex < rj45ColCount; colIndex++) {
const portX = -0.03 + colIndex * 0.012;
const globalCol = groupIndex * rj45ColCount + colIndex;
const topPortNum = globalCol * 2 + 1;
const bottomPortNum = globalCol * 2 + 2;
const topStatus = getPortStatus(`Port ${topPortNum}`);
const bottomStatus = getPortStatus(`Port ${bottomPortNum}`);
rj45Positions.push({ x: groupX + portX, y: 0, z: 0 });
rj45Statuses.push(topStatus);
rj45Positions.push({ x: groupX + portX, y: -0.024, z: 0 });
rj45Statuses.push(bottomStatus);
}
}
return (
<group>
<mesh position={[0, 0, frontZ + 0.0055]}>
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
<meshStandardMaterial color="#334155" roughness={0.6} metalness={0.4} />
</mesh>
<group position={[-0.19, 0, frontZ + 0.006]}>
<mesh position={[0, 0, 0]}>
<boxGeometry args={[0.05, height - 0.015, 0.002]} />
<meshStandardMaterial color="#1e293b" />
</mesh>
<mesh position={[-0.015, 0.005, 0.002]}>
<boxGeometry args={[0.012, 0.01, 0.002]} />
<meshStandardMaterial color="#94a3b8" />
</mesh>
<mesh position={[-0.015, 0.012, 0.002]}>
<boxGeometry args={[0.012, 0.002, 0.001]} />
<meshBasicMaterial color="#3b82f6" />
</mesh>
<mesh position={[0.015, 0.005, 0.002]}>
<boxGeometry args={[0.008, 0.012, 0.002]} />
<meshStandardMaterial color="#cbd5e1" />
</mesh>
<group position={[0, -0.01, 0.002]}>
{['SYS', 'PWR'].map((label, idx) => (
<group key={label} position={[(idx - 0.5) * 0.02, 0, 0]}>
<mesh>
<circleGeometry args={[0.0015, 8]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
</group>
))}
</group>
</group>
<group position={[-0.13, -0.005, 0]}>
<InstancedSFPports
count={sfpCount}
positions={sfpPositions}
statuses={sfpStatuses}
frontZ={frontZ}
/>
</group>
<group position={[-0.02, 0, 0]}>
<InstancedRJ45Ports
count={rj45PortCount}
positions={rj45Positions}
statuses={rj45Statuses}
frontZ={frontZ}
/>
</group>
</group>
);
};
// 渲染设备背板
const renderBackPanel = () => {
// Back face Z position (flush with back of chassis)
const backZ = chassisZ - chassisDepth / 2;
// Rotate 180 deg to face backwards
return (
<group position={[0, 0, backZ]} rotation={[0, Math.PI, 0]}>
{/* 基础背板 - 透明玻璃质感 (Reduced quality for performance) */}
<mesh position={[0, 0, 0.001]}>
<boxGeometry args={[chassisWidth, height - gap, 0.002]} />
<meshStandardMaterial
color="#94a3b8"
transparent
opacity={0.3}
roughness={0.2}
metalness={0.8}
/>
</mesh>
{/* 电源模块 (PSU) - 左侧 (从背面看是右侧,但我们旋转了) */}
{/* 2个 PSU 垂直排列或并排 */}
<group position={[0.25, 0, 0.002]}>
{[-0.02, 0.02].map((yOffset, i) => (
<group key={i} position={[0, height > 0.15 ? yOffset * 2 : 0, 0]}>
{/* 如果高度够大(>1U),垂直排列,否则只显示一个或者水平排列 */}
{/* 简化:1U设备只显示左边一个,2U显示两个 */}
{(height > 0.15 || i === 0) && (
<group position={[i === 1 && height <= 0.15 ? -0.06 : 0, 0, 0]}>
{/* PSU 面板 */}
<mesh>
<boxGeometry args={[0.05, height > 0.15 ? 0.04 : 0.08, 0.004]} />
<meshStandardMaterial color="#94a3b8" metalness={0.8} roughness={0.4} />
</mesh>
{/* 把手 */}
<mesh position={[0, 0, 0.004]}>
<boxGeometry args={[0.01, 0.02, 0.004]} />
<meshStandardMaterial color="#000000" />
</mesh>
{/* 电源插口 C13 */}
<mesh position={[0.015, 0, 0.002]}>
<boxGeometry args={[0.012, 0.01, 0.002]} />
<meshStandardMaterial color="#1a202c" />
</mesh>
{/* 状态灯 */}
<mesh position={[-0.015, 0.01, 0.002]}>
<circleGeometry args={[0.002, 8]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
</group>
)}
</group>
))}
</group>
{/* 风扇模块 (Fan Modules) - 中间 */}
{/* 3-4个风扇阵列 */}
<group position={[0, 0, 0.002]}>
{Array.from({ length: 3 }).map((_, i) => (
<group key={i} position={[-0.1 + i * 0.1, 0, 0]}>
{/* 风扇网罩 */}
<mesh>
<boxGeometry args={[0.08, height - 0.03, 0.001]} />
<meshStandardMaterial color="#1e293b" />
</mesh>
{/* 风扇叶片模拟 (纹理) */}
<mesh position={[0, 0, 0.001]}>
<circleGeometry args={[Math.min(0.035, (height-0.03)/2), 8]} />
<meshBasicMaterial color="#334155" />
</mesh>
{/* 红色拉手 (热插拔) */}
<mesh position={[0, -0.01, 0.003]}>
<boxGeometry args={[0.01, 0.02, 0.002]} />
<meshStandardMaterial color="#ef4444" />
</mesh>
</group>
))}
</group>
{/* 网卡/扩展模块 (PCIe/LOM) - 右侧 */}
<group position={[-0.25, 0, 0.002]}>
{/* 竖向 PCIe 挡板 */}
{Array.from({ length: 2 }).map((_, i) => (
<group key={i} position={[i * 0.08, 0, 0]}>
<mesh>
<boxGeometry args={[0.02, height - 0.02, 0.001]} />
<meshStandardMaterial color="#cbd5e1" metalness={0.9} />
</mesh>
{/* 端口 (SFP+ or RJ45) */}
<mesh position={[0, 0.01, 0.002]}>
<boxGeometry args={[0.012, 0.012, 0.002]} />
<meshStandardMaterial color="#000000" />
</mesh>
<mesh position={[0, -0.01, 0.002]}>
<boxGeometry args={[0.012, 0.012, 0.002]} />
<meshStandardMaterial color="#000000" />
</mesh>
</group>
))}
</group>
</group>
);
};
const renderDeviceFace = () => {
const type = device.type?.toLowerCase() || '';
if (type.includes('server') || type.includes('服务器')) return renderServerFace();
if (type.includes('switch') || type.includes('交换机')) return renderSwitchFace();
if (type.includes('storage') || type.includes('存储')) return renderStorageFace();
if (type.includes('firewall') || type.includes('防火墙') || type.includes('router') || type.includes('路由器')) {
return <FirewallFace device={device} height={height} frontZ={frontZ} isSelected={isSelected} />;
}
// 默认通用设备样式
return (
<group>
{/* 默认面板纹理 */}
<mesh position={[0, 0, frontZ + 0.005]}>
<boxGeometry args={[0.7, height - gap - 0.01, 0.002]} />
<meshStandardMaterial color="#1e293b" roughness={0.6} />
</mesh>
{/* 装饰线 */}
<mesh position={[0, 0, frontZ + 0.006]}>
<boxGeometry args={[0.6, 0.005, 0.001]} />
<meshStandardMaterial color={deviceColor} />
</mesh>
</group>
);
};
return (
<group position={position || [0, 0, 0]}>
<group
position={[0, 0, currentZ]}
onClick={(e) => {
e.stopPropagation();
setIsExtended(!isExtended);
onClick && onClick(device);
}}
onPointerOver={(e) => {
e.stopPropagation();
setHover(true);
onHover && onHover(device);
}}
onPointerOut={(e) => {
setHover(false);
onHover && onHover(null);
}}
>
{/* 机身 (Chassis) - 深色金属 */}
<mesh ref={mesh} position={[0, 0, chassisZ]}>
<boxGeometry args={[chassisWidth, height - gap, chassisDepth]} />
<meshStandardMaterial
color="#333333" // 深灰色哑光金属
roughness={0.9} // 哑光
metalness={0.3}
/>
</mesh>
{/* 前面板底座 (Front Panel Base) */}
<mesh position={[0, 0, panelZ]}>
<boxGeometry args={[panelWidth, height - gap, panelDepth]} />
<meshStandardMaterial
color={hovered || isSelected ? "#666666" : "#555555"} // 稍浅的灰色塑料质感
roughness={0.8}
metalness={0.1}
/>
</mesh>
{/* 告警时的红色辉光 (设备两侧) */}
{(device.status === 'error' || device.status === 'fault') && (
<>
<pointLight position={[-0.4, 0, frontZ]} color="#ff0000" intensity={0.8} distance={0.3} />
<pointLight position={[0.4, 0, frontZ]} color="#ff0000" intensity={0.8} distance={0.3} />
</>
)}
{/* 设备特定前面板细节 */}
{renderDeviceFace()}
{/* 设备背板细节 */}
{renderBackPanel()}
</group>
{/* 设备名称 (悬浮显示,避免遮挡细节) - 暂时禁用 */}
{/* {hovered && (
<Text
position={[0, height/2 + 0.02, frontZ + 0.05]}
fontSize={0.04}
color="white"
anchorX="center"
anchorY="bottom"
outlineWidth={0.002}
outlineColor="#000000"
>
{device.name}
</Text>
)} */}
{/* 状态指示灯 (统一位置) */}
<group position={[panelWidth/2 - 0.03, 0, frontZ + 0.01]}>
{/* 灯座 */}
<mesh position={[0, 0, -0.002]}>
<circleGeometry args={[0.01, 16]} />
<meshStandardMaterial color="#333" />
</mesh>
{/* 发光体 */}
<mesh>
<circleGeometry args={[0.006, 16]} />
<meshBasicMaterial color={statusColor} toneMapped={false} />
</mesh>
{/* 光晕效果 */}
<pointLight color={statusColor} intensity={0.5} distance={0.1} />
</group>
</group>
);
};
export default DeviceModel;
+152
View File
@@ -0,0 +1,152 @@
import React, { useMemo, useRef } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
export const LOD_LEVELS = {
HIGH: 0,
MEDIUM: 1,
LOW: 2
};
export const LOD_DISTANCES = {
HIGH: 5,
MEDIUM: 10
};
const createSimplifiedDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => {
const depth = rackDepth || 1.0;
const dHeight = device.height || device.u_height || 1;
const height = dHeight * uHeight;
const chassisWidth = 0.44;
const chassisDepth = 0.8;
const panelWidth = 0.4826;
const panelDepth = 0.02;
const frontZ = depth / 2 - 0.02;
const halfHeight = height / 2;
return (
<group>
<mesh position={[0, 0, frontZ - panelDepth / 2]}>
<boxGeometry args={[panelWidth, height - 0.002, panelDepth]} />
<meshStandardMaterial color={deviceColor} roughness={0.8} metalness={0.1} />
</mesh>
<mesh position={[0, 0, frontZ - panelDepth - chassisDepth / 2]}>
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
</mesh>
<mesh position={[panelWidth/2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
<circleGeometry args={[0.006, 16]} />
<meshBasicMaterial color={statusColor} toneMapped={false} />
</mesh>
</group>
);
};
const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => {
const depth = rackDepth || 1.0;
const dHeight = device.height || device.u_height || 1;
const height = dHeight * uHeight;
const chassisWidth = 0.44;
const chassisDepth = 0.8;
const panelWidth = 0.4826;
const panelDepth = 0.02;
const frontZ = depth / 2 - 0.02;
const is2U = height > 0.08;
const halfHeight = height / 2;
return (
<group>
<mesh position={[0, 0, frontZ + 0.0055]}>
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
<meshStandardMaterial color="#1e293b" roughness={0.7} metalness={0.5} />
</mesh>
<mesh position={[0, 0, frontZ - panelDepth / 2]}>
<boxGeometry args={[panelWidth, height - 0.002, panelDepth]} />
<meshStandardMaterial color={deviceColor} roughness={0.8} metalness={0.1} />
</mesh>
<mesh position={[0, 0, frontZ - panelDepth - chassisDepth / 2]}>
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
</mesh>
<group position={[-0.18, 0, frontZ + 0.006]}>
<mesh position={[-0.02, 0, 0]}>
<boxGeometry args={[0.04, height - 0.01, 0.002]} />
<meshStandardMaterial color="#000000" roughness={0.2} />
</mesh>
<mesh position={[0, 0.01, 0.002]}>
<boxGeometry args={[0.012, 0.01, 0.002]} />
<meshStandardMaterial color="#cbd5e1" />
</mesh>
</group>
<group position={[0.05, 0, frontZ + 0.006]}>
{Array.from({ length: is2U ? 4 : 2 }).map((_, i) => (
<mesh key={i} position={[(i - 1) * 0.08, 0, 0]}>
<boxGeometry args={[0.06, 0.03, 0.004]} />
<meshStandardMaterial color="#334155" roughness={0.6} />
</mesh>
))}
</group>
<mesh position={[panelWidth/2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
<circleGeometry args={[0.006, 16]} />
<meshBasicMaterial color={statusColor} toneMapped={false} />
</mesh>
</group>
);
};
const LODManager = ({
device,
uHeight,
rackDepth,
position,
deviceColor,
statusColor,
children,
level = LOD_LEVELS.HIGH
}) => {
const groupRef = useRef();
const { camera } = useThree();
const [lodLevel, setLodLevel] = React.useState(LOD_LEVELS.HIGH);
const distanceRef = useRef(0);
useFrame(() => {
if (groupRef.current) {
const distance = camera.position.distanceTo(groupRef.current.position);
distanceRef.current = distance;
let newLevel = LOD_LEVELS.HIGH;
if (distance > LOD_DISTANCES.MEDIUM) {
newLevel = LOD_LEVELS.LOW;
} else if (distance > LOD_DISTANCES.HIGH) {
newLevel = LOD_LEVELS.MEDIUM;
}
if (newLevel !== lodLevel) {
setLodLevel(newLevel);
}
}
});
if (level !== LOD_LEVELS.HIGH) {
return children;
}
const renderLODMesh = () => {
switch (lodLevel) {
case LOD_LEVELS.LOW:
return createSimplifiedDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor);
case LOD_LEVELS.MEDIUM:
return createMediumDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor);
default:
return null;
}
};
return (
<group ref={groupRef} position={position}>
{lodLevel === LOD_LEVELS.HIGH ? children : renderLODMesh()}
</group>
);
};
export default LODManager;
+166
View File
@@ -0,0 +1,166 @@
import React, { useMemo } from 'react';
import DeviceModel from './DeviceModel';
import LODManager, { LOD_LEVELS } from './LODManager';
const RackModel = ({
rack,
devices = [],
selectedDeviceId,
onDeviceClick,
onDeviceLeave,
onDeviceHover,
onEditDevice,
onAddNic,
onAddPort,
tooltipFields,
deviceSlideEnabled = true
}) => {
const width = 0.6;
const depth = 1.0;
const uHeight = 0.04445;
const postWidth = 0.05;
const rackHeight = rack?.height || 45;
const height = rackHeight * uHeight + 0.2;
// 颜色映射
const colors = {
server: '#3b82f6',
switch: '#22c55e',
router: '#f59e0b',
firewall: '#ef4444',
storage: '#8b5cf6',
default: '#3b82f6',
status: {
running: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
offline: '#6b7280'
}
};
const getDeviceColor = (type) => {
const t = type?.toLowerCase() || '';
if (t.includes('server') || t.includes('服务器')) return colors.server;
if (t.includes('switch') || t.includes('交换机')) return colors.switch;
if (t.includes('router') || t.includes('路由器')) return colors.router;
if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall;
if (t.includes('storage') || t.includes('存储')) return colors.storage;
return colors.default;
};
// 生成机柜框架
const frame = useMemo(() => {
const materialProps = { color: "#333", roughness: 0.5, metalness: 0.8 };
const postArgs = [postWidth, height, postWidth];
const topBottomArgs = [width + 0.02, 0.02, depth + 0.02];
return (
<group>
<mesh position={[-width/2 + postWidth/2, height/2, -depth/2 + postWidth/2]}>
<boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[width/2 - postWidth/2, height/2, -depth/2 + postWidth/2]}>
<boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[-width/2 + postWidth/2, height/2, depth/2 - postWidth/2]}>
<boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[width/2 - postWidth/2, height/2, depth/2 - postWidth/2]}>
<boxGeometry args={postArgs} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, height, 0]}>
<boxGeometry args={topBottomArgs} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0, 0]}>
<boxGeometry args={topBottomArgs} />
<meshStandardMaterial {...materialProps} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={`side-${side}`} position={[side * (width/2 - 0.005), height/2, 0]}>
<boxGeometry args={[0.01, height - 0.04, depth - 0.04]} />
<meshStandardMaterial
color="#2d3748"
roughness={0.4}
metalness={0.7}
side={2}
/>
</mesh>
))}
<mesh position={[0, height/2, -depth/2 + 0.005]}>
<boxGeometry args={[width - 0.04, height - 0.04, 0.01]} />
<meshPhysicalMaterial
color="#e2e8f0"
transparent
opacity={0.2}
roughness={0}
metalness={0.9}
transmission={0.8}
thickness={0.01}
/>
<mesh position={[0.2, 0, 0.01]}>
<boxGeometry args={[0.02, 0.15, 0.02]} />
<meshStandardMaterial color="#333" />
</mesh>
</mesh>
</group>
);
}, [width, height, depth, postWidth]);
return (
<group position={[0, 0.5, 0]}>
{frame}
<group position={[0, 0.1, 0]}>
{devices.map((device) => {
const uStart = device.position || device.u_position || 1;
const uSize = device.height || device.u_height || 1;
const yPos = (uStart - 1) * uHeight + (uSize * uHeight) / 2;
const deviceColor = getDeviceColor(device.type);
const statusColor = colors.status[device.status] || colors.status.running;
return (
<LODManager
key={device.id}
device={device}
uHeight={uHeight}
rackDepth={depth}
position={[0, yPos, 0]}
deviceColor={deviceColor}
statusColor={statusColor}
level={LOD_LEVELS.HIGH}
>
<DeviceModel
device={device}
uHeight={uHeight}
rackDepth={depth}
position={[0, 0, 0]}
isSelected={selectedDeviceId === device.id}
onClick={onDeviceClick}
onPointerOver={onDeviceHover}
onPointerOut={onDeviceLeave}
onEdit={onEditDevice}
onAddNic={onAddNic}
onAddPort={onAddPort}
tooltipFields={tooltipFields}
slideEnabled={deviceSlideEnabled}
/>
</LODManager>
);
})}
</group>
</group>
);
};
export default RackModel;
+67
View File
@@ -0,0 +1,67 @@
import React, { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
const envMapUrl = '/assets/3d/env.hdr';
import RackModel from './RackModel';
const Scene = ({ rack, devices, selectedDeviceId, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled = true }) => {
return (
<Canvas shadows dpr={[1, 1.2]} performance={{ min: 0.5 }}>
<PerspectiveCamera makeDefault position={[3, 2, 4]} fov={50} />
<ambientLight intensity={0.5} color="#ffffff" />
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
<directionalLight
position={[10, 10, 5]}
intensity={1}
castShadow
shadow-mapSize={[1024, 1024]}
shadow-camera-far={20}
shadow-camera-left={-10}
shadow-camera-right={10}
shadow-camera-top={10}
shadow-camera-bottom={-10}
/>
<Suspense fallback={null}>
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
</Suspense>
{/* Models */}
<group position={[0, 0, 0]}>
<RackModel
rack={rack}
devices={devices}
selectedDeviceId={selectedDeviceId}
onDeviceClick={onDeviceClick}
onDeviceLeave={onDeviceLeave}
onDeviceHover={onDeviceHover}
tooltipFields={tooltipFields}
deviceSlideEnabled={deviceSlideEnabled}
/>
</group>
{/* Controls */}
<OrbitControls
makeDefault
minPolarAngle={0}
maxPolarAngle={Math.PI / 1.75}
enablePan={true}
enableZoom={true}
enableRotate={true}
mouseButtons={{
LEFT: 0, // 左键旋转
MIDDLE: 2, // 中键平移
RIGHT: 0 // 右键禁用
}}
touches={{
ONE: 1, // 单指旋转
TWO: 2 // 双指平移
}}
target={[0, (rack?.height || 45) * 0.04445 / 2 + 0.1, 0]} // Focus on middle of rack
/>
</Canvas>
);
};
export default Scene;
@@ -0,0 +1,158 @@
export const MATERIAL_TYPES = {
RACK_FRAME: 'rack_frame',
RAIL: 'rail',
RAIL_HOLE: 'rail_hole',
SIDE_PANEL: 'side_panel',
TOP_PLATE: 'top_plate',
BOTTOM_PLATE: 'bottom_plate',
DEVICE_CHASSIS: 'device_chassis',
DEVICE_PANEL: 'device_panel',
DEVICE_PANEL_SELECTED: 'device_panel_selected',
DRIVE_TRAY: 'drive_tray',
DRIVE_TRAY_HANDLE: 'drive_tray_handle',
LED_INDICATOR: 'led_indicator',
LED_ERROR: 'led_error',
SFP_PORT: 'sfp_port',
RJ45_PORT: 'rj45_port',
VENT_HOLE: 'vent_hole',
BACK_PANEL: 'back_panel',
PSU_MODULE: 'psu_module',
FAN_MODULE: 'fan_module',
TEXT_LABEL: 'text_label',
};
export const MATERIAL_CONFIGS = {
[MATERIAL_TYPES.RACK_FRAME]: {
color: '#2a2a2a',
metalness: 0.85,
roughness: 0.4,
envMapIntensity: 1.0,
},
[MATERIAL_TYPES.RAIL]: {
color: '#3a3a3a',
metalness: 0.9,
roughness: 0.25,
envMapIntensity: 1.2,
},
[MATERIAL_TYPES.RAIL_HOLE]: {
color: '#000000',
metalness: 0.0,
roughness: 0.9,
},
[MATERIAL_TYPES.SIDE_PANEL]: {
color: '#1a1a1a',
metalness: 0.7,
roughness: 0.6,
envMapIntensity: 0.8,
},
[MATERIAL_TYPES.TOP_PLATE]: {
color: '#222222',
metalness: 0.8,
roughness: 0.35,
envMapIntensity: 1.0,
},
[MATERIAL_TYPES.BOTTOM_PLATE]: {
color: '#222222',
metalness: 0.8,
roughness: 0.35,
envMapIntensity: 1.0,
},
[MATERIAL_TYPES.DEVICE_CHASSIS]: {
color: '#333333',
metalness: 0.4,
roughness: 0.85,
},
[MATERIAL_TYPES.DEVICE_PANEL]: {
color: '#555555',
metalness: 0.15,
roughness: 0.75,
},
[MATERIAL_TYPES.DEVICE_PANEL_SELECTED]: {
color: '#666666',
metalness: 0.2,
roughness: 0.7,
},
[MATERIAL_TYPES.DRIVE_TRAY]: {
color: '#0f172a',
metalness: 0.3,
roughness: 0.8,
},
[MATERIAL_TYPES.DRIVE_TRAY_HANDLE]: {
color: '#334155',
metalness: 0.5,
roughness: 0.5,
},
[MATERIAL_TYPES.LED_INDICATOR]: {
color: '#10b981',
emissive: '#10b981',
emissiveIntensity: 0.8,
metalness: 0.0,
roughness: 0.3,
toneMapped: false,
},
[MATERIAL_TYPES.LED_ERROR]: {
color: '#ef4444',
emissive: '#ef4444',
emissiveIntensity: 1.0,
metalness: 0.0,
roughness: 0.2,
toneMapped: false,
},
[MATERIAL_TYPES.SFP_PORT]: {
color: '#3b82f6',
metalness: 0.8,
roughness: 0.2,
transparent: true,
opacity: 0.6,
},
[MATERIAL_TYPES.RJ45_PORT]: {
color: '#1e293b',
metalness: 0.6,
roughness: 0.4,
},
[MATERIAL_TYPES.VENT_HOLE]: {
color: '#1a202c',
metalness: 0.2,
roughness: 0.9,
},
[MATERIAL_TYPES.BACK_PANEL]: {
color: '#64748b',
metalness: 0.8,
roughness: 0.3,
transparent: true,
opacity: 0.35,
},
[MATERIAL_TYPES.PSU_MODULE]: {
color: '#475569',
metalness: 0.85,
roughness: 0.35,
},
[MATERIAL_TYPES.FAN_MODULE]: {
color: '#1e293b',
metalness: 0.4,
roughness: 0.7,
},
[MATERIAL_TYPES.TEXT_LABEL]: {
color: '#ffffff',
metalness: 0.0,
roughness: 1.0,
},
};
export const DEVICE_TYPE_COLORS = {
server: '#3b82f6',
switch: '#22c55e',
router: '#f59e0b',
firewall: '#ef4444',
storage: '#8b5cf6',
default: '#3b82f6',
};
export const STATUS_COLORS = {
running: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
offline: '#6b7280',
maintenance: '#8b5cf6',
fault: '#ef4444',
};
@@ -0,0 +1,90 @@
import * as THREE from 'three';
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
export const createDeviceChassisMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_CHASSIS];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createDevicePanelMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createDevicePanelSelectedMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL_SELECTED];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createDriveTrayMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createDriveTrayHandleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY_HANDLE];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createLedIndicatorMaterial = (color = '#10b981', options = {}) => {
return <meshBasicMaterial color={color} toneMapped={false} {...options} />;
};
export const createLedErrorMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.LED_ERROR];
return <meshBasicMaterial color={config.color} toneMapped={false} {...options} />;
};
export const createSfpPortMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SFP_PORT];
return <meshPhysicalMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} transparent opacity={config.opacity} {...options} />;
};
export const createRj45PortMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RJ45_PORT];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createVentHoleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.VENT_HOLE];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createBackPanelMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BACK_PANEL];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} transparent opacity={config.opacity} {...options} />;
};
export const createPsuModuleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.PSU_MODULE];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createFanModuleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.FAN_MODULE];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createConsolePortMaterial = (color = '#facc15') => {
return <meshStandardMaterial color={color} metalness={0.6} roughness={0.4} />;
};
export const createUsbPortMaterial = (color = '#94a3b8') => {
return <meshStandardMaterial color={color} metalness={0.5} roughness={0.5} />;
};
export const createResetButtonMaterial = (color = '#ef4444') => {
return <meshStandardMaterial color={color} metalness={0.3} roughness={0.6} />;
};
export const createPcieCoverMaterial = (color = '#cbd5e1') => {
return <meshStandardMaterial color={color} metalness={0.9} roughness={0.3} />;
};
export const createPortLedMaterial = (color = '#4ade80') => {
return <meshBasicMaterial color={color} transparent opacity={0.8} />;
};
export const createPortLinkLedMaterial = (color = '#22c55e') => {
return <meshBasicMaterial color={color} transparent opacity={1.0} />;
};
@@ -0,0 +1,4 @@
export * from './constants.js';
export * from './utils.js';
export * from './rackFrame.js';
export * from './devicePanel.js';
@@ -0,0 +1,48 @@
import * as THREE from 'three';
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
export const createRackFrameMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RACK_FRAME];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
};
export const createRailMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
};
export const createRailHoleMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL_HOLE];
return <meshBasicMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
};
export const createSidePanelMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SIDE_PANEL];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
};
export const createTopPlateMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.TOP_PLATE];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
};
export const createBottomPlateMaterial = (options = {}) => {
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BOTTOM_PLATE];
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
};
export const createTextLabelMaterial = (color = '#ffffff') => {
return <meshBasicMaterial color={color} side={THREE.DoubleSide} />;
};
export const createUMarkMaterial = () => {
return <meshBasicMaterial color="#ffffff" side={THREE.DoubleSide} />;
};
export const createOutlineMaterial = (color = '#3b82f6', opacity = 0.8) => {
return <lineBasicMaterial color={color} transparent opacity={opacity} />;
};
export const createSelectionMaterial = (color = '#3b82f6', opacity = 0.3) => {
return <meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} />;
};
@@ -0,0 +1,26 @@
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
export const getMaterialConfig = (type) => {
return MATERIAL_CONFIGS[type] || null;
};
export const getMaterialType = (type) => {
return MATERIAL_TYPES[type] || null;
};
export const createMaterialConfig = (type, options = {}) => {
const baseConfig = MATERIAL_CONFIGS[type];
if (!baseConfig) {
console.warn(`Material type "${type}" not found in configurations`);
return null;
}
return { ...baseConfig, ...options };
};
export const mergeMaterialOptions = (type, options = {}) => {
const baseConfig = MATERIAL_CONFIGS[type];
if (!baseConfig) {
return options;
}
return { ...baseConfig, ...options };
};
@@ -0,0 +1,255 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Form, Select, Input, Button, message, Space, Spin } from 'antd';
import { SwapOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Option } = Select;
const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [devices, setDevices] = useState([]);
const [sourcePorts, setSourcePorts] = useState([]);
const [targetPorts, setTargetPorts] = useState([]);
const [fetchingDevices, setFetchingDevices] = useState(false);
const prevVisibleRef = useRef(false);
// Initialize when visible changes from false to true
useEffect(() => {
if (visible && !prevVisibleRef.current) {
form.resetFields();
if (sourceDevice) {
form.setFieldsValue({
sourceDeviceId: sourceDevice.deviceId || sourceDevice.id,
});
fetchDevicePorts(sourceDevice.deviceId || sourceDevice.id, 'source');
}
fetchDevices();
}
prevVisibleRef.current = visible;
}, [visible, sourceDevice, form]);
const fetchDevices = async () => {
try {
setFetchingDevices(true);
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
setDevices(response.data.devices || []);
} catch (error) {
console.error('Failed to fetch devices:', error);
message.error('获取设备列表失败');
} finally {
setFetchingDevices(false);
}
};
const fetchDevicePorts = async (deviceId, type) => {
if (!deviceId) {
if (type === 'source') setSourcePorts([]);
else setTargetPorts([]);
return;
}
try {
const response = await axios.get(`/api/device-ports/device/${deviceId}`);
const ports = response.data || [];
if (type === 'source') {
setSourcePorts(ports);
} else {
setTargetPorts(ports);
}
} catch (error) {
console.error(`Failed to fetch ${type} ports:`, error);
message.error('获取端口列表失败');
}
};
const handleSourceDeviceChange = (deviceId) => {
form.setFieldsValue({ sourcePort: undefined });
fetchDevicePorts(deviceId, 'source');
};
const handleTargetDeviceChange = (deviceId) => {
form.setFieldsValue({ targetPort: undefined });
fetchDevicePorts(deviceId, 'target');
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
// Logic to ensure Switch is always Source if possible (for Cable Management view consistency)
const sourceDev = devices.find(d => d.deviceId === values.sourceDeviceId);
const targetDev = devices.find(d => d.deviceId === values.targetDeviceId);
let payload = { ...values };
// If Source is NOT Switch AND Target IS Switch, swap them
if (sourceDev && targetDev &&
sourceDev.type !== 'switch' &&
targetDev.type === 'switch') {
payload = {
...values,
sourceDeviceId: values.targetDeviceId,
sourcePort: values.targetPort,
targetDeviceId: values.sourceDeviceId,
targetPort: values.sourcePort
};
console.log('Swapped source/target to ensure Switch is Source');
}
await axios.post('/api/cables', payload);
message.success('接线创建成功');
onSuccess?.();
onClose();
} catch (error) {
if (error.errorFields) return;
console.error('Failed to create cable:', error);
message.error('接线创建失败');
} finally {
setLoading(false);
}
};
return (
<Modal
title={
<Space>
<SwapOutlined style={{ color: '#1890ff' }} />
<span>新增接线</span>
</Space>
}
open={visible}
onCancel={onClose}
onOk={handleSubmit}
confirmLoading={loading}
width={600}
maskClosable={false}
>
<Form form={form} layout="vertical">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Source Side */}
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>源设备 (起点)</div>
<Form.Item
name="sourceDeviceId"
label="设备"
rules={[{ required: true, message: '请选择源设备' }]}
>
<Select
placeholder="选择源设备"
showSearch
filterOption={(input, option) =>
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
}
onChange={handleSourceDeviceChange}
loading={fetchingDevices}
disabled={!!sourceDevice} // Lock source device if provided
>
{devices.map(d => (
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="sourcePort"
label="端口"
rules={[{ required: true, message: '请选择源端口' }]}
>
<Select placeholder="选择源端口" showSearch>
{sourcePorts.map(p => (
<Option key={p.portId} value={p.portName}>
{p.portName} ({p.portType})
</Option>
))}
</Select>
</Form.Item>
</div>
{/* Target Side */}
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>目标设备 (终点)</div>
<Form.Item
name="targetDeviceId"
label="设备"
rules={[{ required: true, message: '请选择目标设备' }]}
>
<Select
placeholder="选择目标设备"
showSearch
filterOption={(input, option) =>
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
}
onChange={handleTargetDeviceChange}
loading={fetchingDevices}
>
{devices.filter(d => d.deviceId !== form.getFieldValue('sourceDeviceId')).map(d => (
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="targetPort"
label="端口"
rules={[{ required: true, message: '请选择目标端口' }]}
>
<Select
placeholder="选择目标端口"
showSearch
disabled={!form.getFieldValue('targetDeviceId')}
>
{targetPorts.map(p => (
<Option key={p.portId} value={p.portName}>
{p.portName} ({p.portType})
</Option>
))}
</Select>
</Form.Item>
</div>
</div>
<div style={{ marginTop: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
<Form.Item
name="cableType"
label="线缆类型"
initialValue="ethernet"
rules={[{ required: true }]}
>
<Select>
<Option value="ethernet">网线</Option>
<Option value="fiber">光纤</Option>
<Option value="copper">铜缆</Option>
</Select>
</Form.Item>
<Form.Item
name="status"
label="状态"
initialValue="normal"
rules={[{ required: true }]}
>
<Select>
<Option value="normal">正常</Option>
<Option value="fault">故障</Option>
<Option value="disconnected">未连接</Option>
</Select>
</Form.Item>
<Form.Item
name="cableLength"
label="长度 (米)"
>
<Input type="number" min={0} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} />
</Form.Item>
</div>
</Form>
</Modal>
);
};
export default CableCreateModal;
-371
View File
@@ -1,371 +0,0 @@
import React from 'react';
import {
CloudServerOutlined, SwitcherOutlined, DatabaseOutlined,
CloudOutlined, LaptopOutlined, MobileOutlined,
PrinterOutlined
} from '@ant-design/icons';
// 设备图标映射
const getDeviceIcon = (deviceType) => {
try {
if (!deviceType) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
const type = deviceType.toLowerCase();
if (type.includes('server') || type.includes('服务器')) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
if (type.includes('switch') || type.includes('交换机')) return <SwitcherOutlined style={{ color: '#ffffff' }} />;
if (type.includes('storage') || type.includes('存储')) return <DatabaseOutlined style={{ color: '#ffffff' }} />;
if (type.includes('router') || type.includes('路由器')) return <CloudOutlined style={{ color: '#ffffff' }} />;
if (type.includes('laptop') || type.includes('笔记本')) return <LaptopOutlined style={{ color: '#ffffff' }} />;
if (type.includes('mobile') || type.includes('手机')) return <MobileOutlined style={{ color: '#ffffff' }} />;
if (type.includes('printer') || type.includes('打印机')) return <PrinterOutlined style={{ color: '#ffffff' }} />;
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
} catch (error) {
console.error('设备图标渲染错误:', error);
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
}
};
// 设备状态颜色映射
const getDeviceStatusColor = (status) => {
const statusColorMap = {
'normal': '#10b981',
'running': '#10b981',
'warning': '#f59e0b',
'error': '#ef4444',
'fault': '#ef4444',
'offline': '#6b7280',
'maintenance': '#3b82f6',
undefined: '#3b82f6',
null: '#3b82f6'
};
return statusColorMap[status] || '#3b82f6';
};
// 设备状态主题
const getStatusTheme = (status) => {
const themeMap = {
'normal': {
bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)',
borderColor: '#10b981',
topBorderColor: '#34d399',
glowColor: 'rgba(16, 185, 129, 0.4)',
shadowColor: 'rgba(16, 185, 129, 0.3)',
iconColor: '#10b981',
label: '正常'
},
'running': {
bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)',
borderColor: '#10b981',
topBorderColor: '#34d399',
glowColor: 'rgba(16, 185, 129, 0.4)',
shadowColor: 'rgba(16, 185, 129, 0.3)',
iconColor: '#10b981',
label: '运行中'
},
'warning': {
bgGradient: 'linear-gradient(180deg, #d97706 0%, #b45309 50%, #92400e 100%)',
borderColor: '#f59e0b',
topBorderColor: '#fbbf24',
glowColor: 'rgba(245, 158, 11, 0.4)',
shadowColor: 'rgba(245, 158, 11, 0.3)',
iconColor: '#f59e0b',
label: '警告'
},
'error': {
bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)',
borderColor: '#ef4444',
topBorderColor: '#f87171',
glowColor: 'rgba(239, 68, 68, 0.5)',
shadowColor: 'rgba(239, 68, 68, 0.4)',
iconColor: '#ef4444',
label: '故障'
},
'fault': {
bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)',
borderColor: '#ef4444',
topBorderColor: '#f87171',
glowColor: 'rgba(239, 68, 68, 0.5)',
shadowColor: 'rgba(239, 68, 68, 0.4)',
iconColor: '#ef4444',
label: '故障'
},
'offline': {
bgGradient: 'linear-gradient(180deg, #4b5563 0%, #374151 50%, #1f2937 100%)',
borderColor: '#6b7280',
topBorderColor: '#9ca3af',
glowColor: 'rgba(107, 114, 128, 0.2)',
shadowColor: 'rgba(0, 0, 0, 0.2)',
iconColor: '#9ca3af',
label: '离线'
},
'maintenance': {
bgGradient: 'linear-gradient(180deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%)',
borderColor: '#3b82f6',
topBorderColor: '#60a5fa',
glowColor: 'rgba(59, 130, 246, 0.4)',
shadowColor: 'rgba(59, 130, 246, 0.3)',
iconColor: '#3b82f6',
label: '维护中'
},
'default': {
bgGradient: 'linear-gradient(180deg, #3d4451 0%, #2d3139 50%, #252930 100%)',
borderColor: '#4a5568',
topBorderColor: '#565c6b',
glowColor: 'rgba(56, 189, 248, 0.2)',
shadowColor: 'rgba(0, 0, 0, 0.3)',
iconColor: '#38bdf8',
label: '未知'
}
};
return themeMap[status] || themeMap['default'];
};
// 设备样式计算
const getDeviceStyle = (device, rackHeight) => {
// 添加参数验证
if (!device || typeof device.position !== 'number' || typeof device.height !== 'number') {
return {};
}
const uHeight = 25; // 调整为更小的U高度以适应屏幕显示,1U=25px
const deviceHeight = Math.max(1, device.height) * uHeight;
// 设备位置从底部开始计算(U1在底部)
let position = Math.max(1, device.position);
let deviceUHeight = Math.max(1, device.height);
// 确保设备不会超出机柜范围
if (position + deviceUHeight - 1 > rackHeight) {
// 如果设备会超出机柜,调整位置或高度
position = Math.max(1, rackHeight - deviceUHeight + 1);
}
// 计算设备的顶部位置(从机柜顶部算起)
// 设备占用从 position 到 position + height - 1 的U
// 机柜顶部是U0,所以设备顶部的topPosition是:
const deviceBottomU = position; // 设备底部U数
const deviceTopU = position + deviceUHeight - 1; // 设备顶部U数
const topPosition = (rackHeight - deviceTopU) * uHeight;
return {
height: `${deviceHeight}px`, // 确保设备高度精确等于U位高度
top: `${topPosition}px`, // 确保设备顶部与U位网格线对齐
// 移除任何可能影响占满U位的样式
margin: 0,
padding: 0
};
};
// 设备组件
const DeviceComponent = ({ device, rackHeight, isHighlighted, onMouseEnter, onMouseLeave }) => {
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;
// 获取状态主题
const statusTheme = getStatusTheme(device?.status);
const statusColor = getDeviceStatusColor(device?.status);
return (
<div
key={deviceId}
className={`device ${isHighlighted ? 'highlighted' : ''} ${device?.status === 'warning' ? 'status-warning' : ''} ${(device?.status === 'error' || device?.status === 'fault') ? 'status-error' : ''}`}
style={{
...getDeviceStyle(device, rackHeight),
background: statusTheme.bgGradient,
border: isHighlighted
? `2px solid ${statusTheme.topBorderColor}`
: `1px solid ${statusTheme.borderColor}`,
borderTop: isHighlighted
? `2px solid ${statusTheme.topBorderColor}`
: `1px solid ${statusTheme.topBorderColor}`
}}
onMouseEnter={(e) => {
const isOneU = (device?.height || 1) === 1;
const isFaultStatus = device?.status === 'error' || device?.status === 'fault';
if (isOneU && !isFaultStatus) {
e.currentTarget.style.height = '33px';
e.currentTarget.style.zIndex = '150';
}
e.currentTarget.style.transform = 'scale(1.008)';
e.currentTarget.style.boxShadow = `
0 4px 12px ${statusTheme.shadowColor},
0 2px 6px rgba(0,0,0,0.3),
inset 0 1px 0 rgba(255,255,255,0.15)
`;
e.currentTarget.style.borderColor = statusTheme.topBorderColor;
if (onMouseEnter) {
const rect = e.currentTarget.getBoundingClientRect();
onMouseEnter({ device, position: { x: rect.right + 5, y: rect.top + rect.height / 2 } });
}
}}
onMouseLeave={(e) => {
const isOneU = (device?.height || 1) === 1;
const isFaultStatus = device?.status === 'error' || device?.status === 'fault';
if (isOneU && !isFaultStatus) {
const originalHeight = (device?.height || 1) * 25;
e.currentTarget.style.height = `${originalHeight}px`;
e.currentTarget.style.zIndex = '100';
}
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = `
0 1px 2px rgba(0,0,0,0.3),
0 2px 4px rgba(0,0,0,0.2),
inset 0 1px 0 rgba(255,255,255,0.1),
inset 0 -1px 0 rgba(0,0,0,0.1)
`;
e.currentTarget.style.borderColor = statusTheme.borderColor;
if (onMouseLeave) {
onMouseLeave();
}
}}
>
<div className="device-status-top-bar" style={{
background: `linear-gradient(90deg, ${statusTheme.topBorderColor} 0%, ${statusTheme.borderColor} 50%, ${statusTheme.topBorderColor} 100%)`
}} />
{/* 左侧状态指示区域 - 增强版 */}
<div className="device-status-indicator" style={{
background: isHighlighted
? `linear-gradient(180deg, ${statusTheme.borderColor}33 0%, ${statusTheme.borderColor}22 100%)`
: `linear-gradient(180deg, ${statusTheme.borderColor}44 0%, ${statusTheme.borderColor}22 100%)`,
borderRight: `1px solid ${statusTheme.borderColor}66`
}}>
{/* 设备类型标识 */}
<div className="device-type-badge" style={{
background: `linear-gradient(180deg, ${statusTheme.borderColor}66 0%, ${statusTheme.borderColor}33 100%)`,
border: `1px solid ${statusTheme.borderColor}44`
}}>
<span className="device-type-text" style={{ color: statusTheme.topBorderColor }}>
{device.type?.toUpperCase() || 'DEV'}
</span>
</div>
{/* LED状态指示灯组 */}
<div className="device-leds">
{/* 主状态灯 */}
<div className="main-status-leds">
<div className={`led ${device.status === 'warning' ? 'status-warning' : ''} ${device.status === 'error' ? 'status-error' : ''} ${device.status === 'normal' || device.status === 'running' ? 'status-normal' : 'offline'}`} style={{
backgroundColor: getDeviceStatusColor(device.status),
boxShadow: `0 0 8px ${getDeviceStatusColor(device.status)}`
}} />
<div className={`led ${device.status === 'running' ? 'status-running' : 'offline'}`} />
<div className="led status-running" />
</div>
{/* 电源指示灯 */}
<div className="power-led">
<div className={`power-led-indicator ${device.status !== 'offline' ? 'on' : ''}`} />
<span className={`power-led-text ${device.status !== 'offline' ? 'on' : ''}`}>
PWR
</span>
</div>
</div>
{/* 设备序列号标签 */}
<div className="device-serial">
<span className="device-serial-text">
SN:{device.serial?.slice(-4) || '0000'}
</span>
</div>
</div>
{/* 中间设备信息区域 - 增强版 */}
<div className="device-info">
{/* 设备品牌/厂商标识 */}
<div className="device-brand">
<div className="device-brand-icon">
{getDeviceIcon(device.type)}
</div>
<span className="device-brand-text">
{device.brand || 'ENTERPRISE'}
</span>
</div>
{/* 设备名称 */}
<div className="device-name">
{deviceName}
</div>
{/* 型号和规格 */}
<div className="device-model">
<span className="device-model-text">
{device.model || device.type?.toUpperCase() || 'STD'}
</span>
{device.ip && (
<span className="device-ip">
{device.ip}
</span>
)}
</div>
{/* 散热/通风口装饰 - 根据设备类型显示 */}
{(device.type === 'server' || device.type === 'storage') && (
<div className="device-ventilation">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="ventilation-fin" />
))}
</div>
)}
</div>
{/* 右侧端口/功能区域 - 增强版 */}
<div className="device-ports">
{/* 端口指示灯阵列 */}
<div className="port-leds">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`port-led ${i < 3 ? 'active' : 'inactive'}`} />
))}
</div>
{/* 管理接口标识 */}
<div className="management-interface">
<div className="management-icon" />
<span className="management-text">
MGMT
</span>
</div>
{/* 设备高度U数标识 */}
<div className="device-height">
{device.height}U
</div>
</div>
</div>
);
} catch (error) {
console.error('设备渲染错误:', error, device);
// 渲染一个简单的错误显示元素
return (
<div
key={`error-${Math.random()}`}
style={{
position: 'absolute',
left: '35px',
right: '35px',
top: '50%',
height: '30px',
transform: 'translateY(-50%)',
backgroundColor: '#ff4d4f',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
borderRadius: '4px'
}}
>
设备加载错误
</div>
);
}
};
// 默认导出
export default DeviceComponent;
+64 -33
View File
@@ -1,6 +1,6 @@
import React, { useState, useCallback, useMemo } from 'react';
import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip } from 'antd';
import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip, Button, Popconfirm } from 'antd';
import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined, EditOutlined, PlusCircleOutlined, DeleteOutlined } from '@ant-design/icons';
import NetworkCardPanel from './NetworkCardPanel';
const { Text, Title } = Typography;
@@ -18,7 +18,7 @@ const designTokens = {
}
};
function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables }) {
function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables, onEdit, onAddNic, onAddPort, onAddCable, onDeleteCable, tooltipFields, refreshTrigger }) {
const [activeTab, setActiveTab] = useState('ports');
const deviceCables = useMemo(() => {
@@ -55,6 +55,34 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
return typeMap[type?.toLowerCase()] || type || '未知设备';
}, []);
const renderFieldValue = useCallback((field, device) => {
const fieldKey = field.field;
if (fieldKey === 'status') return getStatusTag(device.status);
let value = device[fieldKey];
if (fieldKey === 'type') value = getDeviceTypeName(value);
else if (fieldKey === 'position') value = `U${device.position} ${device.height ? `(${device.height}U)` : ''}`;
return <Text strong style={{ fontSize: '14px' }}>{value || '-'}</Text>;
}, [getStatusTag, getDeviceTypeName]);
const displayFields = useMemo(() => {
if (tooltipFields && Object.keys(tooltipFields).length > 0) {
return Object.values(tooltipFields).filter(f => f.enabled);
}
// Default fallback fields if no config
return [
{ field: 'deviceId', label: '设备ID' },
{ field: 'type', label: '设备类型' },
{ field: 'status', label: '设备状态' },
{ field: 'position', label: '位置' },
{ field: 'ipAddress', label: 'IP地址' },
{ field: 'brand', label: '品牌' }
];
}, [tooltipFields]);
const tabItems = [
{
key: 'ports',
@@ -69,6 +97,7 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
deviceId={device?.deviceId}
deviceName={device?.name}
onRefresh={onRefreshCables}
refreshTrigger={refreshTrigger}
/>
)
},
@@ -91,6 +120,16 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
key={cable.cableId}
size="small"
style={{ borderRadius: '8px' }}
extra={
<Popconfirm
title="确定要删除这条接线吗?"
onConfirm={() => onDeleteCable?.(cable.cableId)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
}
>
<div style={{ marginBottom: designTokens.spacing.sm }}>
<Space direction="vertical" size={4}>
@@ -150,9 +189,25 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
</Space>
}
placement="right"
width={520}
width={600}
open={visible}
onClose={onClose}
extra={
<Space>
<Tooltip title="编辑设备信息">
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)} />
</Tooltip>
<Tooltip title="添加网卡">
<Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>加网卡</Button>
</Tooltip>
<Tooltip title="添加端口">
<Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>加端口</Button>
</Tooltip>
<Tooltip title="添加接线">
<Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>加接线</Button>
</Tooltip>
</Space>
}
styles={{ body: { padding: '16px 20px', overflow: 'auto' } }}
>
<div className="device-info-section" style={{ marginBottom: '20px' }}>
@@ -165,36 +220,12 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
padding: '16px',
borderRadius: '10px'
}}>
<div className="info-item">
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备ID</Text>
<Text strong style={{ fontSize: '14px' }}>{device.deviceId}</Text>
</div>
<div className="info-item">
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备类型</Text>
<Text strong style={{ fontSize: '14px' }}>{getDeviceTypeName(device.type)}</Text>
</div>
<div className="info-item">
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备状态</Text>
{getStatusTag(device.status)}
</div>
<div className="info-item">
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>位置</Text>
<Text strong style={{ fontSize: '14px' }}>
U{device.position} {device.height && `(${device.height}U)`}
</Text>
</div>
{device.ipAddress && (
<div className="info-item">
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>IP地址</Text>
<Text strong style={{ fontSize: '14px' }}>{device.ipAddress}</Text>
{displayFields.map(field => (
<div className="info-item" key={field.field}>
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>{field.label}</Text>
{renderFieldValue(field, device)}
</div>
)}
{device.brand && (
<div className="info-item">
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>品牌</Text>
<Text strong style={{ fontSize: '14px' }}>{device.brand}</Text>
</div>
)}
))}
</div>
</div>
+2 -2
View File
@@ -18,7 +18,7 @@ const designTokens = {
}
};
function NetworkCardPanel({ deviceId, deviceName, onRefresh }) {
function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
const [cards, setCards] = useState([]);
const [networkCards, setNetworkCards] = useState([]);
const [loading, setLoading] = useState(false);
@@ -56,7 +56,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh }) {
useEffect(() => {
fetchData();
}, [fetchData]);
}, [fetchData, refreshTrigger]);
const handleDeleteCard = useCallback(async (card) => {
try {
+13 -5
View File
@@ -84,9 +84,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
const [previewPorts, setPreviewPorts] = useState([]);
const [showPreview, setShowPreview] = useState(false);
const [nicList, setNicList] = useState([]);
const prevVisibleRef = React.useRef(false);
useEffect(() => {
if (visible) {
// 只有当 visible 从 false 变为 true 时才执行初始化
if (visible && !prevVisibleRef.current) {
setPreviewPorts([]);
setShowPreview(false);
form.resetFields();
@@ -95,12 +97,13 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
form.setFieldsValue({ nicId: defaultNicId });
}
if (device?.deviceId && networkCards.length === 0) {
if (device?.deviceId && (!networkCards || networkCards.length === 0)) {
fetchNetworkCards();
} else if (networkCards.length > 0) {
} else if (networkCards && networkCards.length > 0) {
setNicList(networkCards);
}
}
prevVisibleRef.current = visible;
}, [visible, device, defaultNicId, networkCards, form]);
const fetchNetworkCards = async () => {
@@ -205,6 +208,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
okText={portCount > 1 ? `创建 ${portCount} 个端口` : '创建'}
cancelText="取消"
width={560}
zIndex={1050}
styles={{ body: { padding: '20px 24px' } }}
>
<Form
@@ -259,7 +263,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
label={
<Space>
端口名称
<Tooltip title="支持单个端口(如 eth0/1)或端口范围(如 1/0/1-1/0/48">
<Tooltip title="支持单个端口(如 eth0/1)或端口范围(如 1/0/1-1/0/48" mouseEnterDelay={0.5}>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</Space>
@@ -284,7 +288,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
>
<Input
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
onChange={handlePortNameChange}
onChange={(e) => {
// 确保 Form 值更新
form.setFieldValue('portName', e.target.value);
handlePortNameChange(e);
}}
/>
</Form.Item>
+862
View File
@@ -0,0 +1,862 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Layout, Select, Card, Spin, message, Typography, Descriptions, Tag, Button, Space, Empty, Modal, Form, Input, InputNumber, DatePicker, Checkbox, Switch } from 'antd';
import { CloudServerOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, UpOutlined, DownOutlined, EditOutlined, SettingOutlined, FullscreenOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import Scene from '../components/3d/Scene';
import NetworkCardCreateModal from '../components/NetworkCardCreateModal';
import PortCreateModal from '../components/PortCreateModal';
import CableCreateModal from '../components/CableCreateModal';
import DeviceDetailDrawer from '../components/DeviceDetailDrawer';
const { Header, Content, Sider } = Layout;
const { Title, Text } = Typography;
const { Option } = Select;
const Rack3DVisualization = () => {
const navigate = useNavigate();
const [racks, setRacks] = useState([]);
const [selectedRack, setSelectedRack] = useState(null);
const [selectedRoom, setSelectedRoom] = useState(null);
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(false);
const [loadingDevices, setLoadingDevices] = useState(false);
const [selectedDevice, setSelectedDevice] = useState(null); // Currently selected device in 3D view
const [hoveredDevice, setHoveredDevice] = useState(null); // Currently hovered device
const [isRackInfoCollapsed, setIsRackInfoCollapsed] = useState(false); // Rack Info Card collapsed state
// Edit Modal State
const [modalVisible, setModalVisible] = useState(false);
const [editingDevice, setEditingDevice] = useState(null);
const [form] = Form.useForm();
// Field configuration state
const [tooltipFields, setTooltipFields] = useState({});
const [showTooltipConfig, setShowTooltipConfig] = useState(false);
const [loadingTooltipFields, setLoadingTooltipFields] = useState(false);
const [savingTooltipConfig, setSavingTooltipConfig] = useState(false);
// Add Port/NIC/Cable Modal State
const [nicModalVisible, setNicModalVisible] = useState(false);
const [portModalVisible, setPortModalVisible] = useState(false);
const [cableModalVisible, setCableModalVisible] = useState(false);
const [operatingDevice, setOperatingDevice] = useState(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Device Cables State
const [deviceCables, setDeviceCables] = useState([]);
// Device Slide Animation Toggle
const [deviceSlideEnabled, setDeviceSlideEnabled] = useState(false);
const fetchDeviceCables = useCallback(async (deviceId) => {
if (!deviceId) return;
try {
const response = await axios.get(`/api/cables/device/${deviceId}`);
setDeviceCables(response.data || []);
} catch (error) {
console.error('Failed to fetch device cables:', error);
setDeviceCables([]);
}
}, []);
const handleAddNic = (device) => {
setOperatingDevice(device);
setNicModalVisible(true);
};
const handleAddPort = (device) => {
setOperatingDevice(device);
setPortModalVisible(true);
};
const handleAddCable = (device) => {
setOperatingDevice(device);
setCableModalVisible(true);
};
// Handle Edit Device Click
const handleEditDevice = (device) => {
setEditingDevice(device);
// Prepare form data (similar to DeviceManagement)
const deviceData = { ...device };
if (deviceData.purchaseDate) deviceData.purchaseDate = dayjs(deviceData.purchaseDate);
if (deviceData.warrantyExpiry) deviceData.warrantyExpiry = dayjs(deviceData.warrantyExpiry);
// Fixed fields
const fixedFields = [
'deviceId', 'name', 'type', 'model', 'serialNumber', 'rackId',
'position', 'height', 'powerConsumption', 'status', 'purchaseDate',
'warrantyExpiry', 'ipAddress', 'description'
];
const cleanDeviceData = {};
fixedFields.forEach(field => {
if (deviceData[field] !== undefined) {
cleanDeviceData[field] = deviceData[field];
}
});
// Custom fields if any (simplified for 3D view context)
if (deviceData.customFields && typeof deviceData.customFields === 'object') {
Object.entries(deviceData.customFields).forEach(([key, value]) => {
cleanDeviceData[key] = value;
});
}
form.setFieldsValue(cleanDeviceData);
setModalVisible(true);
};
const handleModalCancel = () => {
setModalVisible(false);
setEditingDevice(null);
form.resetFields();
};
const handleModalSubmit = async (values) => {
try {
const deviceData = {
...values,
purchaseDate: values.purchaseDate ? values.purchaseDate.format('YYYY-MM-DD') : null,
warrantyExpiry: values.warrantyExpiry ? values.warrantyExpiry.format('YYYY-MM-DD') : null,
};
if (editingDevice) {
await axios.put(`/api/devices/${editingDevice.deviceId || editingDevice.id}`, deviceData);
message.success('设备更新成功');
// Refresh devices
if (selectedRack) {
fetchDevices(selectedRack.rackId);
}
setModalVisible(false);
setEditingDevice(null);
}
} catch (error) {
message.error('设备更新失败');
console.error(error);
}
};
// Fetch Racks
const fetchRacks = useCallback(async () => {
try {
setLoading(true);
const response = await axios.get('/api/racks', { params: { pageSize: 1000 } });
const racksArray = response.data.racks || [];
// Filter valid racks
const validRacks = racksArray.filter(r => r && r.rackId && typeof r.height === 'number');
setRacks(validRacks);
if (validRacks.length > 0) {
// Default select first rack
const firstRack = validRacks[0];
setSelectedRack(firstRack);
if (firstRack?.Room) {
const roomKey = firstRack.Room.roomId || firstRack.Room.id || firstRack.Room.name;
setSelectedRoom(roomKey);
}
}
} catch (error) {
console.error('Failed to fetch racks:', error);
message.error('获取机柜列表失败');
} finally {
setLoading(false);
}
}, []);
// Fetch Devices for Rack
const fetchDevices = useCallback(async (rackId) => {
if (!rackId) return;
try {
setLoadingDevices(true);
const response = await axios.get(`/api/devices?rackId=${rackId}`);
let devicesData = [];
if (Array.isArray(response.data)) {
devicesData = response.data;
} else if (response.data && Array.isArray(response.data.data)) {
devicesData = response.data.data;
} else if (response.data && Array.isArray(response.data.devices)) {
devicesData = response.data.devices;
}
// Process devices (ensure position/height are numbers)
const validDevices = devicesData.filter(d => {
if (!d.deviceId && !d.id) return false;
// Normalize deviceId
if (!d.deviceId && d.id) d.deviceId = d.id;
let pos = d.position || d.u_position;
let h = d.height || d.u_height || 1;
if (typeof pos === 'string') pos = parseInt(pos, 10);
if (typeof h === 'string') h = parseInt(h, 10);
// Mutate for consistency
d.position = pos;
d.height = h;
return typeof pos === 'number' && !isNaN(pos);
});
setDevices(validDevices);
} catch (error) {
console.error('Failed to fetch devices:', error);
message.error('获取设备列表失败');
setDevices([]);
} finally {
setLoadingDevices(false);
}
}, []);
// Fetch Tooltip Fields from API
const fetchTooltipDeviceFields = useCallback(async () => {
try {
setLoadingTooltipFields(true);
const response = await axios.get('/api/deviceFields');
const fields = response.data;
const newTooltipFields = {};
if (fields && fields.length > 0) {
fields.forEach(field => {
newTooltipFields[field.fieldName] = {
label: field.displayName,
enabled: field.visible,
field: field.fieldName,
fieldType: field.fieldType || 'text'
};
});
}
setTooltipFields(newTooltipFields);
} catch (error) {
console.error('Failed to fetch device fields:', error);
message.error('获取字段配置失败');
} finally {
setLoadingTooltipFields(false);
}
}, []);
const saveTooltipConfig = async () => {
try {
setSavingTooltipConfig(true);
// Transform object to array for backend API
const fieldConfigs = Object.values(tooltipFields).map(field => ({
fieldName: field.field,
visible: field.enabled,
displayName: field.label,
fieldType: field.fieldType
}));
await axios.post('/api/deviceFields/config', fieldConfigs);
message.success('字段配置保存成功');
setShowTooltipConfig(false);
// Refresh fields to sync with DB
fetchTooltipDeviceFields();
} catch (error) {
console.error('Failed to save tooltip config:', error);
message.error('保存失败');
} finally {
setSavingTooltipConfig(false);
}
};
useEffect(() => {
fetchRacks();
fetchTooltipDeviceFields();
}, [fetchRacks, fetchTooltipDeviceFields]);
useEffect(() => {
if (selectedRack?.rackId) {
fetchDevices(selectedRack.rackId);
setSelectedDevice(null); // Reset selection on rack change
}
}, [selectedRack, fetchDevices]);
// Group racks by room
const rooms = useMemo(() => {
const roomMap = new Map();
racks.forEach(rack => {
if (rack?.Room) {
const roomKey = rack.Room.roomId || rack.Room.id || rack.Room.name;
if (!roomMap.has(roomKey)) {
roomMap.set(roomKey, { ...rack.Room, key: roomKey });
}
}
});
return Array.from(roomMap.values());
}, [racks]);
const racksInSelectedRoom = useMemo(() => {
if (!selectedRoom) return [];
return racks.filter(r => {
const rKey = r.Room?.roomId || r.Room?.id || r.Room?.name;
return rKey === selectedRoom;
});
}, [selectedRoom, racks]);
const handleDeviceClick = (device) => {
setSelectedDevice(device);
if (device) {
fetchDeviceCables(device.deviceId || device.id);
}
};
const handleDeviceLeave = (device) => {
// 移除鼠标离开事件,避免抽屉意外关闭
// setSelectedDevice((current) => {
// const deviceId = device.deviceId || device.id;
// const currentId = current?.deviceId || current?.id;
// return currentId === deviceId ? null : current;
// });
};
const handleDeviceHover = useCallback((device) => {
if (modalVisible || nicModalVisible || portModalVisible) return;
setHoveredDevice(device);
}, [modalVisible, nicModalVisible, portModalVisible]);
const handleClosePortModal = useCallback(() => {
setPortModalVisible(false);
setOperatingDevice(null);
}, []);
const handlePortSuccess = useCallback(() => {
message.success('端口添加成功');
}, []);
const handleCloseNicModal = useCallback(() => {
setNicModalVisible(false);
setOperatingDevice(null);
}, []);
const handleNicSuccess = useCallback(() => {
message.success('网卡添加成功');
}, []);
const handleCloseCableModal = useCallback(() => {
setCableModalVisible(false);
setOperatingDevice(null);
}, []);
const handleCableSuccess = useCallback(() => {
message.success('接线添加成功');
setRefreshTrigger(prev => prev + 1);
if (selectedDevice) {
fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id);
}
}, [selectedDevice, fetchDeviceCables]);
const handleDeleteCable = useCallback(async (cableId) => {
try {
await axios.delete(`/api/cables/${cableId}`);
message.success('接线删除成功');
setRefreshTrigger(prev => prev + 1);
if (selectedDevice) {
fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id);
}
} catch (error) {
console.error('删除接线失败:', error);
message.error('删除接线失败');
}
}, [selectedDevice, fetchDeviceCables]);
return (
<Layout style={{ height: '100vh', overflow: 'hidden', background: '#000', position: 'relative' }}>
<Header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
background: 'rgba(15, 23, 42, 0.6)', // Deep blue-grey, semi-transparent
backdropFilter: 'blur(20px)',
position: 'absolute',
width: '100%',
zIndex: 100,
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
height: '64px'
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Button
type="text"
icon={<ArrowLeftOutlined style={{ color: 'rgba(255,255,255,0.8)' }} />}
onClick={() => navigate('/')}
style={{ marginRight: 16 }}
className="hover-bright"
/>
<div style={{
display: 'flex',
alignItems: 'center',
background: 'rgba(255,255,255,0.05)',
padding: '6px 12px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.05)'
}}>
<CloudServerOutlined style={{ fontSize: '20px', color: '#3b82f6', marginRight: '10px' }} />
<span style={{
fontSize: '16px',
fontWeight: 600,
color: '#f8fafc',
letterSpacing: '0.5px',
textShadow: '0 2px 4px rgba(0,0,0,0.2)'
}}>3D 机柜可视化</span>
</div>
</div>
<Space size="middle">
<Select
placeholder="选择机房"
style={{ width: 180 }}
value={selectedRoom}
onChange={(val) => {
setSelectedRoom(val);
const roomRacks = racks.filter(r => (r.Room?.roomId || r.Room?.id || r.Room?.name) === val);
if (roomRacks.length > 0) setSelectedRack(roomRacks[0]);
else setSelectedRack(null);
}}
variant="borderless"
popupMatchSelectWidth={false}
className="glass-select"
>
{rooms.map(room => (
<Option key={room.key} value={room.key}>{room.name}</Option>
))}
</Select>
<Select
placeholder="选择机柜"
style={{ width: 180 }}
value={selectedRack?.rackId}
onChange={(val) => setSelectedRack(racks.find(r => r.rackId === val))}
disabled={!selectedRoom}
variant="borderless"
className="glass-select"
>
{racksInSelectedRoom.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>{rack.name}</Option>
))}
</Select>
<Button
type="primary"
ghost
icon={<ReloadOutlined />}
onClick={() => { fetchRacks(); if(selectedRack) fetchDevices(selectedRack.rackId); }}
style={{ borderRadius: '6px', borderColor: 'rgba(255,255,255,0.3)', color: 'rgba(255,255,255,0.9)' }}
className="hover-bright"
>
刷新
</Button>
<div style={{
display: 'flex',
alignItems: 'center',
background: 'rgba(255,255,255,0.05)',
padding: '4px 12px',
borderRadius: '6px',
border: '1px solid rgba(255,255,255,0.05)'
}}>
<FullscreenOutlined style={{ color: deviceSlideEnabled ? '#22c55e' : 'rgba(255,255,255,0.4)', marginRight: 8 }} />
<span style={{
fontSize: '13px',
color: deviceSlideEnabled ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.5)',
marginRight: 8
}}>设备弹出</span>
<Switch
size="small"
checked={deviceSlideEnabled}
onChange={setDeviceSlideEnabled}
checkedChildren="开"
unCheckedChildren="关"
/>
</div>
<Button
type="primary"
ghost
icon={<SettingOutlined />}
onClick={() => setShowTooltipConfig(true)}
style={{ borderRadius: '6px', borderColor: 'rgba(255,255,255,0.3)', color: 'rgba(255,255,255,0.9)' }}
className="hover-bright"
>
显示配置
</Button>
</Space>
</Header>
{/* Inject custom styles for glass selects */}
<style>{`
.glass-select .ant-select-selector {
background: rgba(255, 255, 255, 0.08) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: white !important;
border-radius: 6px !important;
transition: all 0.3s;
}
.glass-select:hover .ant-select-selector {
background: rgba(255, 255, 255, 0.15) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
.glass-select .ant-select-selection-item,
.glass-select .ant-select-selection-placeholder {
color: rgba(255, 255, 255, 0.9) !important;
font-size: 13px;
}
.glass-select .ant-select-arrow {
color: rgba(255, 255, 255, 0.6) !important;
}
.hover-bright:hover {
color: white !important;
background: rgba(255,255,255,0.1) !important;
}
`}</style>
<Layout>
<Content style={{ position: 'relative', background: '#ffffff' }}>
{loading ? (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: '#1890ff' }}>加载资源中...</div>
</div>
) : selectedRack ? (
<>
<Scene
rack={selectedRack}
devices={devices}
selectedDeviceId={selectedDevice?.deviceId || selectedDevice?.id}
onDeviceClick={handleDeviceClick}
onDeviceLeave={handleDeviceLeave}
onDeviceHover={handleDeviceHover}
tooltipFields={tooltipFields}
deviceSlideEnabled={deviceSlideEnabled}
/>
{/* Rack Info Overlay (New) */}
{selectedRack && (
<div style={{
position: 'absolute',
top: 88, // Moved down to avoid header overlap
left: 24,
background: 'rgba(255, 255, 255, 0.85)', // Slightly more transparent
backdropFilter: 'blur(12px)',
padding: isRackInfoCollapsed ? '12px 16px' : '20px',
borderRadius: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)', // Softer, deeper shadow
width: isRackInfoCollapsed ? 'auto' : '280px',
zIndex: 5,
border: '1px solid rgba(255, 255, 255, 0.6)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden',
cursor: isRackInfoCollapsed ? 'pointer' : 'default'
}}
onClick={() => isRackInfoCollapsed && setIsRackInfoCollapsed(false)}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: isRackInfoCollapsed ? 0 : 16, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{
width: 36, height: 36,
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
borderRadius: '10px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 12,
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
flexShrink: 0
}}>
<CloudServerOutlined style={{ color: 'white', fontSize: 18 }} />
</div>
<div style={{ opacity: isRackInfoCollapsed ? 0 : 1, width: isRackInfoCollapsed ? 0 : 'auto', transition: 'opacity 0.2s', whiteSpace: 'nowrap', overflow: 'hidden' }}>
<div style={{ fontSize: '16px', fontWeight: 700, color: '#1e293b', lineHeight: 1.2 }}>
{selectedRack.name}
</div>
<div style={{ fontSize: '12px', color: '#64748b', marginTop: 2 }}>
ID: {selectedRack.rackId}
</div>
</div>
</div>
<Button
type="text"
size="small"
icon={isRackInfoCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={(e) => {
e.stopPropagation();
setIsRackInfoCollapsed(!isRackInfoCollapsed);
}}
style={{ color: '#94a3b8', marginLeft: isRackInfoCollapsed ? 8 : 0 }}
/>
</div>
{/* Content Area - Collapsible */}
<div style={{
maxHeight: isRackInfoCollapsed ? 0 : '500px',
opacity: isRackInfoCollapsed ? 0 : 1,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden'
}}>
{/* Stats Grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
<div style={{ background: 'rgba(241, 245, 249, 0.6)', padding: '10px', borderRadius: '10px' }}>
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: 2 }}>总高度</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: '#0f172a' }}>
{selectedRack.height}<span style={{ fontSize: '12px', fontWeight: 400, marginLeft: 2 }}>U</span>
</div>
</div>
<div style={{ background: 'rgba(241, 245, 249, 0.6)', padding: '10px', borderRadius: '10px' }}>
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: 2 }}>设备数</div>
<div style={{ fontSize: '18px', fontWeight: 600, color: '#3b82f6' }}>
{devices.length}<span style={{ fontSize: '12px', fontWeight: 400, marginLeft: 2 }}></span>
</div>
</div>
</div>
{/* Guide Section */}
<div style={{
marginTop: 16,
paddingTop: 16,
borderTop: '1px solid rgba(0,0,0,0.06)',
}}>
<div style={{
fontSize: '12px',
fontWeight: 600,
color: '#94a3b8',
marginBottom: 8,
display: 'flex', alignItems: 'center'
}}>
<InfoCircleOutlined style={{ marginRight: 6 }} /> 操作指南
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{[
{ icon: '🖱️', text: '左键旋转 / 中键平移' },
{ icon: '🔍', text: '滚轮缩放视图' },
{ icon: '👆', text: '点击设备查看详情' }
].map((item, i) => (
<div key={i} style={{
fontSize: '12px',
color: '#475569',
display: 'flex',
alignItems: 'center',
background: 'rgba(255,255,255,0.5)',
padding: '4px 8px',
borderRadius: '6px'
}}>
<span style={{ marginRight: 8, opacity: 0.8 }}>{item.icon}</span>
{item.text}
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Overlay Info for Hovered Device */}
{hoveredDevice && !selectedDevice && (
<div style={{
position: 'absolute',
top: 88, // Moved down to avoid header overlap
right: 24,
background: 'rgba(0, 0, 0, 0.7)',
padding: '12px',
borderRadius: '8px',
color: 'white',
pointerEvents: 'none',
maxWidth: '300px',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.1)',
zIndex: 10
}}>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{hoveredDevice.name}</div>
<div style={{ fontSize: '12px', opacity: 0.8 }}>
<div>位置: U{hoveredDevice.position}</div>
<div>高度: {hoveredDevice.height}U</div>
<div>状态: {hoveredDevice.status}</div>
</div>
</div>
)}
{/* Device Detail Floating Popup - Removed, now handled in DeviceModel */}
</>
) : (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Empty description={<span style={{ color: 'rgba(0, 0, 0, 0.45)' }}>请选择一个机柜以查看 3D 视图</span>} />
</div>
)}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EditOutlined style={{ color: '#667eea' }} />
<span>编辑设备信息</span>
</div>
}
open={modalVisible}
onCancel={handleModalCancel}
footer={null}
width={700}
>
<Form
form={form}
layout="vertical"
onFinish={handleModalSubmit}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Form.Item name="name" label="设备名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="type" label="设备类型" rules={[{ required: true }]}>
<Select>
<Option value="server">服务器</Option>
<Option value="switch">交换机</Option>
<Option value="router">路由器</Option>
<Option value="storage">存储设备</Option>
<Option value="firewall">防火墙</Option>
<Option value="other">其他设备</Option>
</Select>
</Form.Item>
<Form.Item name="model" label="型号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="serialNumber" label="序列号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="position" label="位置(U)" rules={[{ required: true }]}>
<InputNumber min={1} max={selectedRack?.height || 45} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="height" label="高度(U)" rules={[{ required: true }]}>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select>
<Option value="running">运行中</Option>
<Option value="maintenance">维护中</Option>
<Option value="offline">离线</Option>
<Option value="fault">故障</Option>
</Select>
</Form.Item>
<Form.Item name="powerConsumption" label="功率(W)" rules={[{ required: true }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="ipAddress" label="IP地址">
<Input />
</Form.Item>
<Form.Item name="purchaseDate" label="购买日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="warrantyExpiry" label="保修到期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
<div style={{ textAlign: 'right', marginTop: 16 }}>
<Space>
<Button onClick={handleModalCancel}>取消</Button>
<Button type="primary" htmlType="submit">保存</Button>
</Space>
</div>
</Form>
</Modal>
{/* Field Configuration Modal */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SettingOutlined style={{ color: '#1890ff' }} />
<span>设备详情字段配置</span>
</div>
}
open={showTooltipConfig}
onCancel={() => setShowTooltipConfig(false)}
footer={null}
width={520}
>
{loadingTooltipFields ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />
<div style={{ marginTop: 8 }}>加载配置中...</div>
</div>
) : (
<div>
<div style={{ marginBottom: 16, color: '#666' }}>选择要在设备详情卡片中显示的字段</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, maxHeight: '400px', overflowY: 'auto' }}>
{Object.entries(tooltipFields).map(([key, field]) => (
<div
key={key}
style={{
padding: '8px 12px',
border: field.enabled ? '1px solid #1890ff' : '1px solid #d9d9d9',
background: field.enabled ? '#e6f7ff' : '#fff',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.3s'
}}
onClick={() => {
setTooltipFields(prev => ({
...prev,
[key]: { ...prev[key], enabled: !prev[key].enabled }
}));
}}
>
<Checkbox checked={field.enabled} style={{ marginRight: 8 }} />
<span>{field.label}</span>
</div>
))}
</div>
<div style={{ marginTop: 24, textAlign: 'right', borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
<Space>
<Button onClick={() => setShowTooltipConfig(false)}>取消</Button>
<Button type="primary" onClick={saveTooltipConfig} loading={savingTooltipConfig}>保存配置</Button>
</Space>
</div>
</div>
)}
</Modal>
<NetworkCardCreateModal
device={operatingDevice}
visible={nicModalVisible}
onClose={handleCloseNicModal}
onSuccess={handleNicSuccess}
/>
<PortCreateModal
device={operatingDevice}
visible={portModalVisible}
onClose={handleClosePortModal}
onSuccess={handlePortSuccess}
/>
<CableCreateModal
visible={cableModalVisible}
onClose={handleCloseCableModal}
onSuccess={handleCableSuccess}
sourceDevice={operatingDevice}
/>
<DeviceDetailDrawer
visible={!!selectedDevice}
device={selectedDevice}
onClose={() => setSelectedDevice(null)}
onEdit={handleEditDevice}
onAddNic={handleAddNic}
onAddPort={handleAddPort}
onAddCable={handleAddCable}
tooltipFields={tooltipFields}
cables={deviceCables}
onRefreshCables={() => selectedDevice && fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id)}
onDeleteCable={handleDeleteCable}
refreshTrigger={refreshTrigger}
/>
</Content>
</Layout>
</Layout>
);
};
export default Rack3DVisualization;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
assetsInclude: ['**/*.hdr', '**/*.woff2'],
server: {
host: '0.0.0.0',
port: 3000,