feat(3d): 优化3D场景性能并添加纹理缓存
refactor(DeviceModel): 使用相机Z坐标阈值减少背板渲染状态更新 refactor(LODManager): 基于距离变化率优化LOD切换逻辑 feat(TextureCache): 新增纹理缓存类用于U位标签纹理管理 refactor(RackModel): 使用纹理缓存重构U位标签生成逻辑 feat(backend): 添加端口兼容性检查功能 feat(cables): 实现端口类型和速率兼容性验证API feat(frontend): 新增设备筛选组件FilterableDeviceSelect refactor(PortPanel): 优化端口占用状态显示和交互 feat(CableWizardModal): 集成端口兼容性检查并优化UI
This commit is contained in:
+186
-1
@@ -5,6 +5,159 @@ const Cable = require('../models/Cable');
|
|||||||
const Device = require('../models/Device');
|
const Device = require('../models/Device');
|
||||||
const DevicePort = require('../models/DevicePort');
|
const DevicePort = require('../models/DevicePort');
|
||||||
|
|
||||||
|
const PORT_COMPATIBILITY = {
|
||||||
|
RJ45: {
|
||||||
|
compatibleWith: ['RJ45'],
|
||||||
|
cableTypes: ['ethernet', 'copper'],
|
||||||
|
description: '电口',
|
||||||
|
},
|
||||||
|
SFP: {
|
||||||
|
compatibleWith: ['SFP', 'SFP+', 'SFP28', 'QSFP', 'QSFP28'],
|
||||||
|
cableTypes: ['fiber'],
|
||||||
|
description: '光口',
|
||||||
|
},
|
||||||
|
'SFP+': {
|
||||||
|
compatibleWith: ['SFP', 'SFP+', 'SFP28', 'QSFP28'],
|
||||||
|
cableTypes: ['fiber'],
|
||||||
|
description: '万兆光口',
|
||||||
|
},
|
||||||
|
SFP28: {
|
||||||
|
compatibleWith: ['SFP28', 'SFP+', 'QSFP28'],
|
||||||
|
cableTypes: ['fiber'],
|
||||||
|
description: '25G光口',
|
||||||
|
},
|
||||||
|
QSFP: {
|
||||||
|
compatibleWith: ['QSFP', 'QSFP28'],
|
||||||
|
cableTypes: ['fiber'],
|
||||||
|
description: '40G光口',
|
||||||
|
},
|
||||||
|
QSFP28: {
|
||||||
|
compatibleWith: ['QSFP28', 'QSFP'],
|
||||||
|
cableTypes: ['fiber'],
|
||||||
|
description: '100G光口',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPEED_COMPATIBILITY = {
|
||||||
|
'100M': { compatibleSpeeds: ['100M', '1G'], warningThreshold: null },
|
||||||
|
'1G': { compatibleSpeeds: ['100M', '1G', '10G'], warningThreshold: '10G' },
|
||||||
|
'10G': { compatibleSpeeds: ['1G', '10G', '25G'], warningThreshold: '25G' },
|
||||||
|
'25G': { compatibleSpeeds: ['10G', '25G', '40G'], warningThreshold: '40G' },
|
||||||
|
'40G': { compatibleSpeeds: ['25G', '40G', '100G'], warningThreshold: '100G' },
|
||||||
|
'100G': { compatibleSpeeds: ['40G', '100G'], warningThreshold: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkPortCompatibility(sourcePort, targetPort) {
|
||||||
|
const incompatibilityReasons = [];
|
||||||
|
|
||||||
|
const sourcePortType = sourcePort.portType;
|
||||||
|
const targetPortType = targetPort.portType;
|
||||||
|
|
||||||
|
const sourceCompat = PORT_COMPATIBILITY[sourcePortType];
|
||||||
|
const targetCompat = PORT_COMPATIBILITY[targetPortType];
|
||||||
|
|
||||||
|
if (!sourceCompat || !targetCompat) {
|
||||||
|
incompatibilityReasons.push({
|
||||||
|
type: 'unknown',
|
||||||
|
message: `未知端口类型: 源端口=${sourcePortType}, 目标端口=${targetPortType}`,
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
return { compatible: false, reasons: incompatibilityReasons };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCanConnect = sourceCompat.compatibleWith.includes(targetPortType);
|
||||||
|
const targetCanConnect = targetCompat.compatibleWith.includes(sourcePortType);
|
||||||
|
|
||||||
|
if (!sourceCanConnect || !targetCanConnect) {
|
||||||
|
incompatibilityReasons.push({
|
||||||
|
type: 'portType',
|
||||||
|
message: `端口类型不兼容: 源端口(${sourcePortType}-${sourceCompat.description})无法连接到目标端口(${targetPortType}-${targetCompat.description})`,
|
||||||
|
severity: 'error',
|
||||||
|
details: {
|
||||||
|
sourcePortType,
|
||||||
|
targetPortType,
|
||||||
|
sourceDescription: sourceCompat.description,
|
||||||
|
targetDescription: targetCompat.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSpeedCompat = SPEED_COMPATIBILITY[sourcePort.portSpeed];
|
||||||
|
const targetSpeedCompat = SPEED_COMPATIBILITY[targetPort.portSpeed];
|
||||||
|
|
||||||
|
if (sourceSpeedCompat && targetSpeedCompat) {
|
||||||
|
const sourceCanSupportTarget = sourceSpeedCompat.compatibleSpeeds.includes(targetPort.portSpeed);
|
||||||
|
const targetCanSupportSource = targetSpeedCompat.compatibleSpeeds.includes(sourcePort.portSpeed);
|
||||||
|
|
||||||
|
if (!sourceCanSupportTarget || !targetCanSupportSource) {
|
||||||
|
if (sourcePort.portSpeed !== targetPort.portSpeed) {
|
||||||
|
incompatibilityReasons.push({
|
||||||
|
type: 'speed',
|
||||||
|
message: `端口速率不匹配: 源端口(${sourcePort.portSpeed})与目标端口(${targetPort.portSpeed})速率不一致,可能影响连接质量`,
|
||||||
|
severity: 'warning',
|
||||||
|
details: {
|
||||||
|
sourceSpeed: sourcePort.portSpeed,
|
||||||
|
targetSpeed: targetPort.portSpeed,
|
||||||
|
sourceWarning: sourceSpeedCompat.warningThreshold,
|
||||||
|
targetWarning: targetSpeedCompat.warningThreshold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
compatible: incompatibilityReasons.filter(r => r.severity === 'error').length === 0,
|
||||||
|
reasons: incompatibilityReasons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validatePortCompatibility(sourceDeviceId, sourcePortName, targetDeviceId, targetPortName) {
|
||||||
|
try {
|
||||||
|
const [sourcePorts, targetPorts] = await Promise.all([
|
||||||
|
DevicePort.findAll({ where: { deviceId: sourceDeviceId, portName: sourcePortName } }),
|
||||||
|
DevicePort.findAll({ where: { deviceId: targetDeviceId, portName: targetPortName } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sourcePort = sourcePorts[0];
|
||||||
|
const targetPort = targetPorts[0];
|
||||||
|
|
||||||
|
if (!sourcePort) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reasons: [{
|
||||||
|
type: 'notFound',
|
||||||
|
message: `源设备 ${sourceDeviceId} 的端口 ${sourcePortName} 不存在`,
|
||||||
|
severity: 'error',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetPort) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reasons: [{
|
||||||
|
type: 'notFound',
|
||||||
|
message: `目标设备 ${targetDeviceId} 的端口 ${targetPortName} 不存在`,
|
||||||
|
severity: 'error',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkPortCompatibility(sourcePort, targetPort);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证端口兼容性时出错:', error);
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
reasons: [{
|
||||||
|
type: 'error',
|
||||||
|
message: `验证端口兼容性时出错: ${error.message}`,
|
||||||
|
severity: 'error',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 辅助函数:更新端口状态
|
// 辅助函数:更新端口状态
|
||||||
async function updatePortStatus(deviceId, portName, status) {
|
async function updatePortStatus(deviceId, portName, status) {
|
||||||
try {
|
try {
|
||||||
@@ -245,6 +398,27 @@ router.post('/check-conflict', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 检查端口兼容性
|
||||||
|
router.post('/check-compatibility', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceDeviceId, sourcePort, targetDeviceId, targetPort } = req.body;
|
||||||
|
|
||||||
|
if (!sourceDeviceId || !sourcePort || !targetDeviceId || !targetPort) {
|
||||||
|
return res.status(400).json({ error: '缺少必填字段' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const compatibilityResult = await validatePortCompatibility(sourceDeviceId, sourcePort, targetDeviceId, targetPort);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
compatible: compatibilityResult.compatible,
|
||||||
|
...compatibilityResult,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查端口兼容性失败:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -273,6 +447,18 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(400).json({ error: '源设备和目标设备不能相同' });
|
return res.status(400).json({ error: '源设备和目标设备不能相同' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 端口兼容性验证
|
||||||
|
const compatibilityResult = await validatePortCompatibility(sourceDeviceId, sourcePort, targetDeviceId, targetPort);
|
||||||
|
if (!compatibilityResult.compatible) {
|
||||||
|
const errorReasons = compatibilityResult.reasons.filter(r => r.severity === 'error');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: '端口不兼容',
|
||||||
|
incompatibility: true,
|
||||||
|
compatibilityResult,
|
||||||
|
errors: errorReasons.map(r => r.message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 如果不是强制模式,检查冲突(包括反向端口分配)
|
// 如果不是强制模式,检查冲突(包括反向端口分配)
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const existingCable = await Cable.findOne({
|
const existingCable = await Cable.findOne({
|
||||||
@@ -280,7 +466,6 @@ router.post('/', async (req, res) => {
|
|||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ sourceDeviceId, sourcePort },
|
{ sourceDeviceId, sourcePort },
|
||||||
{ targetDeviceId, targetPort },
|
{ targetDeviceId, targetPort },
|
||||||
// 反向检查:已有接线的目标端口恰好是当前源端口
|
|
||||||
{ sourceDeviceId: targetDeviceId, sourcePort: targetPort },
|
{ sourceDeviceId: targetDeviceId, sourcePort: targetPort },
|
||||||
{ targetDeviceId: sourceDeviceId, targetPort: sourcePort },
|
{ targetDeviceId: sourceDeviceId, targetPort: sourcePort },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -630,7 +630,7 @@ const DeviceModel = ({
|
|||||||
// 背板渲染控制状态
|
// 背板渲染控制状态
|
||||||
const [backPanelLevel, setBackPanelLevel] = useState(0); // 0: 不渲染, 1: 简化, 2: 完整
|
const [backPanelLevel, setBackPanelLevel] = useState(0); // 0: 不渲染, 1: 简化, 2: 完整
|
||||||
const { camera } = useThree();
|
const { camera } = useThree();
|
||||||
const frameCount = useRef(0);
|
const prevCameraZRef = useRef(null);
|
||||||
|
|
||||||
// 使用传入的 uHeight (默认为 0.04445m)
|
// 使用传入的 uHeight (默认为 0.04445m)
|
||||||
const uHeight = propUHeight || 0.04445;
|
const uHeight = propUHeight || 0.04445;
|
||||||
@@ -653,20 +653,22 @@ const DeviceModel = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 背板渲染控制 - 根据相机位置动态调整
|
// 背板渲染控制 - 根据相机位置动态调整
|
||||||
// 只在相机绕到背面时才渲染背板
|
// 仅在相机 Z 坐标变化超过阈值时触发,减少不必要的状态更新
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
// 每10帧检查一次,减少计算频率
|
|
||||||
frameCount.current++;
|
|
||||||
if (frameCount.current % 10 !== 0) return;
|
|
||||||
|
|
||||||
if (!groupRef.current) return;
|
if (!groupRef.current) return;
|
||||||
|
|
||||||
|
const cameraZ = camera.position.z;
|
||||||
|
|
||||||
|
// 仅当相机 Z 坐标变化超过阈值(0.1)时才检查
|
||||||
|
if (prevCameraZRef.current !== null && Math.abs(cameraZ - prevCameraZRef.current) < 0.1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevCameraZRef.current = cameraZ;
|
||||||
|
|
||||||
// 获取相机相对设备的位置
|
// 获取相机相对设备的位置
|
||||||
const deviceWorldPos = new THREE.Vector3();
|
const deviceWorldPos = new THREE.Vector3();
|
||||||
groupRef.current.getWorldPosition(deviceWorldPos);
|
groupRef.current.getWorldPosition(deviceWorldPos);
|
||||||
|
|
||||||
const cameraZ = camera.position.z;
|
|
||||||
|
|
||||||
// 简单逻辑:相机在正面(z > threshold)时不渲染背板
|
// 简单逻辑:相机在正面(z > threshold)时不渲染背板
|
||||||
// 相机在背面(z <= threshold)时渲染完整背板
|
// 相机在背面(z <= threshold)时渲染完整背板
|
||||||
const shouldShowBackPanel = cameraZ <= BACK_PANEL_CONFIG.visibilityThreshold;
|
const shouldShowBackPanel = cameraZ <= BACK_PANEL_CONFIG.visibilityThreshold;
|
||||||
|
|||||||
@@ -109,28 +109,26 @@ const LODManager = ({
|
|||||||
const mediumDetailRef = useRef();
|
const mediumDetailRef = useRef();
|
||||||
const lowDetailRef = useRef();
|
const lowDetailRef = useRef();
|
||||||
const { camera } = useThree();
|
const { camera } = useThree();
|
||||||
// 使用 ref 存储 LOD 级别,避免 React 状态更新
|
|
||||||
const lodLevelRef = useRef(LOD_LEVELS.HIGH);
|
const lodLevelRef = useRef(LOD_LEVELS.HIGH);
|
||||||
const distanceRef = useRef(0);
|
const distanceRef = useRef(0);
|
||||||
// 帧计数器,用于节流
|
const prevDistanceRef = useRef(null);
|
||||||
const frameCount = useRef(0);
|
|
||||||
// 用于强制重新渲染的 state(仅在必要时更新)
|
|
||||||
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (!groupRef.current) return;
|
if (!groupRef.current) return;
|
||||||
|
|
||||||
// 节流:每5帧检查一次
|
|
||||||
frameCount.current++;
|
|
||||||
if (frameCount.current % 5 !== 0) return;
|
|
||||||
|
|
||||||
const distance = camera.position.distanceTo(groupRef.current.position);
|
const distance = camera.position.distanceTo(groupRef.current.position);
|
||||||
|
|
||||||
|
// 仅当距离变化超过阈值(10%)时才检查
|
||||||
|
if (prevDistanceRef.current !== null) {
|
||||||
|
const changeRatio = Math.abs(distance - prevDistanceRef.current) / (prevDistanceRef.current || 1);
|
||||||
|
if (changeRatio < 0.1) return;
|
||||||
|
}
|
||||||
|
prevDistanceRef.current = distance;
|
||||||
distanceRef.current = distance;
|
distanceRef.current = distance;
|
||||||
|
|
||||||
// 添加缓冲避免频繁切换(10% 缓冲)
|
const highThreshold = LOD_DISTANCES.HIGH;
|
||||||
const buffer = 0.1;
|
const mediumThreshold = LOD_DISTANCES.MEDIUM;
|
||||||
const highThreshold = LOD_DISTANCES.HIGH * (1 + buffer);
|
|
||||||
const mediumThreshold = LOD_DISTANCES.MEDIUM * (1 + buffer);
|
|
||||||
|
|
||||||
let newLevel = LOD_LEVELS.HIGH;
|
let newLevel = LOD_LEVELS.HIGH;
|
||||||
if (distance > mediumThreshold) {
|
if (distance > mediumThreshold) {
|
||||||
@@ -139,10 +137,8 @@ const LODManager = ({
|
|||||||
newLevel = LOD_LEVELS.MEDIUM;
|
newLevel = LOD_LEVELS.MEDIUM;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有当级别变化时才更新
|
|
||||||
if (newLevel !== lodLevelRef.current) {
|
if (newLevel !== lodLevelRef.current) {
|
||||||
lodLevelRef.current = newLevel;
|
lodLevelRef.current = newLevel;
|
||||||
// 直接操作 ref 切换可见性,避免频繁的 React 重渲染
|
|
||||||
if (highDetailRef.current) {
|
if (highDetailRef.current) {
|
||||||
highDetailRef.current.visible = newLevel === LOD_LEVELS.HIGH;
|
highDetailRef.current.visible = newLevel === LOD_LEVELS.HIGH;
|
||||||
}
|
}
|
||||||
@@ -152,11 +148,8 @@ const LODManager = ({
|
|||||||
if (lowDetailRef.current) {
|
if (lowDetailRef.current) {
|
||||||
lowDetailRef.current.visible = newLevel === LOD_LEVELS.LOW;
|
lowDetailRef.current.visible = newLevel === LOD_LEVELS.LOW;
|
||||||
}
|
}
|
||||||
// 偶尔强制更新以确保同步(每30帧)
|
|
||||||
if (frameCount.current % 30 === 0) {
|
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (level !== LOD_LEVELS.HIGH) {
|
if (level !== LOD_LEVELS.HIGH) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import DeviceModel from './DeviceModel';
|
import DeviceModel from './DeviceModel';
|
||||||
import LODManager, { LOD_LEVELS } from './LODManager';
|
import LODManager, { LOD_LEVELS } from './LODManager';
|
||||||
|
import { getULabelTexture } from './materials/TextureCache';
|
||||||
|
|
||||||
const RackModel = ({
|
const RackModel = ({
|
||||||
rack,
|
rack,
|
||||||
@@ -56,80 +57,50 @@ const RackModel = ({
|
|||||||
// 生成U位刻度标识 - 写在机柜左右两侧柱子上
|
// 生成U位刻度标识 - 写在机柜左右两侧柱子上
|
||||||
const uLabels = useMemo(() => {
|
const uLabels = useMemo(() => {
|
||||||
const labels = [];
|
const labels = [];
|
||||||
// 每一个U位都显示数字,从底部开始(U1在底部)
|
|
||||||
for (let u = 1; u <= rackHeight; u += 1) {
|
for (let u = 1; u <= rackHeight; u += 1) {
|
||||||
// 计算U位标识的Y坐标,需要加上设备组的偏移量
|
|
||||||
const yPos = (u - 1) * uHeight + uHeight / 2 + deviceGroupOffset;
|
const yPos = (u - 1) * uHeight + uHeight / 2 + deviceGroupOffset;
|
||||||
|
|
||||||
// 创建数字纹理 - 白色数字在深色背景上更清晰
|
const texture = getULabelTexture(u);
|
||||||
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 isMajorU = u % 5 === 0;
|
||||||
const planeSize = isMajorU ? 0.035 : 0.025;
|
const planeSize = isMajorU ? 0.035 : 0.025;
|
||||||
const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize);
|
const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize);
|
||||||
|
|
||||||
// 前侧柱子的位置: [-width/2 + postWidth/2, y, depth/2 - postWidth/2]
|
|
||||||
const leftPostX = -width / 2 + postWidth / 2;
|
const leftPostX = -width / 2 + postWidth / 2;
|
||||||
const rightPostX = width / 2 - postWidth / 2;
|
const rightPostX = width / 2 - postWidth / 2;
|
||||||
const frontPostZ = depth / 2 - postWidth / 2;
|
const frontPostZ = depth / 2 - postWidth / 2;
|
||||||
|
|
||||||
// 刻度线颜色:每5U使用醒目的黄色,其他使用灰色
|
|
||||||
const tickColor = isMajorU ? '#fbbf24' : '#6b7280';
|
const tickColor = isMajorU ? '#fbbf24' : '#6b7280';
|
||||||
const tickHeight = isMajorU ? 0.002 : 0.001;
|
const tickHeight = isMajorU ? 0.002 : 0.001;
|
||||||
|
|
||||||
labels.push(
|
labels.push(
|
||||||
<group key={`u-label-${u}`}>
|
<group key={`u-label-${u}`}>
|
||||||
{/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
|
|
||||||
<mesh
|
<mesh
|
||||||
position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
|
position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
|
||||||
geometry={planeGeometry}
|
geometry={planeGeometry}
|
||||||
>
|
>
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
map={leftTexture}
|
map={texture}
|
||||||
transparent={true}
|
transparent={true}
|
||||||
opacity={1}
|
opacity={1}
|
||||||
side={THREE.DoubleSide}
|
side={THREE.DoubleSide}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
|
|
||||||
<mesh
|
<mesh
|
||||||
position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
|
position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
|
||||||
geometry={planeGeometry}
|
geometry={planeGeometry}
|
||||||
>
|
>
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
map={rightTexture}
|
map={texture}
|
||||||
transparent={true}
|
transparent={true}
|
||||||
opacity={1}
|
opacity={1}
|
||||||
side={THREE.DoubleSide}
|
side={THREE.DoubleSide}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* 左前侧柱子上的刻度线 */}
|
|
||||||
<mesh position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
|
<mesh position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
|
||||||
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
||||||
<meshBasicMaterial color={tickColor} />
|
<meshBasicMaterial color={tickColor} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* 右前侧柱子上的刻度线 */}
|
|
||||||
<mesh position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
|
<mesh position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
|
||||||
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
|
||||||
<meshBasicMaterial color={tickColor} />
|
<meshBasicMaterial color={tickColor} />
|
||||||
@@ -138,7 +109,7 @@ const RackModel = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return labels;
|
return labels;
|
||||||
}, [rackHeight, uHeight, width, depth, deviceGroupOffset]);
|
}, [rackHeight]);
|
||||||
|
|
||||||
// 生成机柜框架
|
// 生成机柜框架
|
||||||
const frame = useMemo(() => {
|
const frame = useMemo(() => {
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ const Scene = forwardRef(
|
|||||||
subTitle="3D 场景在渲染过程中遇到错误,请检查浏览器是否支持 WebGL"
|
subTitle="3D 场景在渲染过程中遇到错误,请检查浏览器是否支持 WebGL"
|
||||||
>
|
>
|
||||||
<Canvas
|
<Canvas
|
||||||
shadows
|
|
||||||
dpr={deviceDpr}
|
dpr={deviceDpr}
|
||||||
performance={{ min: 0.5 }}
|
performance={{ min: 0.5 }}
|
||||||
gl={{
|
gl={{
|
||||||
@@ -178,12 +177,12 @@ const Scene = forwardRef(
|
|||||||
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||||
|
|
||||||
<ambientLight intensity={0.5} color="#ffffff" />
|
<ambientLight intensity={0.5} color="#ffffff" />
|
||||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" />
|
||||||
<directionalLight
|
<directionalLight
|
||||||
position={[10, 10, 5]}
|
position={[10, 10, 5]}
|
||||||
intensity={1}
|
intensity={1}
|
||||||
castShadow
|
castShadow
|
||||||
shadow-mapSize={[2048, 2048]}
|
shadow-mapSize={[512, 512]}
|
||||||
shadow-camera-far={20}
|
shadow-camera-far={20}
|
||||||
shadow-camera-left={-10}
|
shadow-camera-left={-10}
|
||||||
shadow-camera-right={10}
|
shadow-camera-right={10}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
class TextureCache {
|
||||||
|
constructor() {
|
||||||
|
this.cache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreate(key, creator) {
|
||||||
|
if (!this.cache.has(key)) {
|
||||||
|
this.cache.set(key, creator());
|
||||||
|
}
|
||||||
|
return this.cache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.cache.forEach(texture => {
|
||||||
|
if (texture instanceof THREE.CanvasTexture) {
|
||||||
|
texture.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uLabelTextureCache = new TextureCache();
|
||||||
|
|
||||||
|
export const getULabelTexture = num => {
|
||||||
|
return uLabelTextureCache.getOrCreate(num, () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 32;
|
||||||
|
canvas.height = 32;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, 32, 32);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 20px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(num.toString(), 16, 16);
|
||||||
|
|
||||||
|
return new THREE.CanvasTexture(canvas);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disposeULabelTextures = () => {
|
||||||
|
uLabelTextureCache.dispose();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextureCache;
|
||||||
@@ -30,14 +30,15 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import CloseButton from './CloseButton';
|
import CloseButton from './CloseButton';
|
||||||
import PortPanel from './PortPanel';
|
import PortPanel from './PortPanel';
|
||||||
|
import FilterableDeviceSelect from './FilterableDeviceSelect';
|
||||||
import { designTokens } from '../config/theme';
|
import { designTokens } from '../config/theme';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const CABLE_TYPES = [
|
const CABLE_TYPES = [
|
||||||
{ value: 'ethernet', label: '以太网线', color: '#52c41a', icon: '🌐' },
|
{ value: 'ethernet', label: '以太网线', desc: 'Cat6 1G/10G', color: '#52c41a', icon: '🌐' },
|
||||||
{ value: 'fiber', label: '光纤', color: '#1890ff', icon: '🔦' },
|
{ value: 'fiber', label: '光纤', desc: 'SMF/MMF 长距离', color: '#1890ff', icon: '🔦' },
|
||||||
{ value: 'copper', label: '铜缆', color: '#faad14', icon: '🔌' },
|
{ value: 'copper', label: '铜缆', desc: '电源/特殊连接', color: '#faad14', icon: '🔌' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CABLE_LENGTHS = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50];
|
const CABLE_LENGTHS = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50];
|
||||||
@@ -73,6 +74,7 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
const [targetPorts, setTargetPorts] = useState([]);
|
const [targetPorts, setTargetPorts] = useState([]);
|
||||||
const [conflicts, setConflicts] = useState([]);
|
const [conflicts, setConflicts] = useState([]);
|
||||||
const [cablesData, setCablesData] = useState([]);
|
const [cablesData, setCablesData] = useState([]);
|
||||||
|
const [compatibilityWarning, setCompatibilityWarning] = useState(null);
|
||||||
|
|
||||||
// 初始化编辑模式的数据
|
// 初始化编辑模式的数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -154,6 +156,21 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const checkPortCompatibility = useCallback(async (srcDeviceId, srcPort, tgtDeviceId, tgtPort) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/cables/check-compatibility', {
|
||||||
|
sourceDeviceId: srcDeviceId,
|
||||||
|
sourcePort: srcPort,
|
||||||
|
targetDeviceId: tgtDeviceId,
|
||||||
|
targetPort: tgtPort,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查端口兼容性失败:', error);
|
||||||
|
return { compatible: false, reasons: [] };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const debounce = (fn, delay) => {
|
const debounce = (fn, delay) => {
|
||||||
let timer = null;
|
let timer = null;
|
||||||
return function (...args) {
|
return function (...args) {
|
||||||
@@ -163,8 +180,8 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeviceSearch = useCallback(
|
const handleDeviceSearch = useCallback(
|
||||||
debounce((value) => {
|
debounce((value, type = '') => {
|
||||||
fetchDevices(value, 'switch');
|
fetchDevices(value, type);
|
||||||
}, 300),
|
}, 300),
|
||||||
[fetchDevices]
|
[fetchDevices]
|
||||||
);
|
);
|
||||||
@@ -183,6 +200,7 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
setSourcePorts([]);
|
setSourcePorts([]);
|
||||||
setTargetPorts([]);
|
setTargetPorts([]);
|
||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
|
setCompatibilityWarning(null);
|
||||||
setDevices([]);
|
setDevices([]);
|
||||||
setCablesData([]);
|
setCablesData([]);
|
||||||
|
|
||||||
@@ -215,7 +233,6 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
const handleSourcePortSelect = useCallback(async port => {
|
const handleSourcePortSelect = useCallback(async port => {
|
||||||
console.log('handleSourcePortSelect called with port:', port);
|
console.log('handleSourcePortSelect called with port:', port);
|
||||||
|
|
||||||
// 如果点击的是已选中的端口,则取消选择
|
|
||||||
const isAlreadySelected =
|
const isAlreadySelected =
|
||||||
sourcePort?.portId === port.portId || sourcePort?.portName === port.portName;
|
sourcePort?.portId === port.portId || sourcePort?.portName === port.portName;
|
||||||
|
|
||||||
@@ -223,13 +240,20 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
console.log('Deselecting source port:', port);
|
console.log('Deselecting source port:', port);
|
||||||
setSourcePort(null);
|
setSourcePort(null);
|
||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
|
setCompatibilityWarning(null);
|
||||||
} else {
|
} else {
|
||||||
// 只有在选择新端口时才进行设备相同性检查
|
|
||||||
if (sourceDevice?.deviceId === targetDevice?.deviceId) {
|
if (sourceDevice?.deviceId === targetDevice?.deviceId) {
|
||||||
message.warning('源设备和目标设备不能相同');
|
message.warning('源设备和目标设备不能相同');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conflictResult = await checkPortConflict(sourceDevice.deviceId, port.portName);
|
||||||
|
if (conflictResult.hasConflict) {
|
||||||
|
const conflict = conflictResult.conflicts[0];
|
||||||
|
message.error(`源端口 ${port.portName} 已被占用,无法选择`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSourcePort(port);
|
setSourcePort(port);
|
||||||
console.log('Source port set to:', port);
|
console.log('Source port set to:', port);
|
||||||
|
|
||||||
@@ -242,7 +266,6 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
const handleTargetPortSelect = useCallback(async port => {
|
const handleTargetPortSelect = useCallback(async port => {
|
||||||
console.log('handleTargetPortSelect called with port:', port);
|
console.log('handleTargetPortSelect called with port:', port);
|
||||||
|
|
||||||
// 如果点击的是已选中的端口,则取消选择
|
|
||||||
const isAlreadySelected =
|
const isAlreadySelected =
|
||||||
targetPort?.portId === port.portId || targetPort?.portName === port.portName;
|
targetPort?.portId === port.portId || targetPort?.portName === port.portName;
|
||||||
|
|
||||||
@@ -250,18 +273,54 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
console.log('Deselecting target port:', port);
|
console.log('Deselecting target port:', port);
|
||||||
setTargetPort(null);
|
setTargetPort(null);
|
||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
|
setCompatibilityWarning(null);
|
||||||
} else {
|
} else {
|
||||||
// 只有在选择新端口时才进行设备相同性检查
|
|
||||||
if (sourceDevice?.deviceId === targetDevice?.deviceId) {
|
if (sourceDevice?.deviceId === targetDevice?.deviceId) {
|
||||||
message.warning('源设备和目标设备不能相同');
|
message.warning('源设备和目标设备不能相同');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conflictResult = await checkPortConflict(sourceDevice.deviceId, port.portName);
|
||||||
|
if (conflictResult.hasConflict) {
|
||||||
|
const conflict = conflictResult.conflicts[0];
|
||||||
|
message.error(`目标端口 ${port.portName} 已被占用,无法选择`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setTargetPort(port);
|
setTargetPort(port);
|
||||||
console.log('Target port set to:', port);
|
console.log('Target port set to:', port);
|
||||||
await checkPortConflict(sourceDevice.deviceId, port.portName);
|
|
||||||
|
if (sourcePort) {
|
||||||
|
const compatResult = await checkPortCompatibility(
|
||||||
|
sourceDevice.deviceId,
|
||||||
|
sourcePort.portName,
|
||||||
|
targetDevice.deviceId,
|
||||||
|
port.portName
|
||||||
|
);
|
||||||
|
if (!compatResult.compatible) {
|
||||||
|
const errorReasons = compatResult.reasons.filter(r => r.severity === 'error');
|
||||||
|
if (errorReasons.length > 0) {
|
||||||
|
setCompatibilityWarning({
|
||||||
|
type: 'error',
|
||||||
|
message: errorReasons[0].message,
|
||||||
|
details: compatResult.reasons,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [sourceDevice, targetDevice, targetPort, checkPortConflict]);
|
} else {
|
||||||
|
const warningReasons = compatResult.reasons.filter(r => r.severity === 'warning');
|
||||||
|
if (warningReasons.length > 0) {
|
||||||
|
setCompatibilityWarning({
|
||||||
|
type: 'warning',
|
||||||
|
message: warningReasons[0].message,
|
||||||
|
details: compatResult.reasons,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCompatibilityWarning(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sourceDevice, targetDevice, targetPort, sourcePort, checkPortConflict, checkPortCompatibility]);
|
||||||
|
|
||||||
const handleNextStep = useCallback(async () => {
|
const handleNextStep = useCallback(async () => {
|
||||||
if (currentStep === 0) {
|
if (currentStep === 0) {
|
||||||
@@ -290,6 +349,10 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
message.warning('目标设备没有可用端口');
|
message.warning('目标设备没有可用端口');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (compatibilityWarning?.type === 'error') {
|
||||||
|
message.error('端口类型不兼容,无法创建接线');
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (currentStep === 2) {
|
} else if (currentStep === 2) {
|
||||||
if (!sourcePort || !targetPort) {
|
if (!sourcePort || !targetPort) {
|
||||||
message.warning('请先选择源端口和目标端口');
|
message.warning('请先选择源端口和目标端口');
|
||||||
@@ -299,10 +362,14 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
message.warning('源端口和目标端口不能相同');
|
message.warning('源端口和目标端口不能相同');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (compatibilityWarning?.type === 'error') {
|
||||||
|
message.error('端口类型不兼容,无法创建接线');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep(prev => prev + 1);
|
setCurrentStep(prev => prev + 1);
|
||||||
}, [currentStep, sourceDevice, targetDevice, sourcePort, targetPort, sourcePorts, targetPorts]);
|
}, [currentStep, sourceDevice, targetDevice, sourcePort, targetPort, sourcePorts, targetPorts, compatibilityWarning]);
|
||||||
|
|
||||||
const handlePrevStep = useCallback(() => {
|
const handlePrevStep = useCallback(() => {
|
||||||
setCurrentStep(prev => Math.max(0, prev - 1));
|
setCurrentStep(prev => Math.max(0, prev - 1));
|
||||||
@@ -413,6 +480,7 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
sourcePorts={sourcePorts}
|
sourcePorts={sourcePorts}
|
||||||
sourcePort={sourcePort}
|
sourcePort={sourcePort}
|
||||||
onPortSelect={handleSourcePortSelect}
|
onPortSelect={handleSourcePortSelect}
|
||||||
|
onDevicesChange={setDevices}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -428,6 +496,8 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
targetPort={targetPort}
|
targetPort={targetPort}
|
||||||
onPortSelect={handleTargetPortSelect}
|
onPortSelect={handleTargetPortSelect}
|
||||||
conflicts={conflicts}
|
conflicts={conflicts}
|
||||||
|
compatibilityWarning={compatibilityWarning}
|
||||||
|
onDevicesChange={setDevices}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -462,6 +532,7 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
cableLength={selectedCableLength}
|
cableLength={selectedCableLength}
|
||||||
cableLabel={cableLabel}
|
cableLabel={cableLabel}
|
||||||
cableDescription={cableDescription}
|
cableDescription={cableDescription}
|
||||||
|
compatibilityWarning={compatibilityWarning}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -604,15 +675,8 @@ const Step1SourceDevice = ({
|
|||||||
sourcePorts,
|
sourcePorts,
|
||||||
sourcePort,
|
sourcePort,
|
||||||
onPortSelect,
|
onPortSelect,
|
||||||
|
onDevicesChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
|
||||||
|
|
||||||
const handleSearch = (e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setSearchKeyword(value);
|
|
||||||
onDeviceSearch?.(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 0' }}>
|
<div style={{ padding: '20px 0' }}>
|
||||||
<Card
|
<Card
|
||||||
@@ -632,12 +696,9 @@ const Step1SourceDevice = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<Input
|
<FilterableDeviceSelect
|
||||||
placeholder="搜索设备名称或ID..."
|
filterType="switch"
|
||||||
prefix={<SearchOutlined />}
|
onDeviceListChange={onDevicesChange}
|
||||||
value={searchKeyword}
|
|
||||||
onChange={handleSearch}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -855,15 +916,9 @@ const Step2TargetDevice = ({
|
|||||||
targetPort,
|
targetPort,
|
||||||
onPortSelect,
|
onPortSelect,
|
||||||
conflicts,
|
conflicts,
|
||||||
|
compatibilityWarning,
|
||||||
|
onDevicesChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
|
||||||
|
|
||||||
const handleSearch = (e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setSearchKeyword(value);
|
|
||||||
onDeviceSearch?.(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 0' }}>
|
<div style={{ padding: '20px 0' }}>
|
||||||
<Card
|
<Card
|
||||||
@@ -883,12 +938,9 @@ const Step2TargetDevice = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<Input
|
<FilterableDeviceSelect
|
||||||
placeholder="搜索设备名称或ID..."
|
filterType=""
|
||||||
prefix={<SearchOutlined />}
|
onDeviceListChange={onDevicesChange}
|
||||||
value={searchKeyword}
|
|
||||||
onChange={handleSearch}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1118,6 +1170,45 @@ const Step2TargetDevice = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{compatibilityWarning && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: compatibilityWarning.type === 'error'
|
||||||
|
? 'rgba(239,68,68,0.1)'
|
||||||
|
: 'rgba(250,173,20,0.1)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${compatibilityWarning.type === 'error'
|
||||||
|
? 'rgba(239,68,68,0.3)'
|
||||||
|
: 'rgba(250,173,20,0.3)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text
|
||||||
|
type={compatibilityWarning.type === 'error' ? 'danger' : 'warning'}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{compatibilityWarning.type === 'error' ? '❌ 端口类型不兼容' : '⚠️ 端口速率不匹配'}
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '12px', color: '#595959' }}>
|
||||||
|
{compatibilityWarning.message}
|
||||||
|
</div>
|
||||||
|
{compatibilityWarning.type === 'error' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
background: 'rgba(0,0,0,0.05)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
请选择相同类型的端口(如 RJ45 电口只能连接 RJ45 电口)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -1164,116 +1255,198 @@ const Step3CableConfig = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1fr 1fr',
|
gridTemplateColumns: 'repeat(12, 1fr)',
|
||||||
gap: '24px',
|
gap: '16px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
|
style={{
|
||||||
连接信息
|
gridColumn: 'span 12',
|
||||||
</Typography.Text>
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginBottom: '8px' }}>
|
||||||
|
源设备
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '15px', marginBottom: '4px' }}>
|
||||||
|
{sourceDevice?.name}
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
border: 'none',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sourcePort?.portName}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: '#f5f5f5',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 4px 12px rgba(24,144,255,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowRightOutlined style={{ color: '#fff', fontSize: '18px' }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#8c8c8c',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
建议长度: <Text strong style={{ color: '#1890ff' }}>{estimatedLength}m</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
marginBottom: '16px',
|
color: '#fff',
|
||||||
border: '1px solid #d9d9d9',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '12px' }}>
|
<div style={{ fontSize: '12px', opacity: 0.8, marginBottom: '8px' }}>
|
||||||
<div style={{ flex: 1 }}>
|
目标设备
|
||||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>源设备</div>
|
|
||||||
<div style={{ fontWeight: 600, color: '#262626' }}>{sourceDevice?.name}</div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#bfbfbf' }}>
|
|
||||||
端口: {sourcePort?.portName}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '15px', marginBottom: '4px' }}>
|
||||||
|
{targetDevice?.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ArrowRightOutlined style={{ color: '#1890ff' }} />
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>目标设备</div>
|
|
||||||
<div style={{ fontWeight: 600, color: '#262626' }}>{targetDevice?.name}</div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#bfbfbf' }}>
|
|
||||||
端口: {targetPort?.portName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>线缆类型</div>
|
|
||||||
<div style={{ marginTop: '8px' }}>
|
|
||||||
{cableTypes.map(type => (
|
|
||||||
<Tag
|
<Tag
|
||||||
key={type.value}
|
|
||||||
color={type.color}
|
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
background: 'rgba(255,255,255,0.2)',
|
||||||
marginRight: '8px',
|
border: 'none',
|
||||||
marginBottom: '8px',
|
color: '#fff',
|
||||||
border: selectedCableType === type.value
|
|
||||||
? `2px solid ${type.color}`
|
|
||||||
: '1px solid #d9d9d9',
|
|
||||||
}}
|
}}
|
||||||
onClick={() => onCableTypeChange(type.value)}
|
|
||||||
>
|
>
|
||||||
{type.icon} {type.label}
|
{targetPort?.portName}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(12, 1fr)',
|
||||||
|
gap: '16px',
|
||||||
|
marginTop: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ gridColumn: 'span 5' }}>
|
||||||
|
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
|
||||||
|
线缆类型
|
||||||
|
</Typography.Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cableTypes.map(type => (
|
||||||
|
<div
|
||||||
|
key={type.value}
|
||||||
|
onClick={() => onCableTypeChange(type.value)}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `2px solid ${selectedCableType === type.value ? type.color : '#e8e8e8'}`,
|
||||||
|
background: selectedCableType === type.value
|
||||||
|
? `${type.color}10`
|
||||||
|
: '#fafafa',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '18px' }}>{type.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '13px' }}>{type.label}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#8c8c8c' }}>{type.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ gridColumn: 'span 7' }}>
|
||||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>线缆长度</div>
|
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
|
||||||
<div style={{ marginTop: '8px' }}>
|
线缆长度
|
||||||
{cableLengths.map(length => (
|
</Typography.Text>
|
||||||
<Tag
|
<div
|
||||||
key={length}
|
|
||||||
color={selectedCableLength === length ? 'blue' : 'default'}
|
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
display: 'flex',
|
||||||
marginRight: '8px',
|
flexWrap: 'wrap',
|
||||||
marginBottom: '8px',
|
gap: '8px',
|
||||||
border: selectedCableLength === length
|
marginBottom: '16px',
|
||||||
? '2px solid #1890ff'
|
|
||||||
: '1px solid #d9d9d9',
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{cableLengths.map(length => (
|
||||||
|
<div
|
||||||
|
key={length}
|
||||||
onClick={() => onCableLengthChange(length)}
|
onClick={() => onCableLengthChange(length)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: `2px solid ${selectedCableLength === length ? '#1890ff' : '#e8e8e8'}`,
|
||||||
|
background: selectedCableLength === length
|
||||||
|
? '#e6f7ff'
|
||||||
|
: '#fafafa',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
fontWeight: selectedCableLength === length ? 600 : 400,
|
||||||
|
color: selectedCableLength === length ? '#1890ff' : '#595959',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{length}m
|
{length}m
|
||||||
</Tag>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
|
||||||
<InfoCircleOutlined style={{ marginRight: '4px' }} />
|
|
||||||
建议长度: <Text code>{estimatedLength}m</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
|
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
|
||||||
线缆属性
|
线缆属性
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: '#f5f5f5',
|
background: '#fafafa',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
border: '1px solid #d9d9d9',
|
border: '1px solid #e8e8e8',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<Typography.Text style={{ fontSize: '13px', display: 'block', marginBottom: '8px' }}>
|
<Typography.Text style={{ fontSize: '12px', color: '#8c8c8c', display: 'block', marginBottom: '6px' }}>
|
||||||
线缆标签
|
线缆标签
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Input
|
<Input
|
||||||
@@ -1282,44 +1455,42 @@ const Step3CableConfig = ({
|
|||||||
onChange={e => setCableLabel(e.target.value)}
|
onChange={e => setCableLabel(e.target.value)}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
|
|
||||||
示例: CABLE-{sourceDevice?.deviceId.slice(-4)}-{targetDevice?.deviceId.slice(-4)}-{Date.now().toString().slice(-4)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text style={{ fontSize: '13px', display: 'block', marginBottom: '8px' }}>
|
<Typography.Text style={{ fontSize: '12px', color: '#8c8c8c', display: 'block', marginBottom: '6px' }}>
|
||||||
备注说明
|
备注说明
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
placeholder="添加接线说明..."
|
placeholder="添加接线说明..."
|
||||||
value={cableDescription}
|
value={cableDescription}
|
||||||
onChange={e => setCableDescription(e.target.value)}
|
onChange={e => setCableDescription(e.target.value)}
|
||||||
rows={6}
|
rows={3}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '16px',
|
marginTop: '16px',
|
||||||
padding: '12px',
|
padding: '14px 16px',
|
||||||
background: 'rgba(24,144,255,0.1)',
|
background: '#f0f5ff',
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
border: '1px solid rgba(24,144,255,0.3)',
|
border: '1px solid #adc6ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Text style={{ fontWeight: 600, color: '#1890ff' }}>
|
<InfoCircleOutlined style={{ color: '#1890ff', fontSize: '16px', marginTop: '2px' }} />
|
||||||
💡 提示
|
<div>
|
||||||
|
<Typography.Text style={{ fontWeight: 600, color: '#1890ff', fontSize: '13px' }}>
|
||||||
|
选择建议
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<div style={{ fontSize: '12px', marginTop: '8px', lineHeight: '1.6' }}>
|
<div style={{ fontSize: '12px', marginTop: '4px', lineHeight: '1.6', color: '#595959' }}>
|
||||||
• 以太网线(Cat6): 适用于1G/10G短距离连接
|
以太网线适用于1G/10G短距离连接 · 光纤适用于长距离或高带宽需求 · 铜缆适用于电源或特殊设备
|
||||||
<br />
|
|
||||||
• 光纤(SMF/MMF): 适用于长距离或高带宽需求
|
|
||||||
<br />
|
|
||||||
• 铜缆: 适用于电源或特殊设备连接
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1337,6 +1508,7 @@ const Step4Preview = ({
|
|||||||
cableLength,
|
cableLength,
|
||||||
cableLabel,
|
cableLabel,
|
||||||
cableDescription,
|
cableDescription,
|
||||||
|
compatibilityWarning,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 0' }}>
|
<div style={{ padding: '20px 0' }}>
|
||||||
@@ -1479,15 +1651,41 @@ const Step4Preview = ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
background: 'rgba(82,196,26,0.1)',
|
background: compatibilityWarning?.type === 'error'
|
||||||
|
? 'rgba(239,68,68,0.1)'
|
||||||
|
: compatibilityWarning?.type === 'warning'
|
||||||
|
? 'rgba(250,173,20,0.1)'
|
||||||
|
: 'rgba(82,196,26,0.1)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid rgba(82,196,26,0.3)',
|
border: `1px solid ${compatibilityWarning?.type === 'error'
|
||||||
|
? 'rgba(239,68,68,0.3)'
|
||||||
|
: compatibilityWarning?.type === 'warning'
|
||||||
|
? 'rgba(250,173,20,0.3)'
|
||||||
|
: 'rgba(82,196,26,0.3)'}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{compatibilityWarning?.type === 'error' ? (
|
||||||
|
<>
|
||||||
|
<CheckCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
<Typography.Text style={{ fontWeight: 600, color: '#ff4d4f' }}>
|
||||||
|
❌ 端口类型不兼容,无法创建接线
|
||||||
|
</Typography.Text>
|
||||||
|
</>
|
||||||
|
) : compatibilityWarning?.type === 'warning' ? (
|
||||||
|
<>
|
||||||
|
<CheckCircleOutlined style={{ color: '#faad14' }} />
|
||||||
|
<Typography.Text style={{ fontWeight: 600, color: '#faad14' }}>
|
||||||
|
⚠️ {compatibilityWarning.message}
|
||||||
|
</Typography.Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
<Typography.Text style={{ fontWeight: 600, color: '#52c41a' }}>
|
<Typography.Text style={{ fontWeight: 600, color: '#52c41a' }}>
|
||||||
✅ 所有信息已确认,可以创建接线
|
✅ 所有信息已确认,可以创建接线
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Select, Input, Button, Spin, Empty, Row, Col } from 'antd';
|
||||||
|
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const DEVICE_TYPES = [
|
||||||
|
{ value: 'server', label: '服务器' },
|
||||||
|
{ value: 'switch', label: '交换机' },
|
||||||
|
{ value: 'router', label: '路由器' },
|
||||||
|
{ value: 'storage', label: '存储' },
|
||||||
|
{ value: 'other', label: '其他' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FilterableDeviceSelect = ({
|
||||||
|
value = null,
|
||||||
|
onChange,
|
||||||
|
onDeviceListChange,
|
||||||
|
filterType = '',
|
||||||
|
}) => {
|
||||||
|
const [roomId, setRoomId] = useState(null);
|
||||||
|
const [rackId, setRackId] = useState(null);
|
||||||
|
const [type, setType] = useState(filterType || '');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
|
||||||
|
const [rooms, setRooms] = useState([]);
|
||||||
|
const [racks, setRacks] = useState([]);
|
||||||
|
const [devices, setDevices] = useState([]);
|
||||||
|
|
||||||
|
const [loadingRooms, setLoadingRooms] = useState(false);
|
||||||
|
const [loadingRacks, setLoadingRacks] = useState(false);
|
||||||
|
const [loadingDevices, setLoadingDevices] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRooms = async () => {
|
||||||
|
setLoadingRooms(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/rooms');
|
||||||
|
const roomsData = response.rooms || response.data || response || [];
|
||||||
|
setRooms(Array.isArray(roomsData) ? roomsData : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载机房列表失败:', error);
|
||||||
|
setRooms([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingRooms(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRooms();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roomId) {
|
||||||
|
setRacks([]);
|
||||||
|
setRackId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRacks = async () => {
|
||||||
|
setLoadingRacks(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/racks', { params: { roomId, pageSize: 500 } });
|
||||||
|
const racksData = response.racks || response.data || response || [];
|
||||||
|
setRacks(Array.isArray(racksData) ? racksData : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载机柜列表失败:', error);
|
||||||
|
setRacks([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingRacks(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRacks();
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const fetchDevices = async () => {
|
||||||
|
setLoadingDevices(true);
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (roomId) params.roomId = roomId;
|
||||||
|
if (rackId) params.rackId = rackId;
|
||||||
|
if (type) params.type = type;
|
||||||
|
if (keyword) params.keyword = keyword;
|
||||||
|
params.pageSize = 500;
|
||||||
|
|
||||||
|
const response = await api.get('/devices/all', { params });
|
||||||
|
let deviceList = response.data || response.devices || response || [];
|
||||||
|
if (!Array.isArray(deviceList)) {
|
||||||
|
deviceList = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setDevices(deviceList);
|
||||||
|
if (onDeviceListChange) {
|
||||||
|
onDeviceListChange(deviceList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载设备列表失败:', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setDevices([]);
|
||||||
|
if (onDeviceListChange) {
|
||||||
|
onDeviceListChange([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingDevices(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(fetchDevices, 300);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [roomId, rackId, type, keyword, onDeviceListChange]);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setRoomId(null);
|
||||||
|
setRackId(null);
|
||||||
|
setType(filterType || '');
|
||||||
|
setKeyword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomChange = (newRoomId) => {
|
||||||
|
setRoomId(newRoomId);
|
||||||
|
setRackId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]} align="middle">
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ whiteSpace: 'nowrap', fontSize: '13px' }}>机房:</span>
|
||||||
|
<Select
|
||||||
|
placeholder="全部"
|
||||||
|
allowClear
|
||||||
|
value={roomId}
|
||||||
|
onChange={handleRoomChange}
|
||||||
|
loading={loadingRooms}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<Option key={room.roomId} value={room.roomId}>
|
||||||
|
{room.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ whiteSpace: 'nowrap', fontSize: '13px' }}>机柜:</span>
|
||||||
|
<Select
|
||||||
|
placeholder="全部"
|
||||||
|
allowClear
|
||||||
|
value={rackId}
|
||||||
|
onChange={setRackId}
|
||||||
|
loading={loadingRacks}
|
||||||
|
disabled={!roomId}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{racks.map((rack) => (
|
||||||
|
<Option key={rack.rackId} value={rack.rackId}>
|
||||||
|
{rack.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ whiteSpace: 'nowrap', fontSize: '13px' }}>类型:</span>
|
||||||
|
<Select
|
||||||
|
placeholder="全部"
|
||||||
|
allowClear
|
||||||
|
value={type || undefined}
|
||||||
|
onChange={setType}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{DEVICE_TYPES.map((t) => (
|
||||||
|
<Option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索设备名称/ID..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleReset}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{loadingDevices && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loadingDevices && devices.length === 0 && (keyword || roomId || rackId || type) && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="无匹配设备"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterableDeviceSelect;
|
||||||
@@ -299,6 +299,20 @@ const PortPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'rgba(250,173,20,0.15)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid rgba(250,173,20,0.3)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#faad14',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ 此端口已被占用,无法选择
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -419,29 +433,19 @@ const PortPanel = ({
|
|||||||
>
|
>
|
||||||
{paginatedPorts.map(port => {
|
{paginatedPorts.map(port => {
|
||||||
const statusColor = getPortStatusColor(port.status);
|
const statusColor = getPortStatusColor(port.status);
|
||||||
const isClickable = onPortClick && port.status !== 'disabled';
|
const isClickable = onPortClick && port.status !== 'disabled' && port.status !== 'occupied';
|
||||||
const cable = findPortCable(port);
|
const cable = findPortCable(port);
|
||||||
const isSelected = selectedPort?.portId === port.portId || selectedPort?.portName === port.portName;
|
const isSelected = selectedPort?.portId === port.portId || selectedPort?.portName === port.portName;
|
||||||
|
|
||||||
return (
|
const handlePortClick = () => {
|
||||||
<Tooltip
|
|
||||||
key={port.portId}
|
|
||||||
title={renderPortTooltip(port)}
|
|
||||||
placement="top"
|
|
||||||
color="#1e293b"
|
|
||||||
overlayStyle={{
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
console.log('Port clicked:', port);
|
|
||||||
if (isClickable) {
|
if (isClickable) {
|
||||||
console.log('Port is clickable, calling onPortClick');
|
|
||||||
onPortClick(port);
|
onPortClick(port);
|
||||||
}
|
}
|
||||||
}}
|
};
|
||||||
|
|
||||||
|
const portElement = (
|
||||||
|
<div
|
||||||
|
onClick={handlePortClick}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -451,7 +455,6 @@ const PortPanel = ({
|
|||||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
minWidth: '0',
|
minWidth: '0',
|
||||||
pointerEvents: isClickable ? 'auto' : 'none',
|
|
||||||
transform: isSelected ? 'scale(1.08)' : 'scale(1)',
|
transform: isSelected ? 'scale(1.08)' : 'scale(1)',
|
||||||
boxShadow: isSelected
|
boxShadow: isSelected
|
||||||
? '0 0 0 4px rgba(24,144,255,0.15), 0 8px 25px rgba(24,144,255,0.25)'
|
? '0 0 0 4px rgba(24,144,255,0.15), 0 8px 25px rgba(24,144,255,0.25)'
|
||||||
@@ -460,7 +463,6 @@ const PortPanel = ({
|
|||||||
: 'none',
|
: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 选中状态标记 - 更明显的视觉反馈 */}
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -488,7 +490,6 @@ const PortPanel = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* LED 指示灯 - 在端口上方 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: isSelected ? '8px' : '6px',
|
width: isSelected ? '8px' : '6px',
|
||||||
@@ -503,8 +504,6 @@ const PortPanel = ({
|
|||||||
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none',
|
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 端口主体 - 矩形样式 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -524,7 +523,6 @@ const PortPanel = ({
|
|||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 端口内部图标 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: isSelected ? '12px' : '10px',
|
fontSize: isSelected ? '12px' : '10px',
|
||||||
@@ -535,8 +533,6 @@ const PortPanel = ({
|
|||||||
>
|
>
|
||||||
{getPortTypeIcon(port.portType)}
|
{getPortTypeIcon(port.portType)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 接线指示标记 */}
|
|
||||||
{cable && (
|
{cable && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -552,8 +548,6 @@ const PortPanel = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 端口名称 - 在端口下方 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: isSelected ? '11px' : '9px',
|
fontSize: isSelected ? '11px' : '9px',
|
||||||
@@ -571,6 +565,20 @@ const PortPanel = ({
|
|||||||
{getPortDisplayName(port.portName)}
|
{getPortDisplayName(port.portName)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={port.portId}
|
||||||
|
title={renderPortTooltip(port)}
|
||||||
|
placement="top"
|
||||||
|
color="#1e293b"
|
||||||
|
overlayStyle={{
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{portElement}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user