diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3fc7a54..aa6d060 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/3d/DeviceModel.jsx b/frontend/src/components/3d/DeviceModel.jsx index b6b4f08..12ef54e 100644 --- a/frontend/src/components/3d/DeviceModel.jsx +++ b/frontend/src/components/3d/DeviceModel.jsx @@ -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 ( - - - + ); }; @@ -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 ( - - - + {hasDetail && ( - - - + )} @@ -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 ( - - - + - - - + ); @@ -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 ( - - - + - - - + - - - + - - - - + ); @@ -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 ( - - - - - - - - - - - - - - - - + + + + ); }; @@ -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 ( + + {/* 基础背板 - 使用共享几何体 */} + + + ); + }; + + // 渲染完整版背板(包含 PSU、风扇、网卡等细节) + const renderFullBackPanel = (backZ) => { + return ( + + {/* 基础背板 - 透明玻璃质感 */} + + + + + + {/* 电源模块 (PSU) - 左侧 */} + + {[-0.02, 0.02].map((yOffset, i) => ( + 0.15 ? yOffset * 2 : 0, 0]}> + {(height > 0.15 || i === 0) && ( + + {/* PSU 面板 */} + + 0.15 ? 0.04 : 0.08, 0.004]} /> + + + {/* 把手 */} + + + + + {/* 电源插口 */} + + + + + {/* 状态灯 */} + + + + + + )} + + ))} + + + {/* 风扇模块 (Fan Modules) - 中间 */} + + {Array.from({ length: 3 }).map((_, i) => ( + + {/* 风扇网罩 */} + + + + + {/* 风扇叶片 */} + + + + + {/* 红色拉手 */} + + + + + + ))} + + + {/* 网卡/扩展模块 (PCIe/LOM) - 右侧 */} + + {Array.from({ length: 2 }).map((_, i) => ( + + + + + + {/* 端口 */} + + + + + + + + + + ))} + + + ); + }; + + // 根据级别渲染背板 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 ( - - {/* 基础背板 - 透明玻璃质感 (Reduced quality for performance) */} - - - - - - {/* 电源模块 (PSU) - 左侧 (从背面看是右侧,但我们旋转了) */} - {/* 2个 PSU 垂直排列或并排 */} - - {[-0.02, 0.02].map((yOffset, i) => ( - 0.15 ? yOffset * 2 : 0, 0]}> - {/* 如果高度够大(>1U),垂直排列,否则只显示一个或者水平排列 */} - {/* 简化:1U设备只显示左边一个,2U显示两个 */} - {(height > 0.15 || i === 0) && ( - - {/* PSU 面板 */} - - 0.15 ? 0.04 : 0.08, 0.004]} /> - - - {/* 把手 */} - - - - - {/* 电源插口 C13 */} - - - - - {/* 状态灯 */} - - - - - - )} - - ))} - - - {/* 风扇模块 (Fan Modules) - 中间 */} - {/* 3-4个风扇阵列 */} - - {Array.from({ length: 3 }).map((_, i) => ( - - {/* 风扇网罩 */} - - - - - {/* 风扇叶片模拟 (纹理) */} - - - - - {/* 红色拉手 (热插拔) */} - - - - - - ))} - - - {/* 网卡/扩展模块 (PCIe/LOM) - 右侧 */} - - {/* 竖向 PCIe 挡板 */} - {Array.from({ length: 2 }).map((_, i) => ( - - - - - - {/* 端口 (SFP+ or RJ45) */} - - - - - - - - - - ))} - - - ); + // 根据背板级别决定渲染内容 + 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 ( { 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) - 深色金属 */} - - - - + {/* 机身 (Chassis) - 使用共享几何体和材质 */} + - {/* 前面板底座 (Front Panel Base) */} - - - - + {/* 前面板底座 (Front Panel Base) - 使用共享资源 */} + {/* 告警时的红色辉光 (设备两侧) */} {(device.status === 'error' || device.status === 'fault') && ( @@ -815,14 +1056,16 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight )} */} - {/* 状态指示灯 (统一位置) */} + {/* 状态指示灯 (统一位置) - 使用共享几何体 */} {/* 灯座 */} - - - - - {/* 发光体 */} + + {/* 发光体 - 需要动态颜色,使用克隆材质 */} @@ -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; diff --git a/frontend/src/components/3d/LODManager.jsx b/frontend/src/components/3d/LODManager.jsx index 7158e4e..57b686c 100644 --- a/frontend/src/components/3d/LODManager.jsx +++ b/frontend/src/components/3d/LODManager.jsx @@ -80,7 +80,7 @@ const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusC {Array.from({ length: is2U ? 4 : 2 }).map((_, i) => ( - + @@ -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 ( - {lodLevel === LOD_LEVELS.HIGH ? children : renderLODMesh()} + {/* 高细节模型 - 默认显示 */} + + {children} + + + {/* 中等细节模型 */} + + {createMediumDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor)} + + + {/* 低细节模型 */} + + {createSimplifiedDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor)} + ); }; diff --git a/frontend/src/components/3d/RackModel.jsx b/frontend/src/components/3d/RackModel.jsx index 4dc30d2..40a1f6a 100644 --- a/frontend/src/components/3d/RackModel.jsx +++ b/frontend/src/components/3d/RackModel.jsx @@ -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( + + {/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */} + + + + {/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */} + + + + {/* 左前侧柱子上的刻度线 */} + + + + + {/* 右前侧柱子上的刻度线 */} + + + + + + ); + } + 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) => ( - ))} - - - - - - - - + {/* U位刻度标识 */} + {uLabels} ); - }, [width, height, depth, postWidth]); + }, [width, height, depth, postWidth, uLabels]); return ( diff --git a/frontend/src/components/3d/Scene.jsx b/frontend/src/components/3d/Scene.jsx index 9f19275..913e052 100644 --- a/frontend/src/components/3d/Scene.jsx +++ b/frontend/src/components/3d/Scene.jsx @@ -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 ( - + + ); +}; + +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 ( + @@ -29,37 +106,11 @@ const Scene = ({ rack, devices, selectedDeviceId, onDeviceClick, onDeviceLeave, {/* Models */} - + - {/* Controls */} - + {/* Controls - 使用独立组件保持旋转中心固定 */} + ); }; diff --git a/frontend/src/context/Scene3DContext.jsx b/frontend/src/context/Scene3DContext.jsx new file mode 100644 index 0000000..cff9f8b --- /dev/null +++ b/frontend/src/context/Scene3DContext.jsx @@ -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 ( + + {children} + + ); +}; + +// 自定义 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; diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index cc633ae..0782b23 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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)' }} > -
-
- +
+
+ + {title} + +
+ +
-
- {title} -
-
{loading ? ( @@ -764,26 +775,28 @@ function Dashboard() {
{customStatus ? ( statKey === 'totalRacks' ? ( -
+
正常运行中
) : ( -
+
全部在线
@@ -792,14 +805,15 @@ function Dashboard() {
0 ? designTokens.colors.success.main : designTokens.colors.error.main, - marginTop: '8px' + flexWrap: 'wrap', + gap: '4px' }}> - {trend > 0 ? : } - {Math.abs(trend)}% - 环比 + {trend > 0 ? : } + {Math.abs(trend)}% + 环比
)}
@@ -811,6 +825,7 @@ function Dashboard() { const navButtons = useMemo(() => navButtonsData.map(({ key, icon: Icon, text, color }) => (
handleNavHover(e, true, `nav-${key}`)} onMouseLeave={(e) => handleNavHover(e, false, `nav-${key}`)} > -
- +
+
- {text} + {text}
)), [hoveredCard]); @@ -902,13 +917,13 @@ function Dashboard() { return ( <> -
-
-

+
+
+

IDC设备管理系统

-

实时监控 · 智能管理 · 高效运维

+

实时监控 · 智能管理 · 高效运维

@@ -1042,7 +1057,7 @@ function Dashboard() { -
+
{navButtons}
@@ -1051,14 +1066,42 @@ function Dashboard() { diff --git a/frontend/src/pages/Rack3DVisualization.jsx b/frontend/src/pages/Rack3DVisualization.jsx index 921b218..9aca32b 100644 --- a/frontend/src/pages/Rack3DVisualization.jsx +++ b/frontend/src/pages/Rack3DVisualization.jsx @@ -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 = () => {
{[ - { icon: '🖱️', text: '左键旋转 / 中键平移' }, + { icon: '🖱️', text: '左键旋转视图' }, { icon: '🔍', text: '滚轮缩放视图' }, { icon: '👆', text: '点击设备查看详情' } ].map((item, i) => (