feat: 优化3D可视化性能并更新文档
- 实现3D可视化性能优化(降低DPR、阴影贴图、简化光源) - 实现LOD多级细节系统,根据相机距离自动切换 - 添加设备弹出动画开关,默认关闭 - 修复设备缩放时位置偏移问题 - 更新README添加项目截图占位符 - 精简DEPLOYMENT.md文档,添加Gitee仓库支持 - 更新CHANGELOG.md记录v1.1.0版本
This commit is contained in:
@@ -0,0 +1,837 @@
|
||||
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const PERFORMANCE_MODE = true;
|
||||
|
||||
const InstancedStatusLights = ({ count, positions, colors: statusColors, zOffset }) => {
|
||||
const meshRef = useRef();
|
||||
const colorArray = useMemo(() => {
|
||||
const arr = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const color = new THREE.Color(statusColors[i] || '#22c55e');
|
||||
arr[i * 3] = color.r;
|
||||
arr[i * 3 + 1] = color.g;
|
||||
arr[i * 3 + 2] = color.b;
|
||||
}
|
||||
return arr;
|
||||
}, [count, statusColors]);
|
||||
|
||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y, positions[i].z + zOffset);
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [count, positions, zOffset, dummy]);
|
||||
|
||||
return (
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
||||
<circleGeometry args={[0.0015, 8]} />
|
||||
<meshBasicMaterial toneMapped={false} />
|
||||
</instancedMesh>
|
||||
);
|
||||
};
|
||||
|
||||
const InstancedDriveBays = ({ count, positions, color, hasDetail = true }) => {
|
||||
const meshRef = useRef();
|
||||
const detailRef = useRef();
|
||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y, positions[i].z);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (detailRef.current && hasDetail) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x + 0.04, positions[i].y, positions[i].z + 0.003);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
detailRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
detailRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [count, positions, hasDetail, dummy]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.07, 0.035, 0.004]} />
|
||||
<meshStandardMaterial color={color || '#334155'} roughness={0.6} metalness={0.4} />
|
||||
</instancedMesh>
|
||||
{hasDetail && (
|
||||
<instancedMesh ref={detailRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.015, 0.025, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" />
|
||||
</instancedMesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const InstancedStorageBays = ({ count, positions, color }) => {
|
||||
const meshRef = useRef();
|
||||
const detailRef = useRef();
|
||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y, positions[i].z);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (detailRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x + 0.03, positions[i].y, positions[i].z + 0.003);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
detailRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
detailRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [count, positions, dummy]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.095, 0.028, 0.005]} />
|
||||
<meshStandardMaterial color={color || '#334155'} metalness={0.6} roughness={0.4} />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={detailRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.02, 0.015, 0.002]} />
|
||||
<meshStandardMaterial color="#000" />
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const InstancedRJ45Ports = ({ count, positions, statuses, frontZ }) => {
|
||||
const meshRef = useRef();
|
||||
const innerRef = useRef();
|
||||
const tabRef = useRef();
|
||||
const ledRef = useRef();
|
||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y + 0.012, frontZ + 0.006);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (innerRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y + 0.012, frontZ + 0.007);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
innerRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
innerRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (tabRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y + 0.015, frontZ + 0.008);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
tabRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
tabRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (ledRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const status = statuses[i] || 'disconnected';
|
||||
const ledY = positions[i].y + (i % 2 === 0 ? 0.021 : -0.002);
|
||||
dummy.position.set(positions[i].x - 0.004, ledY, frontZ + 0.006);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
ledRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
ledRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [count, positions, statuses, frontZ, dummy]);
|
||||
|
||||
const ledColors = useMemo(() => {
|
||||
return statuses.map(s => s !== 'disconnected' ? (s === 'fault' ? '#ef4444' : '#22c55e') : '#475569');
|
||||
}, [statuses]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.011, 0.011, 0.004]} />
|
||||
<meshStandardMaterial color="#cbd5e1" metalness={0.8} />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={innerRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.009, 0.009, 0.002]} />
|
||||
<meshStandardMaterial color="#000" />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={tabRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.007, 0.001, 0.001]} />
|
||||
<meshBasicMaterial color="#facc15" />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={ledRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.002, 0.002, 0.001]} />
|
||||
<meshBasicMaterial toneMapped={false} />
|
||||
<instancedBufferAttribute attach="geometry-attributes-color" args={[new Float32Array(count * 3), 3]} />
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const InstancedSFPports = ({ count, positions, statuses, frontZ }) => {
|
||||
const meshRef = useRef();
|
||||
const innerRef = useRef();
|
||||
const connectorRef = useRef();
|
||||
const ledRef = useRef();
|
||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.006);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (innerRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.009);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
innerRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
innerRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (connectorRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isConnected = statuses[i] !== 'disconnected';
|
||||
dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.012);
|
||||
dummy.scale.set(isConnected ? 1 : 0.001, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
connectorRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
connectorRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
if (ledRef.current) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isConnected = statuses[i] !== 'disconnected';
|
||||
dummy.position.set(positions[i].x, positions[i].y + 0.01, frontZ + 0.009);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
ledRef.current.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
ledRef.current.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [count, positions, statuses, frontZ, dummy]);
|
||||
|
||||
const ledColors = useMemo(() => {
|
||||
return positions.map((_, i) => statuses[i] !== 'disconnected' ? '#22c55e' : '#475569');
|
||||
}, [positions, statuses]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.02, 0.015, 0.005]} />
|
||||
<meshStandardMaterial color="#cbd5e1" metalness={0.9} roughness={0.3} />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={innerRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.016, 0.011, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={connectorRef} args={[undefined, undefined, count]}>
|
||||
<boxGeometry args={[0.01, 0.002, 0.008]} />
|
||||
<meshStandardMaterial color="#3b82f6" />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={ledRef} args={[undefined, undefined, count]}>
|
||||
<coneGeometry args={[0.002, 0.004, 3]} />
|
||||
<meshBasicMaterial />
|
||||
</instancedMesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const FirewallFace = ({ device, height, frontZ, isSelected }) => {
|
||||
const halfWidth = 0.4826 / 2;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[-halfWidth + 0.02, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.01, height, 0.003]} />
|
||||
<meshStandardMaterial color="#ef4444" emissive="#ef4444" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.46, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.6} />
|
||||
</mesh>
|
||||
|
||||
<group position={[-0.08, 0, frontZ + 0.007]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.12, 0.03, 0.002]} />
|
||||
<meshStandardMaterial color="#000000" roughness={0.1} metalness={0.8} />
|
||||
</mesh>
|
||||
<group position={[0, 0, 0.002]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.10, 0.001, 0.001]} />
|
||||
<meshBasicMaterial color="#10b981" transparent opacity={0.5} />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group position={[0.1, 0, frontZ + 0.007]}>
|
||||
{!PERFORMANCE_MODE && Array.from({ length: 4 }).map((_, col) => (
|
||||
<mesh key={col} position={[-0.03 + col * 0.02, 0, 0]}>
|
||||
<circleGeometry args={[0.008, 6]} />
|
||||
<meshBasicMaterial color="#1a202c" />
|
||||
</mesh>
|
||||
))}
|
||||
{!PERFORMANCE_MODE && (
|
||||
<pointLight position={[0, 0, -0.01]} color="#f97316" intensity={0.8} distance={0.2} />
|
||||
)}
|
||||
</group>
|
||||
|
||||
<group position={[0.2, 0.01, frontZ + 0.008]}>
|
||||
{['#22c55e', '#22c55e', device.status === 'error' ? '#ef4444' : '#4b5563'].map((color, i) => (
|
||||
<mesh key={i} position={[0, -i * 0.01, 0]}>
|
||||
<circleGeometry args={[0.002, 8]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
{i === 2 && device.status === 'error' && (
|
||||
<pointLight color="#ef4444" intensity={1} distance={0.05} />
|
||||
)}
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight: propUHeight, position, rackDepth, slideEnabled = true }) => {
|
||||
const mesh = useRef();
|
||||
const [hovered, setHover] = useState(false);
|
||||
const [isExtended, setIsExtended] = useState(false);
|
||||
const [currentZ, setCurrentZ] = useState(0);
|
||||
|
||||
// 使用传入的 uHeight (默认为 0.04445m)
|
||||
const uHeight = propUHeight || 0.04445;
|
||||
const depth = rackDepth || 1.0; // 机柜深度,默认1米
|
||||
|
||||
// 滑轨动画 - 仅在展开时运行且slideEnabled为true
|
||||
useFrame(() => {
|
||||
if (!slideEnabled || (!isExtended && Math.abs(currentZ) < 0.01)) {
|
||||
if (currentZ !== 0) setCurrentZ(0);
|
||||
return;
|
||||
}
|
||||
const targetZ = isExtended ? 0.6 : 0;
|
||||
const lerpFactor = 0.1;
|
||||
setCurrentZ(prev => prev + (targetZ - prev) * lerpFactor);
|
||||
});
|
||||
|
||||
// 尺寸定义 (适配标准 0.6m 机柜)
|
||||
// 标准19英寸机柜内部净宽约 0.45m,设备面板宽 0.4826m (19inch)
|
||||
const chassisWidth = 0.44; // 机身宽度 440mm
|
||||
const chassisDepth = 0.8; // 机身深度 800mm
|
||||
const panelWidth = 0.4826; // 前面板宽度 482.6mm (19英寸)
|
||||
const panelDepth = 0.02; // 前面板厚度 20mm
|
||||
|
||||
// 计算实际位置(防止超出机柜)
|
||||
// 优先使用父组件传入的 position,如果未传入则内部计算(作为 fallback)
|
||||
// 注意:父组件 RackModel 已经计算好了 position=[0, yPos, 0]
|
||||
// 我们只需要处理设备的高度
|
||||
|
||||
const dHeight = device.height || device.u_height || 1;
|
||||
const height = dHeight * uHeight;
|
||||
|
||||
// 间隙调整:减少设备上下间隙,使其更饱满,减少视觉偏差
|
||||
const gap = 0.002; // 总间隙 2mm
|
||||
|
||||
// Z轴定位
|
||||
// 假设机柜中心为0,前沿为 depth/2
|
||||
// 设备前面板应该贴近前沿
|
||||
const frontZ = depth / 2 - 0.02;
|
||||
const panelZ = frontZ - panelDepth / 2;
|
||||
const chassisZ = frontZ - panelDepth - chassisDepth / 2;
|
||||
|
||||
// 颜色定义 (参考 CSS 变量)
|
||||
const colors = {
|
||||
server: '#3b82f6', // --color-device-server
|
||||
switch: '#22c55e', // --color-device-switch
|
||||
router: '#f59e0b', // --color-device-router
|
||||
firewall: '#ef4444', // --color-device-firewall
|
||||
storage: '#8b5cf6', // --color-device-storage
|
||||
default: '#3b82f6',
|
||||
status: {
|
||||
running: '#10b981', // --color-status-running
|
||||
warning: '#f59e0b', // --color-status-warning
|
||||
error: '#ef4444', // --color-status-error
|
||||
offline: '#6b7280' // --color-status-offline
|
||||
},
|
||||
panelBg: '#1e293b', // 深色面板背景
|
||||
panelLight: '#334155',
|
||||
text: '#f1f5f9'
|
||||
};
|
||||
|
||||
const getDeviceColor = (type) => {
|
||||
const t = type?.toLowerCase() || '';
|
||||
if (t.includes('server') || t.includes('服务器')) return colors.server;
|
||||
if (t.includes('switch') || t.includes('交换机')) return colors.switch;
|
||||
if (t.includes('router') || t.includes('路由器')) return colors.router;
|
||||
if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall;
|
||||
if (t.includes('storage') || t.includes('存储')) return colors.storage;
|
||||
return colors.default;
|
||||
};
|
||||
|
||||
const deviceColor = getDeviceColor(device.type);
|
||||
const statusColor = colors.status[device.status] || colors.status.running;
|
||||
|
||||
// 渲染服务器前面板细节
|
||||
const renderServerFace = () => {
|
||||
const is2U = height > 0.08;
|
||||
const driveRows = is2U ? 2 : 1;
|
||||
const driveCols = PERFORMANCE_MODE ? 3 : 4;
|
||||
const driveWidth = 0.07;
|
||||
const driveHeight = 0.035;
|
||||
|
||||
const drivePositions = [];
|
||||
for (let row = 0; row < driveRows; row++) {
|
||||
for (let col = 0; col < driveCols; col++) {
|
||||
if (!is2U && row > 0) continue;
|
||||
const xPos = (col - 1) * (driveWidth + 0.005);
|
||||
const yPos = is2U ? (row === 0 ? 0.02 : -0.02) : 0;
|
||||
drivePositions.push({ x: xPos, y: yPos, z: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" roughness={0.7} metalness={0.5} />
|
||||
</mesh>
|
||||
|
||||
<group position={[-0.18, 0, frontZ + 0.006]}>
|
||||
<mesh position={[-0.02, 0, 0]}>
|
||||
<boxGeometry args={[0.04, height - 0.01, 0.002]} />
|
||||
<meshStandardMaterial color="#000000" roughness={0.2} />
|
||||
</mesh>
|
||||
<group position={[-0.025, 0.01, 0.002]}>
|
||||
<mesh rotation={[Math.PI/2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.004, 0.004, 0.002, 16]} />
|
||||
<meshStandardMaterial color="#cbd5e1" metalness={0.8} />
|
||||
</mesh>
|
||||
{!PERFORMANCE_MODE && (
|
||||
<pointLight color="#22c55e" intensity={0.5} distance={0.05} />
|
||||
)}
|
||||
<mesh position={[0, 0, 0.0011]} rotation={[Math.PI/2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.002, 0.002, 0.001, 16]} />
|
||||
<meshBasicMaterial color={device.status === 'offline' ? '#4b5563' : '#22c55e'} />
|
||||
</mesh>
|
||||
</group>
|
||||
<mesh position={[-0.015, 0.01, 0.002]} rotation={[Math.PI/2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.002, 0.002, 0.002, 16]} />
|
||||
<meshStandardMaterial color="#3b82f6" />
|
||||
</mesh>
|
||||
<mesh position={[-0.02, -0.01, 0.002]}>
|
||||
<boxGeometry args={[0.006, 0.012, 0.002]} />
|
||||
<meshStandardMaterial color="#475569" />
|
||||
</mesh>
|
||||
<mesh position={[-0.02, -0.01, 0.001]}>
|
||||
<boxGeometry args={[0.004, 0.01, 0.001]} />
|
||||
<meshBasicMaterial color="#000" />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
<group position={[0.05, 0, frontZ + 0.006]}>
|
||||
<InstancedDriveBays
|
||||
count={drivePositions.length}
|
||||
positions={drivePositions}
|
||||
color="#334155"
|
||||
hasDetail={!PERFORMANCE_MODE}
|
||||
/>
|
||||
</group>
|
||||
|
||||
<group position={[0.2, 0, frontZ + 0.006]}>
|
||||
<mesh position={[0, 0, 0.002]} rotation={[0, 0, Math.PI/2]}>
|
||||
<cylinderGeometry args={[0.005, 0.005, 0.002, 6]} />
|
||||
<meshStandardMaterial color="#3b82f6" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0.003]}>
|
||||
<boxGeometry args={[0.012, 0.006, 0.001]} />
|
||||
<meshBasicMaterial color="#000" />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染存储设备前面板细节
|
||||
const renderStorageFace = () => {
|
||||
const rows = PERFORMANCE_MODE ? 2 : 3;
|
||||
const cols = PERFORMANCE_MODE ? 2 : 4;
|
||||
const bayWidth = 0.10;
|
||||
const bayHeight = 0.028;
|
||||
|
||||
const bayPositions = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const xPos = (col - 1) * (bayWidth + 0.002);
|
||||
const yStep = (height - 0.02) / rows;
|
||||
const yPos = (rows - 1 - row) * yStep - (rows - 1) * yStep / 2;
|
||||
bayPositions.push({ x: xPos, y: yPos, z: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#0f172a" roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
<group position={[0, 0, frontZ + 0.008]}>
|
||||
<InstancedStorageBays
|
||||
count={bayPositions.length}
|
||||
positions={bayPositions}
|
||||
color="#334155"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染交换机前面板细节
|
||||
const renderSwitchFace = () => {
|
||||
const getPortStatus = (portName) => {
|
||||
if (!device.cables || !Array.isArray(device.cables)) return 'disconnected';
|
||||
const cable = device.cables.find(c =>
|
||||
(c.sourceDeviceId === device.deviceId && c.sourcePort === portName) ||
|
||||
(c.targetDeviceId === device.deviceId && c.targetPort === portName)
|
||||
);
|
||||
return cable ? (cable.status || 'normal') : 'disconnected';
|
||||
};
|
||||
|
||||
const sfpCount = PERFORMANCE_MODE ? 2 : 4;
|
||||
const rj45GroupCount = PERFORMANCE_MODE ? 2 : 4;
|
||||
const rj45ColCount = PERFORMANCE_MODE ? 4 : 6;
|
||||
|
||||
const sfpPositions = [];
|
||||
const sfpStatuses = [];
|
||||
for (let i = 0; i < sfpCount; i++) {
|
||||
sfpPositions.push({ x: -0.13 + i * 0.025, y: -0.005, z: 0 });
|
||||
sfpStatuses.push(getPortStatus(`SFP+ ${i + 1}`));
|
||||
}
|
||||
|
||||
const rj45PortCount = rj45GroupCount * rj45ColCount * 2;
|
||||
const rj45Positions = [];
|
||||
const rj45Statuses = [];
|
||||
for (let groupIndex = 0; groupIndex < rj45GroupCount; groupIndex++) {
|
||||
const groupX = -0.02 + groupIndex * 0.09;
|
||||
for (let colIndex = 0; colIndex < rj45ColCount; colIndex++) {
|
||||
const portX = -0.03 + colIndex * 0.012;
|
||||
const globalCol = groupIndex * rj45ColCount + colIndex;
|
||||
const topPortNum = globalCol * 2 + 1;
|
||||
const bottomPortNum = globalCol * 2 + 2;
|
||||
const topStatus = getPortStatus(`Port ${topPortNum}`);
|
||||
const bottomStatus = getPortStatus(`Port ${bottomPortNum}`);
|
||||
rj45Positions.push({ x: groupX + portX, y: 0, z: 0 });
|
||||
rj45Statuses.push(topStatus);
|
||||
rj45Positions.push({ x: groupX + portX, y: -0.024, z: 0 });
|
||||
rj45Statuses.push(bottomStatus);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#334155" roughness={0.6} metalness={0.4} />
|
||||
</mesh>
|
||||
|
||||
<group position={[-0.19, 0, frontZ + 0.006]}>
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<boxGeometry args={[0.05, height - 0.015, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" />
|
||||
</mesh>
|
||||
<mesh position={[-0.015, 0.005, 0.002]}>
|
||||
<boxGeometry args={[0.012, 0.01, 0.002]} />
|
||||
<meshStandardMaterial color="#94a3b8" />
|
||||
</mesh>
|
||||
<mesh position={[-0.015, 0.012, 0.002]}>
|
||||
<boxGeometry args={[0.012, 0.002, 0.001]} />
|
||||
<meshBasicMaterial color="#3b82f6" />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0.015, 0.005, 0.002]}>
|
||||
<boxGeometry args={[0.008, 0.012, 0.002]} />
|
||||
<meshStandardMaterial color="#cbd5e1" />
|
||||
</mesh>
|
||||
|
||||
<group position={[0, -0.01, 0.002]}>
|
||||
{['SYS', 'PWR'].map((label, idx) => (
|
||||
<group key={label} position={[(idx - 0.5) * 0.02, 0, 0]}>
|
||||
<mesh>
|
||||
<circleGeometry args={[0.0015, 8]} />
|
||||
<meshBasicMaterial color="#22c55e" />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group position={[-0.13, -0.005, 0]}>
|
||||
<InstancedSFPports
|
||||
count={sfpCount}
|
||||
positions={sfpPositions}
|
||||
statuses={sfpStatuses}
|
||||
frontZ={frontZ}
|
||||
/>
|
||||
</group>
|
||||
|
||||
<group position={[-0.02, 0, 0]}>
|
||||
<InstancedRJ45Ports
|
||||
count={rj45PortCount}
|
||||
positions={rj45Positions}
|
||||
statuses={rj45Statuses}
|
||||
frontZ={frontZ}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设备背板
|
||||
const renderBackPanel = () => {
|
||||
// Back face Z position (flush with back of chassis)
|
||||
const backZ = chassisZ - chassisDepth / 2;
|
||||
|
||||
// Rotate 180 deg to face backwards
|
||||
return (
|
||||
<group position={[0, 0, backZ]} rotation={[0, Math.PI, 0]}>
|
||||
{/* 基础背板 - 透明玻璃质感 (Reduced quality for performance) */}
|
||||
<mesh position={[0, 0, 0.001]}>
|
||||
<boxGeometry args={[chassisWidth, height - gap, 0.002]} />
|
||||
<meshStandardMaterial
|
||||
color="#94a3b8"
|
||||
transparent
|
||||
opacity={0.3}
|
||||
roughness={0.2}
|
||||
metalness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* 电源模块 (PSU) - 左侧 (从背面看是右侧,但我们旋转了) */}
|
||||
{/* 2个 PSU 垂直排列或并排 */}
|
||||
<group position={[0.25, 0, 0.002]}>
|
||||
{[-0.02, 0.02].map((yOffset, i) => (
|
||||
<group key={i} position={[0, height > 0.15 ? yOffset * 2 : 0, 0]}>
|
||||
{/* 如果高度够大(>1U),垂直排列,否则只显示一个或者水平排列 */}
|
||||
{/* 简化:1U设备只显示左边一个,2U显示两个 */}
|
||||
{(height > 0.15 || i === 0) && (
|
||||
<group position={[i === 1 && height <= 0.15 ? -0.06 : 0, 0, 0]}>
|
||||
{/* PSU 面板 */}
|
||||
<mesh>
|
||||
<boxGeometry args={[0.05, height > 0.15 ? 0.04 : 0.08, 0.004]} />
|
||||
<meshStandardMaterial color="#94a3b8" metalness={0.8} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* 把手 */}
|
||||
<mesh position={[0, 0, 0.004]}>
|
||||
<boxGeometry args={[0.01, 0.02, 0.004]} />
|
||||
<meshStandardMaterial color="#000000" />
|
||||
</mesh>
|
||||
{/* 电源插口 C13 */}
|
||||
<mesh position={[0.015, 0, 0.002]}>
|
||||
<boxGeometry args={[0.012, 0.01, 0.002]} />
|
||||
<meshStandardMaterial color="#1a202c" />
|
||||
</mesh>
|
||||
{/* 状态灯 */}
|
||||
<mesh position={[-0.015, 0.01, 0.002]}>
|
||||
<circleGeometry args={[0.002, 8]} />
|
||||
<meshBasicMaterial color="#22c55e" />
|
||||
</mesh>
|
||||
</group>
|
||||
)}
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* 风扇模块 (Fan Modules) - 中间 */}
|
||||
{/* 3-4个风扇阵列 */}
|
||||
<group position={[0, 0, 0.002]}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<group key={i} position={[-0.1 + i * 0.1, 0, 0]}>
|
||||
{/* 风扇网罩 */}
|
||||
<mesh>
|
||||
<boxGeometry args={[0.08, height - 0.03, 0.001]} />
|
||||
<meshStandardMaterial color="#1e293b" />
|
||||
</mesh>
|
||||
{/* 风扇叶片模拟 (纹理) */}
|
||||
<mesh position={[0, 0, 0.001]}>
|
||||
<circleGeometry args={[Math.min(0.035, (height-0.03)/2), 8]} />
|
||||
<meshBasicMaterial color="#334155" />
|
||||
</mesh>
|
||||
{/* 红色拉手 (热插拔) */}
|
||||
<mesh position={[0, -0.01, 0.003]}>
|
||||
<boxGeometry args={[0.01, 0.02, 0.002]} />
|
||||
<meshStandardMaterial color="#ef4444" />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* 网卡/扩展模块 (PCIe/LOM) - 右侧 */}
|
||||
<group position={[-0.25, 0, 0.002]}>
|
||||
{/* 竖向 PCIe 挡板 */}
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<group key={i} position={[i * 0.08, 0, 0]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.02, height - 0.02, 0.001]} />
|
||||
<meshStandardMaterial color="#cbd5e1" metalness={0.9} />
|
||||
</mesh>
|
||||
{/* 端口 (SFP+ or RJ45) */}
|
||||
<mesh position={[0, 0.01, 0.002]}>
|
||||
<boxGeometry args={[0.012, 0.012, 0.002]} />
|
||||
<meshStandardMaterial color="#000000" />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.01, 0.002]}>
|
||||
<boxGeometry args={[0.012, 0.012, 0.002]} />
|
||||
<meshStandardMaterial color="#000000" />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
const renderDeviceFace = () => {
|
||||
const type = device.type?.toLowerCase() || '';
|
||||
if (type.includes('server') || type.includes('服务器')) return renderServerFace();
|
||||
if (type.includes('switch') || type.includes('交换机')) return renderSwitchFace();
|
||||
if (type.includes('storage') || type.includes('存储')) return renderStorageFace();
|
||||
if (type.includes('firewall') || type.includes('防火墙') || type.includes('router') || type.includes('路由器')) {
|
||||
return <FirewallFace device={device} height={height} frontZ={frontZ} isSelected={isSelected} />;
|
||||
}
|
||||
|
||||
// 默认通用设备样式
|
||||
return (
|
||||
<group>
|
||||
{/* 默认面板纹理 */}
|
||||
<mesh position={[0, 0, frontZ + 0.005]}>
|
||||
<boxGeometry args={[0.7, height - gap - 0.01, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" roughness={0.6} />
|
||||
</mesh>
|
||||
{/* 装饰线 */}
|
||||
<mesh position={[0, 0, frontZ + 0.006]}>
|
||||
<boxGeometry args={[0.6, 0.005, 0.001]} />
|
||||
<meshStandardMaterial color={deviceColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position || [0, 0, 0]}>
|
||||
<group
|
||||
position={[0, 0, currentZ]}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExtended(!isExtended);
|
||||
onClick && onClick(device);
|
||||
}}
|
||||
onPointerOver={(e) => {
|
||||
e.stopPropagation();
|
||||
setHover(true);
|
||||
onHover && onHover(device);
|
||||
}}
|
||||
onPointerOut={(e) => {
|
||||
setHover(false);
|
||||
onHover && onHover(null);
|
||||
}}
|
||||
>
|
||||
{/* 机身 (Chassis) - 深色金属 */}
|
||||
<mesh ref={mesh} position={[0, 0, chassisZ]}>
|
||||
<boxGeometry args={[chassisWidth, height - gap, chassisDepth]} />
|
||||
<meshStandardMaterial
|
||||
color="#333333" // 深灰色哑光金属
|
||||
roughness={0.9} // 哑光
|
||||
metalness={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* 前面板底座 (Front Panel Base) */}
|
||||
<mesh position={[0, 0, panelZ]}>
|
||||
<boxGeometry args={[panelWidth, height - gap, panelDepth]} />
|
||||
<meshStandardMaterial
|
||||
color={hovered || isSelected ? "#666666" : "#555555"} // 稍浅的灰色塑料质感
|
||||
roughness={0.8}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* 告警时的红色辉光 (设备两侧) */}
|
||||
{(device.status === 'error' || device.status === 'fault') && (
|
||||
<>
|
||||
<pointLight position={[-0.4, 0, frontZ]} color="#ff0000" intensity={0.8} distance={0.3} />
|
||||
<pointLight position={[0.4, 0, frontZ]} color="#ff0000" intensity={0.8} distance={0.3} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 设备特定前面板细节 */}
|
||||
{renderDeviceFace()}
|
||||
|
||||
{/* 设备背板细节 */}
|
||||
{renderBackPanel()}
|
||||
</group>
|
||||
|
||||
{/* 设备名称 (悬浮显示,避免遮挡细节) - 暂时禁用 */}
|
||||
{/* {hovered && (
|
||||
<Text
|
||||
position={[0, height/2 + 0.02, frontZ + 0.05]}
|
||||
fontSize={0.04}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
anchorY="bottom"
|
||||
outlineWidth={0.002}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{device.name}
|
||||
</Text>
|
||||
)} */}
|
||||
|
||||
{/* 状态指示灯 (统一位置) */}
|
||||
<group position={[panelWidth/2 - 0.03, 0, frontZ + 0.01]}>
|
||||
{/* 灯座 */}
|
||||
<mesh position={[0, 0, -0.002]}>
|
||||
<circleGeometry args={[0.01, 16]} />
|
||||
<meshStandardMaterial color="#333" />
|
||||
</mesh>
|
||||
{/* 发光体 */}
|
||||
<mesh>
|
||||
<circleGeometry args={[0.006, 16]} />
|
||||
<meshBasicMaterial color={statusColor} toneMapped={false} />
|
||||
</mesh>
|
||||
{/* 光晕效果 */}
|
||||
<pointLight color={statusColor} intensity={0.5} distance={0.1} />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceModel;
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
|
||||
export const LOD_LEVELS = {
|
||||
HIGH: 0,
|
||||
MEDIUM: 1,
|
||||
LOW: 2
|
||||
};
|
||||
|
||||
export const LOD_DISTANCES = {
|
||||
HIGH: 5,
|
||||
MEDIUM: 10
|
||||
};
|
||||
|
||||
const createSimplifiedDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => {
|
||||
const depth = rackDepth || 1.0;
|
||||
const dHeight = device.height || device.u_height || 1;
|
||||
const height = dHeight * uHeight;
|
||||
const chassisWidth = 0.44;
|
||||
const chassisDepth = 0.8;
|
||||
const panelWidth = 0.4826;
|
||||
const panelDepth = 0.02;
|
||||
const frontZ = depth / 2 - 0.02;
|
||||
const halfHeight = height / 2;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 0, frontZ - panelDepth / 2]}>
|
||||
<boxGeometry args={[panelWidth, height - 0.002, panelDepth]} />
|
||||
<meshStandardMaterial color={deviceColor} roughness={0.8} metalness={0.1} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, frontZ - panelDepth - chassisDepth / 2]}>
|
||||
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
|
||||
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
|
||||
</mesh>
|
||||
<mesh position={[panelWidth/2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
|
||||
<circleGeometry args={[0.006, 16]} />
|
||||
<meshBasicMaterial color={statusColor} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => {
|
||||
const depth = rackDepth || 1.0;
|
||||
const dHeight = device.height || device.u_height || 1;
|
||||
const height = dHeight * uHeight;
|
||||
const chassisWidth = 0.44;
|
||||
const chassisDepth = 0.8;
|
||||
const panelWidth = 0.4826;
|
||||
const panelDepth = 0.02;
|
||||
const frontZ = depth / 2 - 0.02;
|
||||
const is2U = height > 0.08;
|
||||
const halfHeight = height / 2;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||
<meshStandardMaterial color="#1e293b" roughness={0.7} metalness={0.5} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, frontZ - panelDepth / 2]}>
|
||||
<boxGeometry args={[panelWidth, height - 0.002, panelDepth]} />
|
||||
<meshStandardMaterial color={deviceColor} roughness={0.8} metalness={0.1} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, frontZ - panelDepth - chassisDepth / 2]}>
|
||||
<boxGeometry args={[chassisWidth, height - 0.002, chassisDepth]} />
|
||||
<meshStandardMaterial color="#333333" roughness={0.9} metalness={0.3} />
|
||||
</mesh>
|
||||
<group position={[-0.18, 0, frontZ + 0.006]}>
|
||||
<mesh position={[-0.02, 0, 0]}>
|
||||
<boxGeometry args={[0.04, height - 0.01, 0.002]} />
|
||||
<meshStandardMaterial color="#000000" roughness={0.2} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.01, 0.002]}>
|
||||
<boxGeometry args={[0.012, 0.01, 0.002]} />
|
||||
<meshStandardMaterial color="#cbd5e1" />
|
||||
</mesh>
|
||||
</group>
|
||||
<group position={[0.05, 0, frontZ + 0.006]}>
|
||||
{Array.from({ length: is2U ? 4 : 2 }).map((_, i) => (
|
||||
<mesh key={i} position={[(i - 1) * 0.08, 0, 0]}>
|
||||
<boxGeometry args={[0.06, 0.03, 0.004]} />
|
||||
<meshStandardMaterial color="#334155" roughness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
<mesh position={[panelWidth/2 - 0.03, halfHeight - 0.02, frontZ + 0.01]}>
|
||||
<circleGeometry args={[0.006, 16]} />
|
||||
<meshBasicMaterial color={statusColor} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const LODManager = ({
|
||||
device,
|
||||
uHeight,
|
||||
rackDepth,
|
||||
position,
|
||||
deviceColor,
|
||||
statusColor,
|
||||
children,
|
||||
level = LOD_LEVELS.HIGH
|
||||
}) => {
|
||||
const groupRef = useRef();
|
||||
const { camera } = useThree();
|
||||
const [lodLevel, setLodLevel] = React.useState(LOD_LEVELS.HIGH);
|
||||
const distanceRef = useRef(0);
|
||||
|
||||
useFrame(() => {
|
||||
if (groupRef.current) {
|
||||
const distance = camera.position.distanceTo(groupRef.current.position);
|
||||
distanceRef.current = distance;
|
||||
|
||||
let newLevel = LOD_LEVELS.HIGH;
|
||||
if (distance > LOD_DISTANCES.MEDIUM) {
|
||||
newLevel = LOD_LEVELS.LOW;
|
||||
} else if (distance > LOD_DISTANCES.HIGH) {
|
||||
newLevel = LOD_LEVELS.MEDIUM;
|
||||
}
|
||||
|
||||
if (newLevel !== lodLevel) {
|
||||
setLodLevel(newLevel);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (level !== LOD_LEVELS.HIGH) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const renderLODMesh = () => {
|
||||
switch (lodLevel) {
|
||||
case LOD_LEVELS.LOW:
|
||||
return createSimplifiedDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor);
|
||||
case LOD_LEVELS.MEDIUM:
|
||||
return createMediumDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={position}>
|
||||
{lodLevel === LOD_LEVELS.HIGH ? children : renderLODMesh()}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export default LODManager;
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import DeviceModel from './DeviceModel';
|
||||
import LODManager, { LOD_LEVELS } from './LODManager';
|
||||
|
||||
const RackModel = ({
|
||||
rack,
|
||||
devices = [],
|
||||
selectedDeviceId,
|
||||
onDeviceClick,
|
||||
onDeviceLeave,
|
||||
onDeviceHover,
|
||||
onEditDevice,
|
||||
onAddNic,
|
||||
onAddPort,
|
||||
tooltipFields,
|
||||
deviceSlideEnabled = true
|
||||
}) => {
|
||||
const width = 0.6;
|
||||
const depth = 1.0;
|
||||
const uHeight = 0.04445;
|
||||
const postWidth = 0.05;
|
||||
|
||||
const rackHeight = rack?.height || 45;
|
||||
const height = rackHeight * uHeight + 0.2;
|
||||
|
||||
// 颜色映射
|
||||
const colors = {
|
||||
server: '#3b82f6',
|
||||
switch: '#22c55e',
|
||||
router: '#f59e0b',
|
||||
firewall: '#ef4444',
|
||||
storage: '#8b5cf6',
|
||||
default: '#3b82f6',
|
||||
status: {
|
||||
running: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
offline: '#6b7280'
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceColor = (type) => {
|
||||
const t = type?.toLowerCase() || '';
|
||||
if (t.includes('server') || t.includes('服务器')) return colors.server;
|
||||
if (t.includes('switch') || t.includes('交换机')) return colors.switch;
|
||||
if (t.includes('router') || t.includes('路由器')) return colors.router;
|
||||
if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall;
|
||||
if (t.includes('storage') || t.includes('存储')) return colors.storage;
|
||||
return colors.default;
|
||||
};
|
||||
|
||||
// 生成机柜框架
|
||||
const frame = useMemo(() => {
|
||||
const materialProps = { color: "#333", roughness: 0.5, metalness: 0.8 };
|
||||
const postArgs = [postWidth, height, postWidth];
|
||||
const topBottomArgs = [width + 0.02, 0.02, depth + 0.02];
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[-width/2 + postWidth/2, height/2, -depth/2 + postWidth/2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[width/2 - postWidth/2, height/2, -depth/2 + postWidth/2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[-width/2 + postWidth/2, height/2, depth/2 - postWidth/2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[width/2 - postWidth/2, height/2, depth/2 - postWidth/2]}>
|
||||
<boxGeometry args={postArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, height, 0]}>
|
||||
<boxGeometry args={topBottomArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<boxGeometry args={topBottomArgs} />
|
||||
<meshStandardMaterial {...materialProps} />
|
||||
</mesh>
|
||||
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={`side-${side}`} position={[side * (width/2 - 0.005), height/2, 0]}>
|
||||
<boxGeometry args={[0.01, height - 0.04, depth - 0.04]} />
|
||||
<meshStandardMaterial
|
||||
color="#2d3748"
|
||||
roughness={0.4}
|
||||
metalness={0.7}
|
||||
side={2}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<mesh position={[0, height/2, -depth/2 + 0.005]}>
|
||||
<boxGeometry args={[width - 0.04, height - 0.04, 0.01]} />
|
||||
<meshPhysicalMaterial
|
||||
color="#e2e8f0"
|
||||
transparent
|
||||
opacity={0.2}
|
||||
roughness={0}
|
||||
metalness={0.9}
|
||||
transmission={0.8}
|
||||
thickness={0.01}
|
||||
/>
|
||||
<mesh position={[0.2, 0, 0.01]}>
|
||||
<boxGeometry args={[0.02, 0.15, 0.02]} />
|
||||
<meshStandardMaterial color="#333" />
|
||||
</mesh>
|
||||
</mesh>
|
||||
|
||||
</group>
|
||||
);
|
||||
}, [width, height, depth, postWidth]);
|
||||
|
||||
return (
|
||||
<group position={[0, 0.5, 0]}>
|
||||
{frame}
|
||||
|
||||
<group position={[0, 0.1, 0]}>
|
||||
{devices.map((device) => {
|
||||
const uStart = device.position || device.u_position || 1;
|
||||
const uSize = device.height || device.u_height || 1;
|
||||
const yPos = (uStart - 1) * uHeight + (uSize * uHeight) / 2;
|
||||
|
||||
const deviceColor = getDeviceColor(device.type);
|
||||
const statusColor = colors.status[device.status] || colors.status.running;
|
||||
|
||||
return (
|
||||
<LODManager
|
||||
key={device.id}
|
||||
device={device}
|
||||
uHeight={uHeight}
|
||||
rackDepth={depth}
|
||||
position={[0, yPos, 0]}
|
||||
deviceColor={deviceColor}
|
||||
statusColor={statusColor}
|
||||
level={LOD_LEVELS.HIGH}
|
||||
>
|
||||
<DeviceModel
|
||||
device={device}
|
||||
uHeight={uHeight}
|
||||
rackDepth={depth}
|
||||
position={[0, 0, 0]}
|
||||
isSelected={selectedDeviceId === device.id}
|
||||
onClick={onDeviceClick}
|
||||
onPointerOver={onDeviceHover}
|
||||
onPointerOut={onDeviceLeave}
|
||||
onEdit={onEditDevice}
|
||||
onAddNic={onAddNic}
|
||||
onAddPort={onAddPort}
|
||||
tooltipFields={tooltipFields}
|
||||
slideEnabled={deviceSlideEnabled}
|
||||
/>
|
||||
</LODManager>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RackModel;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
|
||||
const envMapUrl = '/assets/3d/env.hdr';
|
||||
import RackModel from './RackModel';
|
||||
|
||||
const Scene = ({ rack, devices, selectedDeviceId, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled = true }) => {
|
||||
return (
|
||||
<Canvas shadows dpr={[1, 1.2]} performance={{ min: 0.5 }}>
|
||||
<PerspectiveCamera makeDefault position={[3, 2, 4]} fov={50} />
|
||||
|
||||
<ambientLight intensity={0.5} color="#ffffff" />
|
||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
||||
<directionalLight
|
||||
position={[10, 10, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-10}
|
||||
shadow-camera-right={10}
|
||||
shadow-camera-top={10}
|
||||
shadow-camera-bottom={-10}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||
</Suspense>
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel
|
||||
rack={rack}
|
||||
devices={devices}
|
||||
selectedDeviceId={selectedDeviceId}
|
||||
onDeviceClick={onDeviceClick}
|
||||
onDeviceLeave={onDeviceLeave}
|
||||
onDeviceHover={onDeviceHover}
|
||||
tooltipFields={tooltipFields}
|
||||
deviceSlideEnabled={deviceSlideEnabled}
|
||||
/>
|
||||
</group>
|
||||
|
||||
{/* Controls */}
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
minPolarAngle={0}
|
||||
maxPolarAngle={Math.PI / 1.75}
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
mouseButtons={{
|
||||
LEFT: 0, // 左键旋转
|
||||
MIDDLE: 2, // 中键平移
|
||||
RIGHT: 0 // 右键禁用
|
||||
}}
|
||||
touches={{
|
||||
ONE: 1, // 单指旋转
|
||||
TWO: 2 // 双指平移
|
||||
}}
|
||||
target={[0, (rack?.height || 45) * 0.04445 / 2 + 0.1, 0]} // Focus on middle of rack
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
export default Scene;
|
||||
@@ -0,0 +1,158 @@
|
||||
export const MATERIAL_TYPES = {
|
||||
RACK_FRAME: 'rack_frame',
|
||||
RAIL: 'rail',
|
||||
RAIL_HOLE: 'rail_hole',
|
||||
SIDE_PANEL: 'side_panel',
|
||||
TOP_PLATE: 'top_plate',
|
||||
BOTTOM_PLATE: 'bottom_plate',
|
||||
DEVICE_CHASSIS: 'device_chassis',
|
||||
DEVICE_PANEL: 'device_panel',
|
||||
DEVICE_PANEL_SELECTED: 'device_panel_selected',
|
||||
DRIVE_TRAY: 'drive_tray',
|
||||
DRIVE_TRAY_HANDLE: 'drive_tray_handle',
|
||||
LED_INDICATOR: 'led_indicator',
|
||||
LED_ERROR: 'led_error',
|
||||
SFP_PORT: 'sfp_port',
|
||||
RJ45_PORT: 'rj45_port',
|
||||
VENT_HOLE: 'vent_hole',
|
||||
BACK_PANEL: 'back_panel',
|
||||
PSU_MODULE: 'psu_module',
|
||||
FAN_MODULE: 'fan_module',
|
||||
TEXT_LABEL: 'text_label',
|
||||
};
|
||||
|
||||
export const MATERIAL_CONFIGS = {
|
||||
[MATERIAL_TYPES.RACK_FRAME]: {
|
||||
color: '#2a2a2a',
|
||||
metalness: 0.85,
|
||||
roughness: 0.4,
|
||||
envMapIntensity: 1.0,
|
||||
},
|
||||
[MATERIAL_TYPES.RAIL]: {
|
||||
color: '#3a3a3a',
|
||||
metalness: 0.9,
|
||||
roughness: 0.25,
|
||||
envMapIntensity: 1.2,
|
||||
},
|
||||
[MATERIAL_TYPES.RAIL_HOLE]: {
|
||||
color: '#000000',
|
||||
metalness: 0.0,
|
||||
roughness: 0.9,
|
||||
},
|
||||
[MATERIAL_TYPES.SIDE_PANEL]: {
|
||||
color: '#1a1a1a',
|
||||
metalness: 0.7,
|
||||
roughness: 0.6,
|
||||
envMapIntensity: 0.8,
|
||||
},
|
||||
[MATERIAL_TYPES.TOP_PLATE]: {
|
||||
color: '#222222',
|
||||
metalness: 0.8,
|
||||
roughness: 0.35,
|
||||
envMapIntensity: 1.0,
|
||||
},
|
||||
[MATERIAL_TYPES.BOTTOM_PLATE]: {
|
||||
color: '#222222',
|
||||
metalness: 0.8,
|
||||
roughness: 0.35,
|
||||
envMapIntensity: 1.0,
|
||||
},
|
||||
[MATERIAL_TYPES.DEVICE_CHASSIS]: {
|
||||
color: '#333333',
|
||||
metalness: 0.4,
|
||||
roughness: 0.85,
|
||||
},
|
||||
[MATERIAL_TYPES.DEVICE_PANEL]: {
|
||||
color: '#555555',
|
||||
metalness: 0.15,
|
||||
roughness: 0.75,
|
||||
},
|
||||
[MATERIAL_TYPES.DEVICE_PANEL_SELECTED]: {
|
||||
color: '#666666',
|
||||
metalness: 0.2,
|
||||
roughness: 0.7,
|
||||
},
|
||||
[MATERIAL_TYPES.DRIVE_TRAY]: {
|
||||
color: '#0f172a',
|
||||
metalness: 0.3,
|
||||
roughness: 0.8,
|
||||
},
|
||||
[MATERIAL_TYPES.DRIVE_TRAY_HANDLE]: {
|
||||
color: '#334155',
|
||||
metalness: 0.5,
|
||||
roughness: 0.5,
|
||||
},
|
||||
[MATERIAL_TYPES.LED_INDICATOR]: {
|
||||
color: '#10b981',
|
||||
emissive: '#10b981',
|
||||
emissiveIntensity: 0.8,
|
||||
metalness: 0.0,
|
||||
roughness: 0.3,
|
||||
toneMapped: false,
|
||||
},
|
||||
[MATERIAL_TYPES.LED_ERROR]: {
|
||||
color: '#ef4444',
|
||||
emissive: '#ef4444',
|
||||
emissiveIntensity: 1.0,
|
||||
metalness: 0.0,
|
||||
roughness: 0.2,
|
||||
toneMapped: false,
|
||||
},
|
||||
[MATERIAL_TYPES.SFP_PORT]: {
|
||||
color: '#3b82f6',
|
||||
metalness: 0.8,
|
||||
roughness: 0.2,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
},
|
||||
[MATERIAL_TYPES.RJ45_PORT]: {
|
||||
color: '#1e293b',
|
||||
metalness: 0.6,
|
||||
roughness: 0.4,
|
||||
},
|
||||
[MATERIAL_TYPES.VENT_HOLE]: {
|
||||
color: '#1a202c',
|
||||
metalness: 0.2,
|
||||
roughness: 0.9,
|
||||
},
|
||||
[MATERIAL_TYPES.BACK_PANEL]: {
|
||||
color: '#64748b',
|
||||
metalness: 0.8,
|
||||
roughness: 0.3,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
},
|
||||
[MATERIAL_TYPES.PSU_MODULE]: {
|
||||
color: '#475569',
|
||||
metalness: 0.85,
|
||||
roughness: 0.35,
|
||||
},
|
||||
[MATERIAL_TYPES.FAN_MODULE]: {
|
||||
color: '#1e293b',
|
||||
metalness: 0.4,
|
||||
roughness: 0.7,
|
||||
},
|
||||
[MATERIAL_TYPES.TEXT_LABEL]: {
|
||||
color: '#ffffff',
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
export const DEVICE_TYPE_COLORS = {
|
||||
server: '#3b82f6',
|
||||
switch: '#22c55e',
|
||||
router: '#f59e0b',
|
||||
firewall: '#ef4444',
|
||||
storage: '#8b5cf6',
|
||||
default: '#3b82f6',
|
||||
};
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
running: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
offline: '#6b7280',
|
||||
maintenance: '#8b5cf6',
|
||||
fault: '#ef4444',
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as THREE from 'three';
|
||||
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
|
||||
|
||||
export const createDeviceChassisMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_CHASSIS];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createDevicePanelMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createDevicePanelSelectedMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL_SELECTED];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createDriveTrayMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createDriveTrayHandleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY_HANDLE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createLedIndicatorMaterial = (color = '#10b981', options = {}) => {
|
||||
return <meshBasicMaterial color={color} toneMapped={false} {...options} />;
|
||||
};
|
||||
|
||||
export const createLedErrorMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.LED_ERROR];
|
||||
return <meshBasicMaterial color={config.color} toneMapped={false} {...options} />;
|
||||
};
|
||||
|
||||
export const createSfpPortMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SFP_PORT];
|
||||
return <meshPhysicalMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} transparent opacity={config.opacity} {...options} />;
|
||||
};
|
||||
|
||||
export const createRj45PortMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RJ45_PORT];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createVentHoleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.VENT_HOLE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createBackPanelMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BACK_PANEL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} transparent opacity={config.opacity} {...options} />;
|
||||
};
|
||||
|
||||
export const createPsuModuleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.PSU_MODULE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createFanModuleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.FAN_MODULE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createConsolePortMaterial = (color = '#facc15') => {
|
||||
return <meshStandardMaterial color={color} metalness={0.6} roughness={0.4} />;
|
||||
};
|
||||
|
||||
export const createUsbPortMaterial = (color = '#94a3b8') => {
|
||||
return <meshStandardMaterial color={color} metalness={0.5} roughness={0.5} />;
|
||||
};
|
||||
|
||||
export const createResetButtonMaterial = (color = '#ef4444') => {
|
||||
return <meshStandardMaterial color={color} metalness={0.3} roughness={0.6} />;
|
||||
};
|
||||
|
||||
export const createPcieCoverMaterial = (color = '#cbd5e1') => {
|
||||
return <meshStandardMaterial color={color} metalness={0.9} roughness={0.3} />;
|
||||
};
|
||||
|
||||
export const createPortLedMaterial = (color = '#4ade80') => {
|
||||
return <meshBasicMaterial color={color} transparent opacity={0.8} />;
|
||||
};
|
||||
|
||||
export const createPortLinkLedMaterial = (color = '#22c55e') => {
|
||||
return <meshBasicMaterial color={color} transparent opacity={1.0} />;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './constants.js';
|
||||
export * from './utils.js';
|
||||
export * from './rackFrame.js';
|
||||
export * from './devicePanel.js';
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as THREE from 'three';
|
||||
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
|
||||
|
||||
export const createRackFrameMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RACK_FRAME];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
};
|
||||
|
||||
export const createRailMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
};
|
||||
|
||||
export const createRailHoleMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL_HOLE];
|
||||
return <meshBasicMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} {...options} />;
|
||||
};
|
||||
|
||||
export const createSidePanelMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SIDE_PANEL];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
};
|
||||
|
||||
export const createTopPlateMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.TOP_PLATE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
};
|
||||
|
||||
export const createBottomPlateMaterial = (options = {}) => {
|
||||
const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BOTTOM_PLATE];
|
||||
return <meshStandardMaterial color={config.color} metalness={config.metalness} roughness={config.roughness} envMapIntensity={config.envMapIntensity} {...options} />;
|
||||
};
|
||||
|
||||
export const createTextLabelMaterial = (color = '#ffffff') => {
|
||||
return <meshBasicMaterial color={color} side={THREE.DoubleSide} />;
|
||||
};
|
||||
|
||||
export const createUMarkMaterial = () => {
|
||||
return <meshBasicMaterial color="#ffffff" side={THREE.DoubleSide} />;
|
||||
};
|
||||
|
||||
export const createOutlineMaterial = (color = '#3b82f6', opacity = 0.8) => {
|
||||
return <lineBasicMaterial color={color} transparent opacity={opacity} />;
|
||||
};
|
||||
|
||||
export const createSelectionMaterial = (color = '#3b82f6', opacity = 0.3) => {
|
||||
return <meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} />;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js';
|
||||
|
||||
export const getMaterialConfig = (type) => {
|
||||
return MATERIAL_CONFIGS[type] || null;
|
||||
};
|
||||
|
||||
export const getMaterialType = (type) => {
|
||||
return MATERIAL_TYPES[type] || null;
|
||||
};
|
||||
|
||||
export const createMaterialConfig = (type, options = {}) => {
|
||||
const baseConfig = MATERIAL_CONFIGS[type];
|
||||
if (!baseConfig) {
|
||||
console.warn(`Material type "${type}" not found in configurations`);
|
||||
return null;
|
||||
}
|
||||
return { ...baseConfig, ...options };
|
||||
};
|
||||
|
||||
export const mergeMaterialOptions = (type, options = {}) => {
|
||||
const baseConfig = MATERIAL_CONFIGS[type];
|
||||
if (!baseConfig) {
|
||||
return options;
|
||||
}
|
||||
return { ...baseConfig, ...options };
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Form, Select, Input, Button, message, Space, Spin } from 'antd';
|
||||
import { SwapOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [sourcePorts, setSourcePorts] = useState([]);
|
||||
const [targetPorts, setTargetPorts] = useState([]);
|
||||
const [fetchingDevices, setFetchingDevices] = useState(false);
|
||||
const prevVisibleRef = useRef(false);
|
||||
|
||||
// Initialize when visible changes from false to true
|
||||
useEffect(() => {
|
||||
if (visible && !prevVisibleRef.current) {
|
||||
form.resetFields();
|
||||
if (sourceDevice) {
|
||||
form.setFieldsValue({
|
||||
sourceDeviceId: sourceDevice.deviceId || sourceDevice.id,
|
||||
});
|
||||
fetchDevicePorts(sourceDevice.deviceId || sourceDevice.id, 'source');
|
||||
}
|
||||
fetchDevices();
|
||||
}
|
||||
prevVisibleRef.current = visible;
|
||||
}, [visible, sourceDevice, form]);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
setFetchingDevices(true);
|
||||
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
|
||||
setDevices(response.data.devices || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch devices:', error);
|
||||
message.error('获取设备列表失败');
|
||||
} finally {
|
||||
setFetchingDevices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDevicePorts = async (deviceId, type) => {
|
||||
if (!deviceId) {
|
||||
if (type === 'source') setSourcePorts([]);
|
||||
else setTargetPorts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/device-ports/device/${deviceId}`);
|
||||
const ports = response.data || [];
|
||||
if (type === 'source') {
|
||||
setSourcePorts(ports);
|
||||
} else {
|
||||
setTargetPorts(ports);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${type} ports:`, error);
|
||||
message.error('获取端口列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceDeviceChange = (deviceId) => {
|
||||
form.setFieldsValue({ sourcePort: undefined });
|
||||
fetchDevicePorts(deviceId, 'source');
|
||||
};
|
||||
|
||||
const handleTargetDeviceChange = (deviceId) => {
|
||||
form.setFieldsValue({ targetPort: undefined });
|
||||
fetchDevicePorts(deviceId, 'target');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
// Logic to ensure Switch is always Source if possible (for Cable Management view consistency)
|
||||
const sourceDev = devices.find(d => d.deviceId === values.sourceDeviceId);
|
||||
const targetDev = devices.find(d => d.deviceId === values.targetDeviceId);
|
||||
|
||||
let payload = { ...values };
|
||||
|
||||
// If Source is NOT Switch AND Target IS Switch, swap them
|
||||
if (sourceDev && targetDev &&
|
||||
sourceDev.type !== 'switch' &&
|
||||
targetDev.type === 'switch') {
|
||||
|
||||
payload = {
|
||||
...values,
|
||||
sourceDeviceId: values.targetDeviceId,
|
||||
sourcePort: values.targetPort,
|
||||
targetDeviceId: values.sourceDeviceId,
|
||||
targetPort: values.sourcePort
|
||||
};
|
||||
console.log('Swapped source/target to ensure Switch is Source');
|
||||
}
|
||||
|
||||
await axios.post('/api/cables', payload);
|
||||
|
||||
message.success('接线创建成功');
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error.errorFields) return;
|
||||
console.error('Failed to create cable:', error);
|
||||
message.error('接线创建失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<SwapOutlined style={{ color: '#1890ff' }} />
|
||||
<span>新增接线</span>
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
{/* Source Side */}
|
||||
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>源设备 (起点)</div>
|
||||
<Form.Item
|
||||
name="sourceDeviceId"
|
||||
label="设备"
|
||||
rules={[{ required: true, message: '请选择源设备' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择源设备"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={handleSourceDeviceChange}
|
||||
loading={fetchingDevices}
|
||||
disabled={!!sourceDevice} // Lock source device if provided
|
||||
>
|
||||
{devices.map(d => (
|
||||
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sourcePort"
|
||||
label="端口"
|
||||
rules={[{ required: true, message: '请选择源端口' }]}
|
||||
>
|
||||
<Select placeholder="选择源端口" showSearch>
|
||||
{sourcePorts.map(p => (
|
||||
<Option key={p.portId} value={p.portName}>
|
||||
{p.portName} ({p.portType})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Target Side */}
|
||||
<div style={{ padding: '12px', background: '#f9f9f9', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 500, color: '#666' }}>目标设备 (终点)</div>
|
||||
<Form.Item
|
||||
name="targetDeviceId"
|
||||
label="设备"
|
||||
rules={[{ required: true, message: '请选择目标设备' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择目标设备"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.children ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={handleTargetDeviceChange}
|
||||
loading={fetchingDevices}
|
||||
>
|
||||
{devices.filter(d => d.deviceId !== form.getFieldValue('sourceDeviceId')).map(d => (
|
||||
<Option key={d.deviceId} value={d.deviceId}>{d.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="targetPort"
|
||||
label="端口"
|
||||
rules={[{ required: true, message: '请选择目标端口' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择目标端口"
|
||||
showSearch
|
||||
disabled={!form.getFieldValue('targetDeviceId')}
|
||||
>
|
||||
{targetPorts.map(p => (
|
||||
<Option key={p.portId} value={p.portName}>
|
||||
{p.portName} ({p.portType})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="cableType"
|
||||
label="线缆类型"
|
||||
initialValue="ethernet"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="ethernet">网线</Option>
|
||||
<Option value="fiber">光纤</Option>
|
||||
<Option value="copper">铜缆</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="normal"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="normal">正常</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
<Option value="disconnected">未连接</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cableLength"
|
||||
label="长度 (米)"
|
||||
>
|
||||
<Input type="number" min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CableCreateModal;
|
||||
@@ -1,371 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CloudServerOutlined, SwitcherOutlined, DatabaseOutlined,
|
||||
CloudOutlined, LaptopOutlined, MobileOutlined,
|
||||
PrinterOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
// 设备图标映射
|
||||
const getDeviceIcon = (deviceType) => {
|
||||
try {
|
||||
if (!deviceType) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||
const type = deviceType.toLowerCase();
|
||||
|
||||
if (type.includes('server') || type.includes('服务器')) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||
if (type.includes('switch') || type.includes('交换机')) return <SwitcherOutlined style={{ color: '#ffffff' }} />;
|
||||
if (type.includes('storage') || type.includes('存储')) return <DatabaseOutlined style={{ color: '#ffffff' }} />;
|
||||
if (type.includes('router') || type.includes('路由器')) return <CloudOutlined style={{ color: '#ffffff' }} />;
|
||||
if (type.includes('laptop') || type.includes('笔记本')) return <LaptopOutlined style={{ color: '#ffffff' }} />;
|
||||
if (type.includes('mobile') || type.includes('手机')) return <MobileOutlined style={{ color: '#ffffff' }} />;
|
||||
if (type.includes('printer') || type.includes('打印机')) return <PrinterOutlined style={{ color: '#ffffff' }} />;
|
||||
|
||||
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||
} catch (error) {
|
||||
console.error('设备图标渲染错误:', error);
|
||||
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 设备状态颜色映射
|
||||
const getDeviceStatusColor = (status) => {
|
||||
const statusColorMap = {
|
||||
'normal': '#10b981',
|
||||
'running': '#10b981',
|
||||
'warning': '#f59e0b',
|
||||
'error': '#ef4444',
|
||||
'fault': '#ef4444',
|
||||
'offline': '#6b7280',
|
||||
'maintenance': '#3b82f6',
|
||||
undefined: '#3b82f6',
|
||||
null: '#3b82f6'
|
||||
};
|
||||
return statusColorMap[status] || '#3b82f6';
|
||||
};
|
||||
|
||||
// 设备状态主题
|
||||
const getStatusTheme = (status) => {
|
||||
const themeMap = {
|
||||
'normal': {
|
||||
bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)',
|
||||
borderColor: '#10b981',
|
||||
topBorderColor: '#34d399',
|
||||
glowColor: 'rgba(16, 185, 129, 0.4)',
|
||||
shadowColor: 'rgba(16, 185, 129, 0.3)',
|
||||
iconColor: '#10b981',
|
||||
label: '正常'
|
||||
},
|
||||
'running': {
|
||||
bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)',
|
||||
borderColor: '#10b981',
|
||||
topBorderColor: '#34d399',
|
||||
glowColor: 'rgba(16, 185, 129, 0.4)',
|
||||
shadowColor: 'rgba(16, 185, 129, 0.3)',
|
||||
iconColor: '#10b981',
|
||||
label: '运行中'
|
||||
},
|
||||
'warning': {
|
||||
bgGradient: 'linear-gradient(180deg, #d97706 0%, #b45309 50%, #92400e 100%)',
|
||||
borderColor: '#f59e0b',
|
||||
topBorderColor: '#fbbf24',
|
||||
glowColor: 'rgba(245, 158, 11, 0.4)',
|
||||
shadowColor: 'rgba(245, 158, 11, 0.3)',
|
||||
iconColor: '#f59e0b',
|
||||
label: '警告'
|
||||
},
|
||||
'error': {
|
||||
bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)',
|
||||
borderColor: '#ef4444',
|
||||
topBorderColor: '#f87171',
|
||||
glowColor: 'rgba(239, 68, 68, 0.5)',
|
||||
shadowColor: 'rgba(239, 68, 68, 0.4)',
|
||||
iconColor: '#ef4444',
|
||||
label: '故障'
|
||||
},
|
||||
'fault': {
|
||||
bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)',
|
||||
borderColor: '#ef4444',
|
||||
topBorderColor: '#f87171',
|
||||
glowColor: 'rgba(239, 68, 68, 0.5)',
|
||||
shadowColor: 'rgba(239, 68, 68, 0.4)',
|
||||
iconColor: '#ef4444',
|
||||
label: '故障'
|
||||
},
|
||||
'offline': {
|
||||
bgGradient: 'linear-gradient(180deg, #4b5563 0%, #374151 50%, #1f2937 100%)',
|
||||
borderColor: '#6b7280',
|
||||
topBorderColor: '#9ca3af',
|
||||
glowColor: 'rgba(107, 114, 128, 0.2)',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)',
|
||||
iconColor: '#9ca3af',
|
||||
label: '离线'
|
||||
},
|
||||
'maintenance': {
|
||||
bgGradient: 'linear-gradient(180deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%)',
|
||||
borderColor: '#3b82f6',
|
||||
topBorderColor: '#60a5fa',
|
||||
glowColor: 'rgba(59, 130, 246, 0.4)',
|
||||
shadowColor: 'rgba(59, 130, 246, 0.3)',
|
||||
iconColor: '#3b82f6',
|
||||
label: '维护中'
|
||||
},
|
||||
'default': {
|
||||
bgGradient: 'linear-gradient(180deg, #3d4451 0%, #2d3139 50%, #252930 100%)',
|
||||
borderColor: '#4a5568',
|
||||
topBorderColor: '#565c6b',
|
||||
glowColor: 'rgba(56, 189, 248, 0.2)',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
iconColor: '#38bdf8',
|
||||
label: '未知'
|
||||
}
|
||||
};
|
||||
|
||||
return themeMap[status] || themeMap['default'];
|
||||
};
|
||||
|
||||
// 设备样式计算
|
||||
const getDeviceStyle = (device, rackHeight) => {
|
||||
// 添加参数验证
|
||||
if (!device || typeof device.position !== 'number' || typeof device.height !== 'number') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const uHeight = 25; // 调整为更小的U高度以适应屏幕显示,1U=25px
|
||||
const deviceHeight = Math.max(1, device.height) * uHeight;
|
||||
|
||||
// 设备位置从底部开始计算(U1在底部)
|
||||
let position = Math.max(1, device.position);
|
||||
let deviceUHeight = Math.max(1, device.height);
|
||||
|
||||
// 确保设备不会超出机柜范围
|
||||
if (position + deviceUHeight - 1 > rackHeight) {
|
||||
// 如果设备会超出机柜,调整位置或高度
|
||||
position = Math.max(1, rackHeight - deviceUHeight + 1);
|
||||
}
|
||||
|
||||
// 计算设备的顶部位置(从机柜顶部算起)
|
||||
// 设备占用从 position 到 position + height - 1 的U
|
||||
// 机柜顶部是U0,所以设备顶部的topPosition是:
|
||||
const deviceBottomU = position; // 设备底部U数
|
||||
const deviceTopU = position + deviceUHeight - 1; // 设备顶部U数
|
||||
const topPosition = (rackHeight - deviceTopU) * uHeight;
|
||||
|
||||
return {
|
||||
height: `${deviceHeight}px`, // 确保设备高度精确等于U位高度
|
||||
top: `${topPosition}px`, // 确保设备顶部与U位网格线对齐
|
||||
// 移除任何可能影响占满U位的样式
|
||||
margin: 0,
|
||||
padding: 0
|
||||
};
|
||||
};
|
||||
|
||||
// 设备组件
|
||||
const DeviceComponent = ({ device, rackHeight, isHighlighted, onMouseEnter, onMouseLeave }) => {
|
||||
try {
|
||||
// 处理统一化后的设备数据
|
||||
const deviceId = device?.deviceId || device?.id || device?.device_id || device?.device || `device-${Math.random()}`;
|
||||
const deviceName = device?.name || device?.deviceName || device?.device_name || device?.title || '未知设备';
|
||||
const position = device?.position || 1;
|
||||
const height = device?.height || 1;
|
||||
|
||||
// 获取状态主题
|
||||
const statusTheme = getStatusTheme(device?.status);
|
||||
const statusColor = getDeviceStatusColor(device?.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deviceId}
|
||||
className={`device ${isHighlighted ? 'highlighted' : ''} ${device?.status === 'warning' ? 'status-warning' : ''} ${(device?.status === 'error' || device?.status === 'fault') ? 'status-error' : ''}`}
|
||||
style={{
|
||||
...getDeviceStyle(device, rackHeight),
|
||||
background: statusTheme.bgGradient,
|
||||
border: isHighlighted
|
||||
? `2px solid ${statusTheme.topBorderColor}`
|
||||
: `1px solid ${statusTheme.borderColor}`,
|
||||
borderTop: isHighlighted
|
||||
? `2px solid ${statusTheme.topBorderColor}`
|
||||
: `1px solid ${statusTheme.topBorderColor}`
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const isOneU = (device?.height || 1) === 1;
|
||||
const isFaultStatus = device?.status === 'error' || device?.status === 'fault';
|
||||
if (isOneU && !isFaultStatus) {
|
||||
e.currentTarget.style.height = '33px';
|
||||
e.currentTarget.style.zIndex = '150';
|
||||
}
|
||||
e.currentTarget.style.transform = 'scale(1.008)';
|
||||
e.currentTarget.style.boxShadow = `
|
||||
0 4px 12px ${statusTheme.shadowColor},
|
||||
0 2px 6px rgba(0,0,0,0.3),
|
||||
inset 0 1px 0 rgba(255,255,255,0.15)
|
||||
`;
|
||||
e.currentTarget.style.borderColor = statusTheme.topBorderColor;
|
||||
|
||||
if (onMouseEnter) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onMouseEnter({ device, position: { x: rect.right + 5, y: rect.top + rect.height / 2 } });
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const isOneU = (device?.height || 1) === 1;
|
||||
const isFaultStatus = device?.status === 'error' || device?.status === 'fault';
|
||||
if (isOneU && !isFaultStatus) {
|
||||
const originalHeight = (device?.height || 1) * 25;
|
||||
e.currentTarget.style.height = `${originalHeight}px`;
|
||||
e.currentTarget.style.zIndex = '100';
|
||||
}
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = `
|
||||
0 1px 2px rgba(0,0,0,0.3),
|
||||
0 2px 4px rgba(0,0,0,0.2),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1),
|
||||
inset 0 -1px 0 rgba(0,0,0,0.1)
|
||||
`;
|
||||
e.currentTarget.style.borderColor = statusTheme.borderColor;
|
||||
if (onMouseLeave) {
|
||||
onMouseLeave();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="device-status-top-bar" style={{
|
||||
background: `linear-gradient(90deg, ${statusTheme.topBorderColor} 0%, ${statusTheme.borderColor} 50%, ${statusTheme.topBorderColor} 100%)`
|
||||
}} />
|
||||
|
||||
{/* 左侧状态指示区域 - 增强版 */}
|
||||
<div className="device-status-indicator" style={{
|
||||
background: isHighlighted
|
||||
? `linear-gradient(180deg, ${statusTheme.borderColor}33 0%, ${statusTheme.borderColor}22 100%)`
|
||||
: `linear-gradient(180deg, ${statusTheme.borderColor}44 0%, ${statusTheme.borderColor}22 100%)`,
|
||||
borderRight: `1px solid ${statusTheme.borderColor}66`
|
||||
}}>
|
||||
{/* 设备类型标识 */}
|
||||
<div className="device-type-badge" style={{
|
||||
background: `linear-gradient(180deg, ${statusTheme.borderColor}66 0%, ${statusTheme.borderColor}33 100%)`,
|
||||
border: `1px solid ${statusTheme.borderColor}44`
|
||||
}}>
|
||||
<span className="device-type-text" style={{ color: statusTheme.topBorderColor }}>
|
||||
{device.type?.toUpperCase() || 'DEV'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* LED状态指示灯组 */}
|
||||
<div className="device-leds">
|
||||
{/* 主状态灯 */}
|
||||
<div className="main-status-leds">
|
||||
<div className={`led ${device.status === 'warning' ? 'status-warning' : ''} ${device.status === 'error' ? 'status-error' : ''} ${device.status === 'normal' || device.status === 'running' ? 'status-normal' : 'offline'}`} style={{
|
||||
backgroundColor: getDeviceStatusColor(device.status),
|
||||
boxShadow: `0 0 8px ${getDeviceStatusColor(device.status)}`
|
||||
}} />
|
||||
<div className={`led ${device.status === 'running' ? 'status-running' : 'offline'}`} />
|
||||
<div className="led status-running" />
|
||||
</div>
|
||||
|
||||
{/* 电源指示灯 */}
|
||||
<div className="power-led">
|
||||
<div className={`power-led-indicator ${device.status !== 'offline' ? 'on' : ''}`} />
|
||||
<span className={`power-led-text ${device.status !== 'offline' ? 'on' : ''}`}>
|
||||
PWR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备序列号标签 */}
|
||||
<div className="device-serial">
|
||||
<span className="device-serial-text">
|
||||
SN:{device.serial?.slice(-4) || '0000'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间设备信息区域 - 增强版 */}
|
||||
<div className="device-info">
|
||||
{/* 设备品牌/厂商标识 */}
|
||||
<div className="device-brand">
|
||||
<div className="device-brand-icon">
|
||||
{getDeviceIcon(device.type)}
|
||||
</div>
|
||||
<span className="device-brand-text">
|
||||
{device.brand || 'ENTERPRISE'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 设备名称 */}
|
||||
<div className="device-name">
|
||||
{deviceName}
|
||||
</div>
|
||||
|
||||
{/* 型号和规格 */}
|
||||
<div className="device-model">
|
||||
<span className="device-model-text">
|
||||
{device.model || device.type?.toUpperCase() || 'STD'}
|
||||
</span>
|
||||
{device.ip && (
|
||||
<span className="device-ip">
|
||||
{device.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 散热/通风口装饰 - 根据设备类型显示 */}
|
||||
{(device.type === 'server' || device.type === 'storage') && (
|
||||
<div className="device-ventilation">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="ventilation-fin" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧端口/功能区域 - 增强版 */}
|
||||
<div className="device-ports">
|
||||
{/* 端口指示灯阵列 */}
|
||||
<div className="port-leds">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={`port-led ${i < 3 ? 'active' : 'inactive'}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 管理接口标识 */}
|
||||
<div className="management-interface">
|
||||
<div className="management-icon" />
|
||||
<span className="management-text">
|
||||
MGMT
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 设备高度U数标识 */}
|
||||
<div className="device-height">
|
||||
{device.height}U
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('设备渲染错误:', error, device);
|
||||
// 渲染一个简单的错误显示元素
|
||||
return (
|
||||
<div
|
||||
key={`error-${Math.random()}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '35px',
|
||||
right: '35px',
|
||||
top: '50%',
|
||||
height: '30px',
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: '#ff4d4f',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
设备加载错误
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 默认导出
|
||||
export default DeviceComponent;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip } from 'antd';
|
||||
import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip, Button, Popconfirm } from 'antd';
|
||||
import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined, EditOutlined, PlusCircleOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import NetworkCardPanel from './NetworkCardPanel';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
@@ -18,7 +18,7 @@ const designTokens = {
|
||||
}
|
||||
};
|
||||
|
||||
function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables }) {
|
||||
function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables, onEdit, onAddNic, onAddPort, onAddCable, onDeleteCable, tooltipFields, refreshTrigger }) {
|
||||
const [activeTab, setActiveTab] = useState('ports');
|
||||
|
||||
const deviceCables = useMemo(() => {
|
||||
@@ -55,6 +55,34 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
|
||||
return typeMap[type?.toLowerCase()] || type || '未知设备';
|
||||
}, []);
|
||||
|
||||
const renderFieldValue = useCallback((field, device) => {
|
||||
const fieldKey = field.field;
|
||||
if (fieldKey === 'status') return getStatusTag(device.status);
|
||||
|
||||
let value = device[fieldKey];
|
||||
|
||||
if (fieldKey === 'type') value = getDeviceTypeName(value);
|
||||
else if (fieldKey === 'position') value = `U${device.position} ${device.height ? `(${device.height}U)` : ''}`;
|
||||
|
||||
return <Text strong style={{ fontSize: '14px' }}>{value || '-'}</Text>;
|
||||
}, [getStatusTag, getDeviceTypeName]);
|
||||
|
||||
const displayFields = useMemo(() => {
|
||||
if (tooltipFields && Object.keys(tooltipFields).length > 0) {
|
||||
return Object.values(tooltipFields).filter(f => f.enabled);
|
||||
}
|
||||
|
||||
// Default fallback fields if no config
|
||||
return [
|
||||
{ field: 'deviceId', label: '设备ID' },
|
||||
{ field: 'type', label: '设备类型' },
|
||||
{ field: 'status', label: '设备状态' },
|
||||
{ field: 'position', label: '位置' },
|
||||
{ field: 'ipAddress', label: 'IP地址' },
|
||||
{ field: 'brand', label: '品牌' }
|
||||
];
|
||||
}, [tooltipFields]);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'ports',
|
||||
@@ -69,6 +97,7 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
|
||||
deviceId={device?.deviceId}
|
||||
deviceName={device?.name}
|
||||
onRefresh={onRefreshCables}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -91,6 +120,16 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
|
||||
key={cable.cableId}
|
||||
size="small"
|
||||
style={{ borderRadius: '8px' }}
|
||||
extra={
|
||||
<Popconfirm
|
||||
title="确定要删除这条接线吗?"
|
||||
onConfirm={() => onDeleteCable?.(cable.cableId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: designTokens.spacing.sm }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
@@ -150,9 +189,25 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
width={520}
|
||||
width={600}
|
||||
open={visible}
|
||||
onClose={onClose}
|
||||
extra={
|
||||
<Space>
|
||||
<Tooltip title="编辑设备信息">
|
||||
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="添加网卡">
|
||||
<Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>加网卡</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="添加端口">
|
||||
<Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>加端口</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="添加接线">
|
||||
<Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>加接线</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
styles={{ body: { padding: '16px 20px', overflow: 'auto' } }}
|
||||
>
|
||||
<div className="device-info-section" style={{ marginBottom: '20px' }}>
|
||||
@@ -165,36 +220,12 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables
|
||||
padding: '16px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备ID</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{device.deviceId}</Text>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备类型</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{getDeviceTypeName(device.type)}</Text>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备状态</Text>
|
||||
{getStatusTag(device.status)}
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>位置</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
U{device.position} {device.height && `(${device.height}U)`}
|
||||
</Text>
|
||||
</div>
|
||||
{device.ipAddress && (
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>IP地址</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{device.ipAddress}</Text>
|
||||
{displayFields.map(field => (
|
||||
<div className="info-item" key={field.field}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>{field.label}</Text>
|
||||
{renderFieldValue(field, device)}
|
||||
</div>
|
||||
)}
|
||||
{device.brand && (
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>品牌</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{device.brand}</Text>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const designTokens = {
|
||||
}
|
||||
};
|
||||
|
||||
function NetworkCardPanel({ deviceId, deviceName, onRefresh }) {
|
||||
function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
const [cards, setCards] = useState([]);
|
||||
const [networkCards, setNetworkCards] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -56,7 +56,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh }) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
}, [fetchData, refreshTrigger]);
|
||||
|
||||
const handleDeleteCard = useCallback(async (card) => {
|
||||
try {
|
||||
|
||||
@@ -84,9 +84,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
const [previewPorts, setPreviewPorts] = useState([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [nicList, setNicList] = useState([]);
|
||||
const prevVisibleRef = React.useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 只有当 visible 从 false 变为 true 时才执行初始化
|
||||
if (visible && !prevVisibleRef.current) {
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
form.resetFields();
|
||||
@@ -95,12 +97,13 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
form.setFieldsValue({ nicId: defaultNicId });
|
||||
}
|
||||
|
||||
if (device?.deviceId && networkCards.length === 0) {
|
||||
if (device?.deviceId && (!networkCards || networkCards.length === 0)) {
|
||||
fetchNetworkCards();
|
||||
} else if (networkCards.length > 0) {
|
||||
} else if (networkCards && networkCards.length > 0) {
|
||||
setNicList(networkCards);
|
||||
}
|
||||
}
|
||||
prevVisibleRef.current = visible;
|
||||
}, [visible, device, defaultNicId, networkCards, form]);
|
||||
|
||||
const fetchNetworkCards = async () => {
|
||||
@@ -205,6 +208,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
okText={portCount > 1 ? `创建 ${portCount} 个端口` : '创建'}
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
zIndex={1050}
|
||||
styles={{ body: { padding: '20px 24px' } }}
|
||||
>
|
||||
<Form
|
||||
@@ -259,7 +263,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
label={
|
||||
<Space>
|
||||
端口名称
|
||||
<Tooltip title="支持单个端口(如 eth0/1)或端口范围(如 1/0/1-1/0/48)">
|
||||
<Tooltip title="支持单个端口(如 eth0/1)或端口范围(如 1/0/1-1/0/48)" mouseEnterDelay={0.5}>
|
||||
<InfoCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
@@ -284,7 +288,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
||||
>
|
||||
<Input
|
||||
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
|
||||
onChange={handlePortNameChange}
|
||||
onChange={(e) => {
|
||||
// 确保 Form 值更新
|
||||
form.setFieldValue('portName', e.target.value);
|
||||
handlePortNameChange(e);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user