feat(3d): 添加Scene3DContext优化3D场景状态管理
refactor(3d): 重构LODManager使用ref优化性能 feat(3d): 在RackModel中添加U位刻度标识 refactor(3d): 优化Scene组件相机控制和渲染设置 refactor(3d): 重构DeviceModel使用共享几何体和材质缓存 refactor(3d): 优化Rack3DVisualization使用Scene3DContext style(dashboard): 改进响应式布局和样式 perf(3d): 优化InstancedMesh性能减少内存占用
This commit is contained in:
@@ -4,6 +4,7 @@ import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutl
|
||||
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';
|
||||
import { Scene3DProvider } from './context/Scene3DContext';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
@@ -580,7 +581,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-3d" element={<PrivateRoute><Rack3DVisualization /></PrivateRoute>} />
|
||||
<Route path="/visualization-3d" element={<PrivateRoute><Scene3DProvider><Rack3DVisualization /></Scene3DProvider></PrivateRoute>} />
|
||||
<Route path="/consumables" element={<PrivateRoute><ConsumableManagement /></PrivateRoute>} />
|
||||
<Route path="/consumables-categories" element={<PrivateRoute><CategoryManagement /></PrivateRoute>} />
|
||||
<Route path="/consumables-stats" element={<PrivateRoute><ConsumableStatistics /></PrivateRoute>} />
|
||||
|
||||
@@ -1,21 +1,153 @@
|
||||
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const PERFORMANCE_MODE = true;
|
||||
|
||||
// 背板渲染控制常量
|
||||
const BACK_PANEL_CONFIG = {
|
||||
// 相机在正面时不渲染背板(z > threshold)
|
||||
visibilityThreshold: -0.3,
|
||||
// 距离阈值:太远时不渲染详细背板
|
||||
detailDistanceThreshold: 3.0,
|
||||
// 简化背板距离:超过此距离只显示基础背板
|
||||
simplifiedDistanceThreshold: 1.5,
|
||||
};
|
||||
|
||||
// ==================== 全局共享几何体和材质缓存 ====================
|
||||
// 这些资源只创建一次,所有设备组件共享,大幅减少显存占用
|
||||
|
||||
// 基础几何体(使用 scale 调整大小,避免重复创建)
|
||||
const SHARED_GEOMETRIES = {
|
||||
// 1x1x1 单位立方体,使用时通过 scale 调整尺寸
|
||||
box: new THREE.BoxGeometry(1, 1, 1),
|
||||
// 圆形几何体
|
||||
circle: new THREE.CircleGeometry(1, 16),
|
||||
// 圆柱体
|
||||
cylinder: new THREE.CylinderGeometry(1, 1, 1, 16),
|
||||
};
|
||||
|
||||
// 共享材质(按类型分类,避免重复创建相同材质)
|
||||
const SHARED_MATERIALS = {
|
||||
// 机身材质 - 深灰色哑光金属
|
||||
chassis: new THREE.MeshStandardMaterial({
|
||||
color: "#333333",
|
||||
roughness: 0.9,
|
||||
metalness: 0.3
|
||||
}),
|
||||
// 面板材质 - 灰色塑料
|
||||
panel: new THREE.MeshStandardMaterial({
|
||||
color: "#555555",
|
||||
roughness: 0.8,
|
||||
metalness: 0.1
|
||||
}),
|
||||
// 面板高亮材质 - 悬停/选中时使用
|
||||
panelHover: new THREE.MeshStandardMaterial({
|
||||
color: "#666666",
|
||||
roughness: 0.8,
|
||||
metalness: 0.1
|
||||
}),
|
||||
// LED 灯材质
|
||||
led: new THREE.MeshBasicMaterial({
|
||||
toneMapped: false
|
||||
}),
|
||||
// 状态灯底座
|
||||
ledBase: new THREE.MeshStandardMaterial({
|
||||
color: "#333333"
|
||||
}),
|
||||
// 深色面板
|
||||
darkPanel: new THREE.MeshStandardMaterial({
|
||||
color: "#1e293b",
|
||||
roughness: 0.7,
|
||||
metalness: 0.5
|
||||
}),
|
||||
// 硬盘托架
|
||||
driveBay: new THREE.MeshStandardMaterial({
|
||||
color: "#334155",
|
||||
roughness: 0.6,
|
||||
metalness: 0.4
|
||||
}),
|
||||
// 装饰条
|
||||
accent: new THREE.MeshStandardMaterial({
|
||||
color: "#000000",
|
||||
roughness: 0.2
|
||||
}),
|
||||
// 金属细节
|
||||
metalDetail: new THREE.MeshStandardMaterial({
|
||||
color: "#cbd5e1"
|
||||
}),
|
||||
// 网口
|
||||
networkPort: new THREE.MeshStandardMaterial({
|
||||
color: "#1e293b",
|
||||
roughness: 0.3
|
||||
}),
|
||||
// 网口发光
|
||||
networkLed: new THREE.MeshBasicMaterial({
|
||||
color: "#10b981",
|
||||
toneMapped: false
|
||||
}),
|
||||
// 电源
|
||||
powerSupply: new THREE.MeshStandardMaterial({
|
||||
color: "#374151",
|
||||
roughness: 0.5
|
||||
}),
|
||||
// 风扇
|
||||
fan: new THREE.MeshStandardMaterial({
|
||||
color: "#1f2937",
|
||||
roughness: 0.4
|
||||
}),
|
||||
// 散热孔
|
||||
vent: new THREE.MeshStandardMaterial({
|
||||
color: "#111827"
|
||||
}),
|
||||
};
|
||||
|
||||
// InstancedMesh 共享几何体
|
||||
const SHARED_INSTANCED_GEOMETRIES = {
|
||||
// 状态灯
|
||||
statusLight: new THREE.CircleGeometry(0.0015, 8),
|
||||
// 硬盘托架
|
||||
driveBay: new THREE.BoxGeometry(0.07, 0.035, 0.004),
|
||||
// 硬盘细节
|
||||
driveDetail: new THREE.BoxGeometry(0.015, 0.025, 0.002),
|
||||
// 存储托架
|
||||
storageBay: new THREE.BoxGeometry(0.08, 0.12, 0.004),
|
||||
// 存储细节
|
||||
storageDetail: new THREE.BoxGeometry(0.06, 0.08, 0.002),
|
||||
// 网口
|
||||
networkPort: new THREE.BoxGeometry(0.012, 0.008, 0.004),
|
||||
// 网口发光
|
||||
networkLed: new THREE.BoxGeometry(0.003, 0.002, 0.001),
|
||||
// SFP端口
|
||||
sfpPort: new THREE.BoxGeometry(0.02, 0.015, 0.005),
|
||||
// SFP内部
|
||||
sfpInner: new THREE.BoxGeometry(0.016, 0.011, 0.002),
|
||||
// SFP连接器
|
||||
sfpConnector: new THREE.BoxGeometry(0.01, 0.002, 0.008),
|
||||
// SFP指示灯
|
||||
sfpLed: new THREE.ConeGeometry(0.002, 0.004, 3),
|
||||
};
|
||||
|
||||
// InstancedMesh 共享材质
|
||||
const SHARED_INSTANCED_MATERIALS = {
|
||||
// 状态灯基础材质
|
||||
statusLight: new THREE.MeshBasicMaterial({ toneMapped: false }),
|
||||
// 硬盘托架
|
||||
driveBay: new THREE.MeshStandardMaterial({ color: '#334155', roughness: 0.6, metalness: 0.4 }),
|
||||
// 硬盘细节
|
||||
driveDetail: new THREE.MeshStandardMaterial({ color: '#1e293b' }),
|
||||
// 存储托架
|
||||
storageBay: new THREE.MeshStandardMaterial({ color: '#374151', roughness: 0.5 }),
|
||||
// 存储细节
|
||||
storageDetail: new THREE.MeshStandardMaterial({ color: '#4b5563' }),
|
||||
// 网口
|
||||
networkPort: new THREE.MeshStandardMaterial({ color: '#1e293b', roughness: 0.3 }),
|
||||
// 网口发光
|
||||
networkLed: new THREE.MeshBasicMaterial({ color: '#10b981', toneMapped: false }),
|
||||
};
|
||||
|
||||
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(), []);
|
||||
|
||||
@@ -30,10 +162,30 @@ const InstancedStatusLights = ({ count, positions, colors: statusColors, zOffset
|
||||
}
|
||||
}, [count, positions, zOffset, dummy]);
|
||||
|
||||
// 修复:正确更新实例颜色
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const color = new THREE.Color(statusColors[i] || '#22c55e');
|
||||
meshRef.current.setColorAt(i, color);
|
||||
}
|
||||
if (meshRef.current.instanceColor) {
|
||||
meshRef.current.instanceColor.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}, [count, statusColors]);
|
||||
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) {
|
||||
meshRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
||||
<circleGeometry args={[0.0015, 8]} />
|
||||
<meshBasicMaterial toneMapped={false} />
|
||||
<instancedMesh ref={meshRef} args={[SHARED_INSTANCED_GEOMETRIES.statusLight, SHARED_INSTANCED_MATERIALS.statusLight, count]}>
|
||||
</instancedMesh>
|
||||
);
|
||||
};
|
||||
@@ -64,16 +216,20 @@ const InstancedDriveBays = ({ count, positions, color, hasDetail = true }) => {
|
||||
}
|
||||
}, [count, positions, hasDetail, dummy]);
|
||||
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (detailRef.current) detailRef.current.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 ref={meshRef} args={[SHARED_INSTANCED_GEOMETRIES.driveBay, SHARED_INSTANCED_MATERIALS.driveBay, count]}>
|
||||
</instancedMesh>
|
||||
{hasDetail && (
|
||||
<instancedMesh ref={detailRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.015, 0.025, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" />
|
||||
<instancedMesh ref={detailRef} args={[SHARED_INSTANCED_GEOMETRIES.driveDetail, SHARED_INSTANCED_MATERIALS.driveDetail, count]}>
|
||||
</instancedMesh>
|
||||
)}
|
||||
</group>
|
||||
@@ -106,15 +262,19 @@ const InstancedStorageBays = ({ count, positions, color }) => {
|
||||
}
|
||||
}, [count, positions, dummy]);
|
||||
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (detailRef.current) detailRef.current.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 ref={meshRef} args={[SHARED_INSTANCED_GEOMETRIES.storageBay, SHARED_INSTANCED_MATERIALS.storageBay, count]}>
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={detailRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.02, 0.015, 0.002]} />
|
||||
<meshStandardMaterial color="#000" />
|
||||
<instancedMesh ref={detailRef} args={[SHARED_INSTANCED_GEOMETRIES.storageDetail, SHARED_INSTANCED_MATERIALS.storageDetail, count]}>
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
@@ -172,24 +332,25 @@ const InstancedRJ45Ports = ({ count, positions, statuses, frontZ }) => {
|
||||
return statuses.map(s => s !== 'disconnected' ? (s === 'fault' ? '#ef4444' : '#22c55e') : '#475569');
|
||||
}, [statuses]);
|
||||
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (innerRef.current) innerRef.current.dispose();
|
||||
if (tabRef.current) tabRef.current.dispose();
|
||||
if (ledRef.current) ledRef.current.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.011, 0.011, 0.004]} />
|
||||
<meshStandardMaterial color="#cbd5e1" metalness={0.8} />
|
||||
<instancedMesh ref={meshRef} args={[SHARED_INSTANCED_GEOMETRIES.networkPort, SHARED_INSTANCED_MATERIALS.networkPort, count]}>
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={innerRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.009, 0.009, 0.002]} />
|
||||
<meshStandardMaterial color="#000" />
|
||||
<instancedMesh ref={innerRef} args={[SHARED_INSTANCED_GEOMETRIES.networkPort, SHARED_INSTANCED_MATERIALS.networkPort, count]}>
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={tabRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.007, 0.001, 0.001]} />
|
||||
<meshBasicMaterial color="#facc15" />
|
||||
<instancedMesh ref={tabRef} args={[SHARED_INSTANCED_GEOMETRIES.networkLed, SHARED_INSTANCED_MATERIALS.networkLed, count]}>
|
||||
</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 ref={ledRef} args={[SHARED_INSTANCED_GEOMETRIES.networkLed, SHARED_INSTANCED_MATERIALS.networkLed, count]}>
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
@@ -243,28 +404,36 @@ const InstancedSFPports = ({ count, positions, statuses, frontZ }) => {
|
||||
}
|
||||
}, [count, positions, statuses, frontZ, dummy]);
|
||||
|
||||
const ledColors = useMemo(() => {
|
||||
return positions.map((_, i) => statuses[i] !== 'disconnected' ? '#22c55e' : '#475569');
|
||||
}, [positions, statuses]);
|
||||
// 修复:正确更新SFP指示灯颜色
|
||||
useEffect(() => {
|
||||
if (ledRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isConnected = statuses[i] !== 'disconnected';
|
||||
const color = new THREE.Color(isConnected ? '#22c55e' : '#475569');
|
||||
ledRef.current.setColorAt(i, color);
|
||||
}
|
||||
if (ledRef.current.instanceColor) {
|
||||
ledRef.current.instanceColor.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}, [count, statuses]);
|
||||
|
||||
// 资源清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (meshRef.current) meshRef.current.dispose();
|
||||
if (innerRef.current) innerRef.current.dispose();
|
||||
if (connectorRef.current) connectorRef.current.dispose();
|
||||
if (ledRef.current) ledRef.current.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<instancedMesh ref={meshRef} args={[SHARED_INSTANCED_GEOMETRIES.sfpPort, SHARED_INSTANCED_MATERIALS.networkPort, count]} />
|
||||
<instancedMesh ref={innerRef} args={[SHARED_INSTANCED_GEOMETRIES.sfpInner, SHARED_INSTANCED_MATERIALS.driveDetail, count]} />
|
||||
<instancedMesh ref={connectorRef} args={[SHARED_INSTANCED_GEOMETRIES.sfpConnector, SHARED_INSTANCED_MATERIALS.metalDetail, count]} />
|
||||
<instancedMesh ref={ledRef} args={[SHARED_INSTANCED_GEOMETRIES.sfpLed, SHARED_INSTANCED_MATERIALS.networkLed, count]} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
@@ -326,23 +495,63 @@ const FirewallFace = ({ device, height, frontZ, isSelected }) => {
|
||||
|
||||
const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight: propUHeight, position, rackDepth, slideEnabled = true }) => {
|
||||
const mesh = useRef();
|
||||
const groupRef = useRef();
|
||||
const [hovered, setHover] = useState(false);
|
||||
const [isExtended, setIsExtended] = useState(false);
|
||||
const [currentZ, setCurrentZ] = useState(0);
|
||||
// 使用 ref 存储动画状态,避免每帧触发 React 状态更新
|
||||
const isExtendedRef = useRef(false);
|
||||
const currentZRef = useRef(0);
|
||||
// 使用 ref 避免重复设置悬停状态
|
||||
const isHoveredRef = useRef(false);
|
||||
|
||||
// 背板渲染控制状态
|
||||
const [backPanelLevel, setBackPanelLevel] = useState(0); // 0: 不渲染, 1: 简化, 2: 完整
|
||||
const { camera } = useThree();
|
||||
const frameCount = useRef(0);
|
||||
|
||||
// 使用传入的 uHeight (默认为 0.04445m)
|
||||
const uHeight = propUHeight || 0.04445;
|
||||
const depth = rackDepth || 1.0; // 机柜深度,默认1米
|
||||
|
||||
// 滑轨动画 - 仅在展开时运行且slideEnabled为true
|
||||
// 使用 ref 直接操作 mesh,避免 React 状态更新
|
||||
useFrame(() => {
|
||||
if (!slideEnabled || (!isExtended && Math.abs(currentZ) < 0.01)) {
|
||||
if (currentZ !== 0) setCurrentZ(0);
|
||||
if (!slideEnabled || (!isExtendedRef.current && Math.abs(currentZRef.current) < 0.001)) {
|
||||
return;
|
||||
}
|
||||
const targetZ = isExtended ? 0.6 : 0;
|
||||
const targetZ = isExtendedRef.current ? 0.6 : 0;
|
||||
const lerpFactor = 0.1;
|
||||
setCurrentZ(prev => prev + (targetZ - prev) * lerpFactor);
|
||||
currentZRef.current += (targetZ - currentZRef.current) * lerpFactor;
|
||||
|
||||
// 直接操作 group 的 position,不通过 React state
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.z = currentZRef.current;
|
||||
}
|
||||
});
|
||||
|
||||
// 背板渲染控制 - 根据相机位置动态调整
|
||||
// 只在相机绕到背面时才渲染背板
|
||||
useFrame(() => {
|
||||
// 每10帧检查一次,减少计算频率
|
||||
frameCount.current++;
|
||||
if (frameCount.current % 10 !== 0) return;
|
||||
|
||||
if (!groupRef.current) return;
|
||||
|
||||
// 获取相机相对设备的位置
|
||||
const deviceWorldPos = new THREE.Vector3();
|
||||
groupRef.current.getWorldPosition(deviceWorldPos);
|
||||
|
||||
const cameraZ = camera.position.z;
|
||||
|
||||
// 简单逻辑:相机在正面(z > threshold)时不渲染背板
|
||||
// 相机在背面(z <= threshold)时渲染完整背板
|
||||
const shouldShowBackPanel = cameraZ <= BACK_PANEL_CONFIG.visibilityThreshold;
|
||||
const newLevel = shouldShowBackPanel ? 2 : 0;
|
||||
|
||||
// 只有当级别变化时才更新状态
|
||||
if (newLevel !== backPanelLevel) {
|
||||
setBackPanelLevel(newLevel);
|
||||
}
|
||||
});
|
||||
|
||||
// 尺寸定义 (适配标准 0.6m 机柜)
|
||||
@@ -617,108 +826,135 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设备背板
|
||||
// 渲染简化版背板(只显示基础背板)
|
||||
const renderSimplifiedBackPanel = (backZ) => {
|
||||
return (
|
||||
<group position={[0, 0, backZ]} rotation={[0, Math.PI, 0]}>
|
||||
{/* 基础背板 - 使用共享几何体 */}
|
||||
<mesh
|
||||
position={[0, 0, 0.001]}
|
||||
geometry={SHARED_GEOMETRIES.box}
|
||||
material={SHARED_MATERIALS.panel}
|
||||
scale={[chassisWidth, height - gap, 0.002]}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染完整版背板(包含 PSU、风扇、网卡等细节)
|
||||
const renderFullBackPanel = (backZ) => {
|
||||
return (
|
||||
<group position={[0, 0, backZ]} rotation={[0, Math.PI, 0]}>
|
||||
{/* 基础背板 - 透明玻璃质感 */}
|
||||
<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) - 左侧 */}
|
||||
<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]}>
|
||||
{(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>
|
||||
{/* 电源插口 */}
|
||||
<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) - 中间 */}
|
||||
<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]}>
|
||||
{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>
|
||||
{/* 端口 */}
|
||||
<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 renderBackPanel = () => {
|
||||
// Back face Z position (flush with back of chassis)
|
||||
// Back face Z position
|
||||
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>
|
||||
);
|
||||
// 根据背板级别决定渲染内容
|
||||
switch (backPanelLevel) {
|
||||
case 0:
|
||||
// 不渲染背板
|
||||
return null;
|
||||
case 1:
|
||||
// 简化背板
|
||||
return renderSimplifiedBackPanel(backZ);
|
||||
case 2:
|
||||
// 完整背板
|
||||
return renderFullBackPanel(backZ);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const renderDeviceFace = () => {
|
||||
const type = device.type?.toLowerCase() || '';
|
||||
@@ -749,41 +985,46 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
return (
|
||||
<group position={position || [0, 0, 0]}>
|
||||
<group
|
||||
position={[0, 0, currentZ]}
|
||||
ref={groupRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExtended(!isExtended);
|
||||
isExtendedRef.current = !isExtendedRef.current;
|
||||
onClick && onClick(device);
|
||||
}}
|
||||
onPointerOver={(e) => {
|
||||
e.stopPropagation();
|
||||
setHover(true);
|
||||
onHover && onHover(device);
|
||||
// 使用 ref 避免重复设置状态
|
||||
if (!isHoveredRef.current) {
|
||||
isHoveredRef.current = true;
|
||||
setHover(true);
|
||||
onHover && onHover(device);
|
||||
}
|
||||
}}
|
||||
onPointerOut={(e) => {
|
||||
setHover(false);
|
||||
onHover && onHover(null);
|
||||
// 重置 ref 并更新状态
|
||||
if (isHoveredRef.current) {
|
||||
isHoveredRef.current = false;
|
||||
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>
|
||||
{/* 机身 (Chassis) - 使用共享几何体和材质 */}
|
||||
<mesh
|
||||
ref={mesh}
|
||||
position={[0, 0, chassisZ]}
|
||||
geometry={SHARED_GEOMETRIES.box}
|
||||
material={SHARED_MATERIALS.chassis}
|
||||
scale={[chassisWidth, height - gap, chassisDepth]}
|
||||
/>
|
||||
|
||||
{/* 前面板底座 (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>
|
||||
{/* 前面板底座 (Front Panel Base) - 使用共享资源 */}
|
||||
<mesh
|
||||
position={[0, 0, panelZ]}
|
||||
geometry={SHARED_GEOMETRIES.box}
|
||||
material={hovered || isSelected ? SHARED_MATERIALS.panelHover : SHARED_MATERIALS.panel}
|
||||
scale={[panelWidth, height - gap, panelDepth]}
|
||||
/>
|
||||
|
||||
{/* 告警时的红色辉光 (设备两侧) */}
|
||||
{(device.status === 'error' || device.status === 'fault') && (
|
||||
@@ -815,14 +1056,16 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
</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
|
||||
position={[0, 0, -0.002]}
|
||||
geometry={SHARED_GEOMETRIES.circle}
|
||||
material={SHARED_MATERIALS.ledBase}
|
||||
scale={[0.01, 0.01, 1]}
|
||||
/>
|
||||
{/* 发光体 - 需要动态颜色,使用克隆材质 */}
|
||||
<mesh>
|
||||
<circleGeometry args={[0.006, 16]} />
|
||||
<meshBasicMaterial color={statusColor} toneMapped={false} />
|
||||
@@ -834,4 +1077,45 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceModel;
|
||||
// 使用 React.memo 优化,避免不必要的重渲染
|
||||
// 自定义比较函数:只在关键属性变化时才重新渲染
|
||||
const DeviceModelMemo = React.memo(DeviceModel, (prevProps, nextProps) => {
|
||||
// 比较设备 ID
|
||||
if (prevProps.device?.id !== nextProps.device?.id) return false;
|
||||
if (prevProps.device?.deviceId !== nextProps.device?.deviceId) return false;
|
||||
|
||||
// 比较选中状态
|
||||
if (prevProps.isSelected !== nextProps.isSelected) return false;
|
||||
|
||||
// 比较滑出动画开关
|
||||
if (prevProps.slideEnabled !== nextProps.slideEnabled) return false;
|
||||
|
||||
// 比较位置(使用 JSON.stringify 简单比较)
|
||||
if (JSON.stringify(prevProps.position) !== JSON.stringify(nextProps.position)) return false;
|
||||
|
||||
// 比较机柜相关属性
|
||||
if (prevProps.rackHeight !== nextProps.rackHeight) return false;
|
||||
if (prevProps.rackDepth !== nextProps.rackDepth) return false;
|
||||
if (prevProps.uHeight !== nextProps.uHeight) return false;
|
||||
|
||||
// 修复:比较设备状态,确保状态变化时正确重渲染
|
||||
if (prevProps.device?.status !== nextProps.device?.status) return false;
|
||||
|
||||
// 修复:比较连接线,确保端口状态变化时正确重渲染
|
||||
const prevCables = prevProps.device?.cables;
|
||||
const nextCables = nextProps.device?.cables;
|
||||
if (Array.isArray(prevCables) && Array.isArray(nextCables)) {
|
||||
if (prevCables.length !== nextCables.length) return false;
|
||||
// 简单比较:检查每个连接线的关键属性
|
||||
for (let i = 0; i < prevCables.length; i++) {
|
||||
if (prevCables[i]?.status !== nextCables[i]?.status) return false;
|
||||
}
|
||||
} else if (prevCables !== nextCables) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 以上都相同,不需要重新渲染
|
||||
return true;
|
||||
});
|
||||
|
||||
export default DeviceModelMemo;
|
||||
|
||||
@@ -80,7 +80,7 @@ const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusC
|
||||
</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]}>
|
||||
<mesh key={`drive-bay-${device.id || 'unknown'}-${i}`} position={[(i - 1) * 0.08, 0, 0]}>
|
||||
<boxGeometry args={[0.06, 0.03, 0.004]} />
|
||||
<meshStandardMaterial color="#334155" roughness={0.6} />
|
||||
</mesh>
|
||||
@@ -105,24 +105,56 @@ const LODManager = ({
|
||||
level = LOD_LEVELS.HIGH
|
||||
}) => {
|
||||
const groupRef = useRef();
|
||||
const highDetailRef = useRef();
|
||||
const mediumDetailRef = useRef();
|
||||
const lowDetailRef = useRef();
|
||||
const { camera } = useThree();
|
||||
const [lodLevel, setLodLevel] = React.useState(LOD_LEVELS.HIGH);
|
||||
// 使用 ref 存储 LOD 级别,避免 React 状态更新
|
||||
const lodLevelRef = useRef(LOD_LEVELS.HIGH);
|
||||
const distanceRef = useRef(0);
|
||||
// 帧计数器,用于节流
|
||||
const frameCount = useRef(0);
|
||||
// 用于强制重新渲染的 state(仅在必要时更新)
|
||||
const [, forceUpdate] = React.useReducer(x => x + 1, 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 (!groupRef.current) return;
|
||||
|
||||
// 节流:每5帧检查一次
|
||||
frameCount.current++;
|
||||
if (frameCount.current % 5 !== 0) return;
|
||||
|
||||
const distance = camera.position.distanceTo(groupRef.current.position);
|
||||
distanceRef.current = distance;
|
||||
|
||||
// 添加缓冲避免频繁切换(10% 缓冲)
|
||||
const buffer = 0.1;
|
||||
const highThreshold = LOD_DISTANCES.HIGH * (1 + buffer);
|
||||
const mediumThreshold = LOD_DISTANCES.MEDIUM * (1 + buffer);
|
||||
|
||||
let newLevel = LOD_LEVELS.HIGH;
|
||||
if (distance > mediumThreshold) {
|
||||
newLevel = LOD_LEVELS.LOW;
|
||||
} else if (distance > highThreshold) {
|
||||
newLevel = LOD_LEVELS.MEDIUM;
|
||||
}
|
||||
|
||||
// 只有当级别变化时才更新
|
||||
if (newLevel !== lodLevelRef.current) {
|
||||
lodLevelRef.current = newLevel;
|
||||
// 直接操作 ref 切换可见性,避免频繁的 React 重渲染
|
||||
if (highDetailRef.current) {
|
||||
highDetailRef.current.visible = newLevel === LOD_LEVELS.HIGH;
|
||||
}
|
||||
|
||||
if (newLevel !== lodLevel) {
|
||||
setLodLevel(newLevel);
|
||||
if (mediumDetailRef.current) {
|
||||
mediumDetailRef.current.visible = newLevel === LOD_LEVELS.MEDIUM;
|
||||
}
|
||||
if (lowDetailRef.current) {
|
||||
lowDetailRef.current.visible = newLevel === LOD_LEVELS.LOW;
|
||||
}
|
||||
// 偶尔强制更新以确保同步(每30帧)
|
||||
if (frameCount.current % 30 === 0) {
|
||||
forceUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -131,20 +163,22 @@ const LODManager = ({
|
||||
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 ref={highDetailRef} visible={true}>
|
||||
{children}
|
||||
</group>
|
||||
|
||||
{/* 中等细节模型 */}
|
||||
<group ref={mediumDetailRef} visible={false}>
|
||||
{createMediumDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor)}
|
||||
</group>
|
||||
|
||||
{/* 低细节模型 */}
|
||||
<group ref={lowDetailRef} visible={false}>
|
||||
{createSimplifiedDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor)}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import DeviceModel from './DeviceModel';
|
||||
import LODManager, { LOD_LEVELS } from './LODManager';
|
||||
|
||||
@@ -49,6 +50,96 @@ const RackModel = ({
|
||||
return colors.default;
|
||||
};
|
||||
|
||||
// 设备组的Y偏移量(与下方设备渲染的偏移一致)
|
||||
const deviceGroupOffset = 0.1;
|
||||
|
||||
// 生成U位刻度标识 - 写在机柜左右两侧柱子上
|
||||
const uLabels = useMemo(() => {
|
||||
const labels = [];
|
||||
// 每一个U位都显示数字,从底部开始(U1在底部)
|
||||
for (let u = 1; u <= rackHeight; u += 1) {
|
||||
// 计算U位标识的Y坐标,需要加上设备组的偏移量
|
||||
const yPos = (u - 1) * uHeight + uHeight / 2 + deviceGroupOffset;
|
||||
|
||||
// 创建数字纹理 - 白色数字在深色背景上更清晰
|
||||
const createNumberTexture = (num) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 清除画布
|
||||
ctx.clearRect(0, 0, 128, 128);
|
||||
|
||||
// 绘制白色数字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 80px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(num.toString(), 64, 64);
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
};
|
||||
|
||||
const leftTexture = createNumberTexture(u);
|
||||
const rightTexture = createNumberTexture(u);
|
||||
|
||||
// 创建平面几何体显示数字 - 每5U使用稍大的尺寸
|
||||
const isMajorU = u % 5 === 0;
|
||||
const planeSize = isMajorU ? 0.035 : 0.025;
|
||||
const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize);
|
||||
|
||||
// 前侧柱子的位置: [-width/2 + postWidth/2, y, depth/2 - postWidth/2]
|
||||
const leftPostX = -width/2 + postWidth/2;
|
||||
const rightPostX = width/2 - postWidth/2;
|
||||
const frontPostZ = depth/2 - postWidth/2;
|
||||
|
||||
// 刻度线颜色:每5U使用醒目的黄色,其他使用灰色
|
||||
const tickColor = isMajorU ? '#fbbf24' : '#6b7280';
|
||||
const tickHeight = isMajorU ? 0.002 : 0.001;
|
||||
|
||||
labels.push(
|
||||
<group key={`u-label-${u}`}>
|
||||
{/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
|
||||
<mesh
|
||||
position={[leftPostX, yPos, frontPostZ + postWidth/2 + 0.001]}
|
||||
geometry={planeGeometry}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
map={leftTexture}
|
||||
transparent={true}
|
||||
opacity={1}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
|
||||
<mesh
|
||||
position={[rightPostX, yPos, frontPostZ + postWidth/2 + 0.001]}
|
||||
geometry={planeGeometry}
|
||||
>
|
||||
<meshBasicMaterial
|
||||
map={rightTexture}
|
||||
transparent={true}
|
||||
opacity={1}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* 左前侧柱子上的刻度线 */}
|
||||
<mesh position={[leftPostX, yPos, frontPostZ + postWidth/2 + 0.002]}>
|
||||
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
||||
<meshBasicMaterial color={tickColor} />
|
||||
</mesh>
|
||||
{/* 右前侧柱子上的刻度线 */}
|
||||
<mesh position={[rightPostX, yPos, frontPostZ + postWidth/2 + 0.002]}>
|
||||
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
||||
<meshBasicMaterial color={tickColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
return labels;
|
||||
}, [rackHeight, uHeight, width, depth, deviceGroupOffset]);
|
||||
|
||||
// 生成机柜框架
|
||||
const frame = useMemo(() => {
|
||||
const materialProps = { color: "#333", roughness: 0.5, metalness: 0.8 };
|
||||
@@ -86,35 +177,21 @@ const RackModel = ({
|
||||
{[-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}
|
||||
<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>
|
||||
{/* U位刻度标识 */}
|
||||
{uLabels}
|
||||
|
||||
</group>
|
||||
);
|
||||
}, [width, height, depth, postWidth]);
|
||||
}, [width, height, depth, postWidth, uLabels]);
|
||||
|
||||
return (
|
||||
<group position={[0, 0.5, 0]}>
|
||||
|
||||
@@ -1,12 +1,89 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import React, { Suspense, useMemo, useRef, useEffect } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
|
||||
const envMapUrl = '/assets/3d/env.hdr';
|
||||
import RackModel from './RackModel';
|
||||
import { useScene3D } from '../../context/Scene3DContext';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
// 检测是否为小屏幕
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
// 移动端或小屏幕使用 dpr=1,桌面端使用 dpr=[1, 1.5]
|
||||
const deviceDpr = isMobile || isSmallScreen ? 1 : [1, 1.5];
|
||||
|
||||
// 内部组件用于处理 OrbitControls
|
||||
const Controls = ({ rack }) => {
|
||||
const controlsRef = useRef();
|
||||
// 机柜中心点(中轴线)
|
||||
const targetY = (rack?.height || 45) * 0.04445 / 2 + 0.5;
|
||||
const fixedTarget = useMemo(() => new THREE.Vector3(0, targetY, 0), [targetY]);
|
||||
|
||||
useFrame(() => {
|
||||
if (controlsRef.current) {
|
||||
// 强制保持 target 在机柜中轴线
|
||||
controlsRef.current.target.copy(fixedTarget);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
});
|
||||
|
||||
const Scene = ({ rack, devices, selectedDeviceId, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled = true }) => {
|
||||
return (
|
||||
<Canvas shadows dpr={[1, 1.2]} performance={{ min: 0.5 }}>
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
makeDefault
|
||||
minPolarAngle={0}
|
||||
maxPolarAngle={Math.PI / 1.75}
|
||||
minAzimuthAngle={-Infinity}
|
||||
maxAzimuthAngle={Infinity}
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
mouseButtons={{
|
||||
LEFT: 0, // 左键旋转
|
||||
MIDDLE: 0, // 中键禁用(避免平移改变旋转中心)
|
||||
RIGHT: 0 // 右键禁用
|
||||
}}
|
||||
touches={{
|
||||
ONE: 1,
|
||||
TWO: 2
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Scene = ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }) => {
|
||||
// 从 Context 获取3D场景状态
|
||||
const {
|
||||
devices,
|
||||
selectedDevice,
|
||||
deviceSlideEnabled
|
||||
} = useScene3D();
|
||||
|
||||
// 使用 useMemo 稳定 props 引用
|
||||
const rackModelProps = useMemo(() => ({
|
||||
rack,
|
||||
devices,
|
||||
selectedDeviceId: selectedDevice?.id,
|
||||
onDeviceClick,
|
||||
onDeviceLeave,
|
||||
onDeviceHover,
|
||||
tooltipFields,
|
||||
deviceSlideEnabled
|
||||
}), [rack, devices, selectedDevice, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: !isMobile, // 移动端关闭抗锯齿提升性能
|
||||
alpha: true, // 必须开启alpha以支持透明背景
|
||||
powerPreference: 'high-performance'
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<PerspectiveCamera makeDefault position={[3, 2, 4]} fov={50} />
|
||||
|
||||
<ambientLight intensity={0.5} color="#ffffff" />
|
||||
@@ -29,37 +106,11 @@ const Scene = ({ rack, devices, selectedDeviceId, onDeviceClick, onDeviceLeave,
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel
|
||||
rack={rack}
|
||||
devices={devices}
|
||||
selectedDeviceId={selectedDeviceId}
|
||||
onDeviceClick={onDeviceClick}
|
||||
onDeviceLeave={onDeviceLeave}
|
||||
onDeviceHover={onDeviceHover}
|
||||
tooltipFields={tooltipFields}
|
||||
deviceSlideEnabled={deviceSlideEnabled}
|
||||
/>
|
||||
<RackModel {...rackModelProps} />
|
||||
</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
|
||||
/>
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls rack={rack} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
|
||||
|
||||
// 3D场景状态上下文
|
||||
// 将3D场景状态与UI状态分离,避免不必要的重渲染
|
||||
|
||||
const Scene3DContext = createContext(null);
|
||||
|
||||
export const Scene3DProvider = ({ children }) => {
|
||||
// 3D场景相关状态
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState(null);
|
||||
const [hoveredDevice, setHoveredDevice] = useState(null);
|
||||
const [deviceSlideEnabled, setDeviceSlideEnabled] = useState(false);
|
||||
const [selectedRack, setSelectedRack] = useState(null);
|
||||
const [racks, setRacks] = useState([]);
|
||||
const [deviceCables, setDeviceCables] = useState([]);
|
||||
const [loadingDevices, setLoadingDevices] = useState(false);
|
||||
|
||||
// 使用 useCallback 稳定回调函数
|
||||
const selectDevice = useCallback((device) => {
|
||||
setSelectedDevice(device);
|
||||
}, []);
|
||||
|
||||
const hoverDevice = useCallback((device) => {
|
||||
setHoveredDevice(device);
|
||||
}, []);
|
||||
|
||||
const toggleDeviceSlide = useCallback(() => {
|
||||
setDeviceSlideEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const setDeviceSlide = useCallback((enabled) => {
|
||||
setDeviceSlideEnabled(enabled);
|
||||
}, []);
|
||||
|
||||
const updateDevices = useCallback((newDevices) => {
|
||||
setDevices(newDevices);
|
||||
}, []);
|
||||
|
||||
const updateRacks = useCallback((newRacks) => {
|
||||
setRacks(newRacks);
|
||||
}, []);
|
||||
|
||||
const selectRack = useCallback((rack) => {
|
||||
setSelectedRack(rack);
|
||||
}, []);
|
||||
|
||||
const updateDeviceCables = useCallback((cables) => {
|
||||
setDeviceCables(cables);
|
||||
}, []);
|
||||
|
||||
const setLoading = useCallback((loading) => {
|
||||
setLoadingDevices(loading);
|
||||
}, []);
|
||||
|
||||
// 使用 useMemo 缓存 context value,避免不必要的重渲染
|
||||
const value = useMemo(() => ({
|
||||
// 状态
|
||||
devices,
|
||||
selectedDevice,
|
||||
hoveredDevice,
|
||||
deviceSlideEnabled,
|
||||
selectedRack,
|
||||
racks,
|
||||
deviceCables,
|
||||
loadingDevices,
|
||||
// 方法
|
||||
selectDevice,
|
||||
hoverDevice,
|
||||
toggleDeviceSlide,
|
||||
setDeviceSlide,
|
||||
updateDevices,
|
||||
updateRacks,
|
||||
selectRack,
|
||||
updateDeviceCables,
|
||||
setLoading,
|
||||
// 直接设置状态的方法(用于兼容现有代码)
|
||||
setDevices,
|
||||
setSelectedDevice,
|
||||
setHoveredDevice,
|
||||
setDeviceSlideEnabled,
|
||||
setSelectedRack,
|
||||
setRacks,
|
||||
setDeviceCables,
|
||||
setLoadingDevices,
|
||||
}), [
|
||||
devices, selectedDevice, hoveredDevice, deviceSlideEnabled,
|
||||
selectedRack, racks, deviceCables, loadingDevices,
|
||||
selectDevice, hoverDevice, toggleDeviceSlide, setDeviceSlide,
|
||||
updateDevices, updateRacks, selectRack, updateDeviceCables, setLoading
|
||||
]);
|
||||
|
||||
return (
|
||||
<Scene3DContext.Provider value={value}>
|
||||
{children}
|
||||
</Scene3DContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 自定义 Hook
|
||||
export const useScene3D = () => {
|
||||
const context = useContext(Scene3DContext);
|
||||
if (!context) {
|
||||
throw new Error('useScene3D must be used within a Scene3DProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default Scene3DContext;
|
||||
@@ -103,35 +103,38 @@ const responsiveConfig = {
|
||||
const containerStyle = {
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(180deg, #f5f7fa 0%, #e8ecf1 100%)',
|
||||
padding: '20px'
|
||||
padding: 'clamp(12px, 3vw, 20px)'
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
padding: '32px 48px',
|
||||
padding: 'clamp(20px, 5vw, 32px) clamp(16px, 4vw, 48px)',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '20px',
|
||||
borderRadius: 'clamp(12px, 3vw, 20px)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)',
|
||||
animation: 'fadeInDown 0.6s ease-out'
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontSize: '2.2rem',
|
||||
fontSize: 'clamp(1.4rem, 4vw, 2.2rem)',
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
margin: '0 0 8px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '16px'
|
||||
gap: 'clamp(8px, 2vw, 16px)',
|
||||
flexWrap: 'wrap',
|
||||
textAlign: 'center'
|
||||
};
|
||||
|
||||
const subtitleStyle = {
|
||||
fontSize: '1.1rem',
|
||||
fontSize: 'clamp(0.85rem, 2.5vw, 1.1rem)',
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
margin: '0'
|
||||
margin: '0',
|
||||
textAlign: 'center'
|
||||
};
|
||||
|
||||
const progressCardStyle = {
|
||||
@@ -198,7 +201,6 @@ const STAT_CARD_BASE_STYLE = {
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
background: '#fff',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
@@ -207,22 +209,20 @@ const STAT_CARD_BASE_STYLE = {
|
||||
};
|
||||
|
||||
const STAT_ICON_CONTAINER_BASE = {
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
width: 'clamp(40px, 8vw, 64px)',
|
||||
height: 'clamp(40px, 8vw, 64px)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '32px',
|
||||
transition: `all ${designTokens.transitions.normal}`
|
||||
fontSize: 'clamp(20px, 4vw, 32px)',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
flexShrink: 0
|
||||
};
|
||||
|
||||
const NAV_BUTTON_BASE = {
|
||||
height: 'auto',
|
||||
padding: '24px 20px',
|
||||
padding: 'clamp(16px, 4vw, 24px) clamp(12px, 3vw, 20px)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: '2px solid #f0f0f0',
|
||||
background: '#fff',
|
||||
@@ -230,20 +230,22 @@ const NAV_BUTTON_BASE = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
gap: 'clamp(8px, 2vw, 12px)',
|
||||
cursor: 'pointer',
|
||||
boxShadow: designTokens.shadows.small
|
||||
boxShadow: designTokens.shadows.small,
|
||||
minWidth: '0'
|
||||
};
|
||||
|
||||
const NAV_ICON_CONTAINER_BASE = {
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
width: 'clamp(44px, 10vw, 60px)',
|
||||
height: 'clamp(44px, 10vw, 60px)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
transition: `all ${designTokens.transitions.normal}`
|
||||
fontSize: 'clamp(20px, 5vw, 28px)',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
flexShrink: 0
|
||||
};
|
||||
|
||||
const createStatCardStyle = (color) => ({
|
||||
@@ -276,8 +278,8 @@ const overviewCardStyle = {
|
||||
|
||||
const navigationGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: '16px',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gap: 'clamp(8px, 2vw, 16px)',
|
||||
marginBottom: '24px'
|
||||
};
|
||||
|
||||
@@ -309,9 +311,14 @@ const navIconContainer = (color) => ({
|
||||
});
|
||||
|
||||
const navTextStyle = {
|
||||
fontSize: '0.9rem',
|
||||
fontSize: 'clamp(0.75rem, 2vw, 0.9rem)',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary
|
||||
color: designTokens.colors.text.primary,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%'
|
||||
};
|
||||
|
||||
const systemInfoStyle = {
|
||||
@@ -651,7 +658,7 @@ function Dashboard() {
|
||||
const statCards = useMemo(() => [
|
||||
{
|
||||
key: 'devices',
|
||||
xs: 24, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
xs: 12, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
icon: CloudServerOutlined,
|
||||
color: designTokens.colors.primary.main,
|
||||
statKey: 'totalDevices',
|
||||
@@ -662,7 +669,7 @@ function Dashboard() {
|
||||
},
|
||||
{
|
||||
key: 'racks',
|
||||
xs: 24, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
xs: 12, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
icon: DatabaseOutlined,
|
||||
color: designTokens.colors.purple.main,
|
||||
statKey: 'totalRacks',
|
||||
@@ -674,7 +681,7 @@ function Dashboard() {
|
||||
},
|
||||
{
|
||||
key: 'rooms',
|
||||
xs: 24, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
xs: 12, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
icon: HomeOutlined,
|
||||
color: designTokens.colors.success.main,
|
||||
statKey: 'totalRooms',
|
||||
@@ -686,7 +693,7 @@ function Dashboard() {
|
||||
},
|
||||
{
|
||||
key: 'faults',
|
||||
xs: 24, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
xs: 12, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
icon: WarningOutlined,
|
||||
color: designTokens.colors.error.main,
|
||||
statKey: 'faultDevices',
|
||||
@@ -697,7 +704,7 @@ function Dashboard() {
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
xs: 24, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
xs: 12, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
icon: TeamOutlined,
|
||||
color: designTokens.colors.cyan.main,
|
||||
statKey: 'totalUsers',
|
||||
@@ -708,7 +715,7 @@ function Dashboard() {
|
||||
},
|
||||
{
|
||||
key: 'tickets',
|
||||
xs: 24, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
xs: 12, sm: 12, md: 8, lg: 6, xl: 4,
|
||||
icon: BarChartOutlined,
|
||||
color: '#fa8c16',
|
||||
statKey: 'activeTickets',
|
||||
@@ -734,27 +741,31 @@ function Dashboard() {
|
||||
style={cardStyle}
|
||||
onMouseEnter={() => setHoveredCard(statKey)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
bodyStyle={{ padding: 'clamp(16px, 3vw, 24px)' }}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={createStatIconContainer(color)}>
|
||||
<Icon style={{ color }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: 'clamp(0.75rem, 2vw, 0.9rem)',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.secondary,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
<div style={createStatIconContainer(color)}>
|
||||
<Icon style={{ color }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.secondary,
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '2.2rem',
|
||||
<div className="stat-value" style={{
|
||||
fontSize: 'clamp(1.6rem, 4vw, 2.2rem)',
|
||||
fontWeight: '700',
|
||||
color: designTokens.colors.text.primary,
|
||||
marginBottom: '8px',
|
||||
background: `linear-gradient(135deg, ${color} 0%, ${color}cc 100%)`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
color: color,
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{loading ? (
|
||||
<Spin size="small" />
|
||||
@@ -764,26 +775,28 @@ function Dashboard() {
|
||||
</div>
|
||||
{customStatus ? (
|
||||
statKey === 'totalRacks' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.85rem', color: designTokens.colors.success.main }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: 'clamp(0.7rem, 1.8vw, 0.85rem)', color: designTokens.colors.success.main, whiteSpace: 'nowrap' }}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
background: designTokens.colors.success.main,
|
||||
borderRadius: '50%',
|
||||
marginRight: '8px',
|
||||
boxShadow: '0 0 8px rgba(82, 196, 26, 0.5)'
|
||||
marginRight: '6px',
|
||||
boxShadow: '0 0 6px rgba(82, 196, 26, 0.5)',
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<span>正常运行中</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.85rem', color: designTokens.colors.success.main }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: 'clamp(0.7rem, 1.8vw, 0.85rem)', color: designTokens.colors.success.main, whiteSpace: 'nowrap' }}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
background: designTokens.colors.success.main,
|
||||
borderRadius: '50%',
|
||||
marginRight: '8px',
|
||||
boxShadow: '0 0 8px rgba(82, 196, 26, 0.5)'
|
||||
marginRight: '6px',
|
||||
boxShadow: '0 0 6px rgba(82, 196, 26, 0.5)',
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<span>全部在线</span>
|
||||
</div>
|
||||
@@ -792,14 +805,15 @@ function Dashboard() {
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.875rem',
|
||||
fontSize: 'clamp(0.7rem, 1.8vw, 0.875rem)',
|
||||
fontWeight: '500',
|
||||
color: trend > 0 ? designTokens.colors.success.main : designTokens.colors.error.main,
|
||||
marginTop: '8px'
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{trend > 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
<span style={{ marginLeft: '4px' }}>{Math.abs(trend)}%</span>
|
||||
<Tag color={tagColor} style={{ marginLeft: '8px', fontSize: '0.75rem', borderRadius: '4px' }}>环比</Tag>
|
||||
{trend > 0 ? <ArrowUpOutlined style={{ fontSize: '0.75rem' }} /> : <ArrowDownOutlined style={{ fontSize: '0.75rem' }} />}
|
||||
<span>{Math.abs(trend)}%</span>
|
||||
<Tag color={tagColor} style={{ fontSize: 'clamp(0.6rem, 1.5vw, 0.75rem)', borderRadius: '4px', margin: 0, padding: '0 4px', lineHeight: '1.4' }}>环比</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -811,6 +825,7 @@ function Dashboard() {
|
||||
const navButtons = useMemo(() => navButtonsData.map(({ key, icon: Icon, text, color }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="nav-button"
|
||||
style={{
|
||||
...createNavButtonStyle(color),
|
||||
...(hoveredCard === `nav-${key}` ? {
|
||||
@@ -823,10 +838,10 @@ function Dashboard() {
|
||||
onMouseEnter={(e) => handleNavHover(e, true, `nav-${key}`)}
|
||||
onMouseLeave={(e) => handleNavHover(e, false, `nav-${key}`)}
|
||||
>
|
||||
<div style={createNavIconContainer(color)}>
|
||||
<Icon style={{ color, fontSize: '28px' }} />
|
||||
<div className="nav-icon" style={createNavIconContainer(color)}>
|
||||
<Icon style={{ color, fontSize: 'clamp(20px, 5vw, 28px)' }} />
|
||||
</div>
|
||||
<span style={navTextStyle}>{text}</span>
|
||||
<span className="nav-text" style={navTextStyle}>{text}</span>
|
||||
</div>
|
||||
)), [hoveredCard]);
|
||||
|
||||
@@ -902,13 +917,13 @@ function Dashboard() {
|
||||
return (
|
||||
<>
|
||||
<style>{styles}</style>
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<h1 style={titleStyle}>
|
||||
<div style={containerStyle} className="dashboard-container">
|
||||
<div style={headerStyle} className="dashboard-header">
|
||||
<h1 style={titleStyle} className="dashboard-title">
|
||||
<DashboardOutlined />
|
||||
IDC设备管理系统
|
||||
</h1>
|
||||
<p style={subtitleStyle}>实时监控 · 智能管理 · 高效运维</p>
|
||||
<p style={subtitleStyle} className="dashboard-subtitle">实时监控 · 智能管理 · 高效运维</p>
|
||||
</div>
|
||||
|
||||
<Row gutter={[24, 24]} style={{ marginBottom: '32px' }}>
|
||||
@@ -1042,7 +1057,7 @@ function Dashboard() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ ...navigationGridStyle, animation: 'fadeInUp 0.6s ease-out 0.6s backwards' }}>
|
||||
<div className="nav-grid" style={{ ...navigationGridStyle, animation: 'fadeInUp 0.6s ease-out 0.6s backwards' }}>
|
||||
{navButtons}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1051,14 +1066,42 @@ function Dashboard() {
|
||||
|
||||
<style>{`
|
||||
@media (max-width: 576px) {
|
||||
${containerStyle} {
|
||||
padding: 16px;
|
||||
.dashboard-container {
|
||||
padding: 12px !important;
|
||||
}
|
||||
${titleStyle} {
|
||||
font-size: 1.6rem;
|
||||
.dashboard-header {
|
||||
padding: 20px 16px !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
${headerStyle} {
|
||||
padding: 24px;
|
||||
.dashboard-title {
|
||||
font-size: 1.4rem !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
.dashboard-subtitle {
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.6rem !important;
|
||||
}
|
||||
.nav-grid {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
.nav-button {
|
||||
padding: 12px 8px !important;
|
||||
}
|
||||
.nav-icon {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
font-size: 20px !important;
|
||||
}
|
||||
.nav-text {
|
||||
font-size: 0.7rem !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 375px) {
|
||||
.nav-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } 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';
|
||||
@@ -9,6 +9,7 @@ import NetworkCardCreateModal from '../components/NetworkCardCreateModal';
|
||||
import PortCreateModal from '../components/PortCreateModal';
|
||||
import CableCreateModal from '../components/CableCreateModal';
|
||||
import DeviceDetailDrawer from '../components/DeviceDetailDrawer';
|
||||
import { useScene3D } from '../context/Scene3DContext';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Title, Text } = Typography;
|
||||
@@ -16,14 +17,29 @@ const { Option } = Select;
|
||||
|
||||
const Rack3DVisualization = () => {
|
||||
const navigate = useNavigate();
|
||||
const [racks, setRacks] = useState([]);
|
||||
const [selectedRack, setSelectedRack] = useState(null);
|
||||
|
||||
// 使用 Scene3DContext 管理3D场景状态
|
||||
const {
|
||||
devices,
|
||||
setDevices,
|
||||
selectedDevice,
|
||||
setSelectedDevice,
|
||||
hoveredDevice,
|
||||
setHoveredDevice,
|
||||
deviceSlideEnabled,
|
||||
setDeviceSlideEnabled,
|
||||
selectedRack,
|
||||
setSelectedRack,
|
||||
racks,
|
||||
setRacks,
|
||||
deviceCables,
|
||||
setDeviceCables,
|
||||
loadingDevices,
|
||||
setLoadingDevices,
|
||||
} = useScene3D();
|
||||
|
||||
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
|
||||
@@ -44,11 +60,13 @@ const Rack3DVisualization = () => {
|
||||
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);
|
||||
// 使用 ref 存储模态框状态,避免 handleDeviceHover 频繁重新创建
|
||||
const modalsOpenRef = useRef(false);
|
||||
|
||||
// 同步 ref 与 state
|
||||
useEffect(() => {
|
||||
modalsOpenRef.current = modalVisible || nicModalVisible || portModalVisible || cableModalVisible;
|
||||
}, [modalVisible, nicModalVisible, portModalVisible, cableModalVisible]);
|
||||
|
||||
const fetchDeviceCables = useCallback(async (deviceId) => {
|
||||
if (!deviceId) return;
|
||||
@@ -79,9 +97,14 @@ const Rack3DVisualization = () => {
|
||||
// Handle Edit Device Click
|
||||
const handleEditDevice = (device) => {
|
||||
setEditingDevice(device);
|
||||
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 当 Modal 打开时,初始化表单数据
|
||||
useEffect(() => {
|
||||
if (modalVisible && editingDevice) {
|
||||
// Prepare form data (similar to DeviceManagement)
|
||||
const deviceData = { ...device };
|
||||
const deviceData = { ...editingDevice };
|
||||
if (deviceData.purchaseDate) deviceData.purchaseDate = dayjs(deviceData.purchaseDate);
|
||||
if (deviceData.warrantyExpiry) deviceData.warrantyExpiry = dayjs(deviceData.warrantyExpiry);
|
||||
|
||||
@@ -107,8 +130,8 @@ const Rack3DVisualization = () => {
|
||||
}
|
||||
|
||||
form.setFieldsValue(cleanDeviceData);
|
||||
setModalVisible(true);
|
||||
};
|
||||
}
|
||||
}, [modalVisible, editingDevice, form]);
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalVisible(false);
|
||||
@@ -300,12 +323,13 @@ const Rack3DVisualization = () => {
|
||||
});
|
||||
}, [selectedRoom, racks]);
|
||||
|
||||
const handleDeviceClick = (device) => {
|
||||
// 使用 useCallback 稳定回调函数引用,避免 DeviceModel 不必要的重渲染
|
||||
const handleDeviceClick = useCallback((device) => {
|
||||
setSelectedDevice(device);
|
||||
if (device) {
|
||||
fetchDeviceCables(device.deviceId || device.id);
|
||||
}
|
||||
};
|
||||
}, [fetchDeviceCables]);
|
||||
|
||||
const handleDeviceLeave = (device) => {
|
||||
// 移除鼠标离开事件,避免抽屉意外关闭
|
||||
@@ -316,10 +340,12 @@ const Rack3DVisualization = () => {
|
||||
// });
|
||||
};
|
||||
|
||||
// 优化 handleDeviceHover - 使用 ref 获取最新状态,减少依赖项
|
||||
const handleDeviceHover = useCallback((device) => {
|
||||
if (modalVisible || nicModalVisible || portModalVisible) return;
|
||||
// 使用 ref 检查模态框状态,避免频繁重新创建回调
|
||||
if (modalsOpenRef.current) return;
|
||||
setHoveredDevice(device);
|
||||
}, [modalVisible, nicModalVisible, portModalVisible]);
|
||||
}, []); // 空依赖项,回调引用永远稳定
|
||||
|
||||
const handleClosePortModal = useCallback(() => {
|
||||
setPortModalVisible(false);
|
||||
@@ -627,7 +653,7 @@ const Rack3DVisualization = () => {
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{[
|
||||
{ icon: '🖱️', text: '左键旋转 / 中键平移' },
|
||||
{ icon: '🖱️', text: '左键旋转视图' },
|
||||
{ icon: '🔍', text: '滚轮缩放视图' },
|
||||
{ icon: '👆', text: '点击设备查看详情' }
|
||||
].map((item, i) => (
|
||||
|
||||
Reference in New Issue
Block a user