From bfe6ca4744a42be92163ff196741e5fb5503260d Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Tue, 7 Apr 2026 17:05:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(3d):=20=E4=BC=98=E5=8C=963D=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E6=80=A7=E8=83=BD=E5=B9=B6=E6=B7=BB=E5=8A=A0=E7=BA=B9?= =?UTF-8?q?=E7=90=86=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routes/cables.js | 187 ++++++- frontend/src/components/3d/DeviceModel.jsx | 18 +- frontend/src/components/3d/LODManager.jsx | 29 +- frontend/src/components/3d/RackModel.jsx | 39 +- frontend/src/components/3d/Scene.jsx | 5 +- .../components/3d/materials/TextureCache.js | 50 ++ frontend/src/components/CableWizardModal.jsx | 518 ++++++++++++------ .../src/components/FilterableDeviceSelect.jsx | 244 +++++++++ frontend/src/components/PortPanel.jsx | 284 +++++----- 9 files changed, 1012 insertions(+), 362 deletions(-) create mode 100644 frontend/src/components/3d/materials/TextureCache.js create mode 100644 frontend/src/components/FilterableDeviceSelect.jsx diff --git a/backend/routes/cables.js b/backend/routes/cables.js index b4aa23a..b0e0593 100644 --- a/backend/routes/cables.js +++ b/backend/routes/cables.js @@ -5,6 +5,159 @@ const Cable = require('../models/Cable'); const Device = require('../models/Device'); 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) { 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) => { try { const { @@ -273,6 +447,18 @@ router.post('/', async (req, res) => { 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) { const existingCable = await Cable.findOne({ @@ -280,7 +466,6 @@ router.post('/', async (req, res) => { [Op.or]: [ { sourceDeviceId, sourcePort }, { targetDeviceId, targetPort }, - // 反向检查:已有接线的目标端口恰好是当前源端口 { sourceDeviceId: targetDeviceId, sourcePort: targetPort }, { targetDeviceId: sourceDeviceId, targetPort: sourcePort }, ], diff --git a/frontend/src/components/3d/DeviceModel.jsx b/frontend/src/components/3d/DeviceModel.jsx index 8d99e6c..2b06c25 100644 --- a/frontend/src/components/3d/DeviceModel.jsx +++ b/frontend/src/components/3d/DeviceModel.jsx @@ -630,7 +630,7 @@ const DeviceModel = ({ // 背板渲染控制状态 const [backPanelLevel, setBackPanelLevel] = useState(0); // 0: 不渲染, 1: 简化, 2: 完整 const { camera } = useThree(); - const frameCount = useRef(0); + const prevCameraZRef = useRef(null); // 使用传入的 uHeight (默认为 0.04445m) const uHeight = propUHeight || 0.04445; @@ -653,20 +653,22 @@ const DeviceModel = ({ }); // 背板渲染控制 - 根据相机位置动态调整 - // 只在相机绕到背面时才渲染背板 + // 仅在相机 Z 坐标变化超过阈值时触发,减少不必要的状态更新 useFrame(() => { - // 每10帧检查一次,减少计算频率 - frameCount.current++; - if (frameCount.current % 10 !== 0) 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(); groupRef.current.getWorldPosition(deviceWorldPos); - const cameraZ = camera.position.z; - // 简单逻辑:相机在正面(z > threshold)时不渲染背板 // 相机在背面(z <= threshold)时渲染完整背板 const shouldShowBackPanel = cameraZ <= BACK_PANEL_CONFIG.visibilityThreshold; diff --git a/frontend/src/components/3d/LODManager.jsx b/frontend/src/components/3d/LODManager.jsx index 857967a..ea00f26 100644 --- a/frontend/src/components/3d/LODManager.jsx +++ b/frontend/src/components/3d/LODManager.jsx @@ -109,28 +109,26 @@ const LODManager = ({ const mediumDetailRef = useRef(); const lowDetailRef = useRef(); const { camera } = useThree(); - // 使用 ref 存储 LOD 级别,避免 React 状态更新 const lodLevelRef = useRef(LOD_LEVELS.HIGH); const distanceRef = useRef(0); - // 帧计数器,用于节流 - const frameCount = useRef(0); - // 用于强制重新渲染的 state(仅在必要时更新) + const prevDistanceRef = useRef(null); const [, forceUpdate] = React.useReducer(x => x + 1, 0); useFrame(() => { if (!groupRef.current) return; - // 节流:每5帧检查一次 - frameCount.current++; - if (frameCount.current % 5 !== 0) return; - 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; - // 添加缓冲避免频繁切换(10% 缓冲) - const buffer = 0.1; - const highThreshold = LOD_DISTANCES.HIGH * (1 + buffer); - const mediumThreshold = LOD_DISTANCES.MEDIUM * (1 + buffer); + const highThreshold = LOD_DISTANCES.HIGH; + const mediumThreshold = LOD_DISTANCES.MEDIUM; let newLevel = LOD_LEVELS.HIGH; if (distance > mediumThreshold) { @@ -139,10 +137,8 @@ const LODManager = ({ newLevel = LOD_LEVELS.MEDIUM; } - // 只有当级别变化时才更新 if (newLevel !== lodLevelRef.current) { lodLevelRef.current = newLevel; - // 直接操作 ref 切换可见性,避免频繁的 React 重渲染 if (highDetailRef.current) { highDetailRef.current.visible = newLevel === LOD_LEVELS.HIGH; } @@ -152,10 +148,7 @@ const LODManager = ({ if (lowDetailRef.current) { lowDetailRef.current.visible = newLevel === LOD_LEVELS.LOW; } - // 偶尔强制更新以确保同步(每30帧) - if (frameCount.current % 30 === 0) { - forceUpdate(); - } + forceUpdate(); } }); diff --git a/frontend/src/components/3d/RackModel.jsx b/frontend/src/components/3d/RackModel.jsx index 40ad0d2..88b2772 100644 --- a/frontend/src/components/3d/RackModel.jsx +++ b/frontend/src/components/3d/RackModel.jsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import * as THREE from 'three'; import DeviceModel from './DeviceModel'; import LODManager, { LOD_LEVELS } from './LODManager'; +import { getULabelTexture } from './materials/TextureCache'; const RackModel = ({ rack, @@ -56,80 +57,50 @@ const RackModel = ({ // 生成U位刻度标识 - 写在机柜左右两侧柱子上 const uLabels = useMemo(() => { const labels = []; - // 每一个U位都显示数字,从底部开始(U1在底部) for (let u = 1; u <= rackHeight; u += 1) { - // 计算U位标识的Y坐标,需要加上设备组的偏移量 const yPos = (u - 1) * uHeight + uHeight / 2 + deviceGroupOffset; - // 创建数字纹理 - 白色数字在深色背景上更清晰 - const createNumberTexture = num => { - const canvas = document.createElement('canvas'); - canvas.width = 128; - canvas.height = 128; - const ctx = canvas.getContext('2d'); + const texture = getULabelTexture(u); - // 清除画布 - ctx.clearRect(0, 0, 128, 128); - - // 绘制白色数字 - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 80px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(num.toString(), 64, 64); - - return new THREE.CanvasTexture(canvas); - }; - - const leftTexture = createNumberTexture(u); - const rightTexture = createNumberTexture(u); - - // 创建平面几何体显示数字 - 每5U使用稍大的尺寸 const isMajorU = u % 5 === 0; const planeSize = isMajorU ? 0.035 : 0.025; const planeGeometry = new THREE.PlaneGeometry(planeSize, planeSize); - // 前侧柱子的位置: [-width/2 + postWidth/2, y, depth/2 - postWidth/2] const leftPostX = -width / 2 + postWidth / 2; const rightPostX = width / 2 - postWidth / 2; const frontPostZ = depth / 2 - postWidth / 2; - // 刻度线颜色:每5U使用醒目的黄色,其他使用灰色 const tickColor = isMajorU ? '#fbbf24' : '#6b7280'; const tickHeight = isMajorU ? 0.002 : 0.001; labels.push( - {/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */} - {/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */} - {/* 左前侧柱子上的刻度线 */} - {/* 右前侧柱子上的刻度线 */} @@ -138,7 +109,7 @@ const RackModel = ({ ); } return labels; - }, [rackHeight, uHeight, width, depth, deviceGroupOffset]); + }, [rackHeight]); // 生成机柜框架 const frame = useMemo(() => { diff --git a/frontend/src/components/3d/Scene.jsx b/frontend/src/components/3d/Scene.jsx index a0a22dd..34eb5ee 100644 --- a/frontend/src/components/3d/Scene.jsx +++ b/frontend/src/components/3d/Scene.jsx @@ -165,7 +165,6 @@ const Scene = forwardRef( subTitle="3D 场景在渲染过程中遇到错误,请检查浏览器是否支持 WebGL" > - + { + 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; diff --git a/frontend/src/components/CableWizardModal.jsx b/frontend/src/components/CableWizardModal.jsx index 4db78e7..de39a1a 100644 --- a/frontend/src/components/CableWizardModal.jsx +++ b/frontend/src/components/CableWizardModal.jsx @@ -30,14 +30,15 @@ import { motion, AnimatePresence } from 'framer-motion'; import axios from 'axios'; import CloseButton from './CloseButton'; import PortPanel from './PortPanel'; +import FilterableDeviceSelect from './FilterableDeviceSelect'; import { designTokens } from '../config/theme'; const { Title, Text } = Typography; const CABLE_TYPES = [ - { value: 'ethernet', label: '以太网线', color: '#52c41a', icon: '🌐' }, - { value: 'fiber', label: '光纤', color: '#1890ff', icon: '🔦' }, - { value: 'copper', label: '铜缆', color: '#faad14', icon: '🔌' }, + { value: 'ethernet', label: '以太网线', desc: 'Cat6 1G/10G', color: '#52c41a', icon: '🌐' }, + { value: 'fiber', label: '光纤', desc: 'SMF/MMF 长距离', color: '#1890ff', icon: '🔦' }, + { value: 'copper', label: '铜缆', desc: '电源/特殊连接', color: '#faad14', icon: '🔌' }, ]; 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 [conflicts, setConflicts] = useState([]); const [cablesData, setCablesData] = useState([]); + const [compatibilityWarning, setCompatibilityWarning] = useState(null); // 初始化编辑模式的数据 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) => { let timer = null; return function (...args) { @@ -163,8 +180,8 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed }; const handleDeviceSearch = useCallback( - debounce((value) => { - fetchDevices(value, 'switch'); + debounce((value, type = '') => { + fetchDevices(value, type); }, 300), [fetchDevices] ); @@ -183,6 +200,7 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed setSourcePorts([]); setTargetPorts([]); setConflicts([]); + setCompatibilityWarning(null); setDevices([]); setCablesData([]); @@ -215,7 +233,6 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed const handleSourcePortSelect = useCallback(async port => { console.log('handleSourcePortSelect called with port:', port); - // 如果点击的是已选中的端口,则取消选择 const isAlreadySelected = 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); setSourcePort(null); setConflicts([]); + setCompatibilityWarning(null); } else { - // 只有在选择新端口时才进行设备相同性检查 if (sourceDevice?.deviceId === targetDevice?.deviceId) { message.warning('源设备和目标设备不能相同'); return; } - + + const conflictResult = await checkPortConflict(sourceDevice.deviceId, port.portName); + if (conflictResult.hasConflict) { + const conflict = conflictResult.conflicts[0]; + message.error(`源端口 ${port.portName} 已被占用,无法选择`); + return; + } + setSourcePort(port); console.log('Source port set to:', port); @@ -242,7 +266,6 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed const handleTargetPortSelect = useCallback(async port => { console.log('handleTargetPortSelect called with port:', port); - // 如果点击的是已选中的端口,则取消选择 const isAlreadySelected = 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); setTargetPort(null); setConflicts([]); + setCompatibilityWarning(null); } else { - // 只有在选择新端口时才进行设备相同性检查 if (sourceDevice?.deviceId === targetDevice?.deviceId) { message.warning('源设备和目标设备不能相同'); return; } - + + const conflictResult = await checkPortConflict(sourceDevice.deviceId, port.portName); + if (conflictResult.hasConflict) { + const conflict = conflictResult.conflicts[0]; + message.error(`目标端口 ${port.portName} 已被占用,无法选择`); + return; + } + setTargetPort(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, + }); + } + } 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, checkPortConflict]); + }, [sourceDevice, targetDevice, targetPort, sourcePort, checkPortConflict, checkPortCompatibility]); const handleNextStep = useCallback(async () => { if (currentStep === 0) { @@ -290,6 +349,10 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed message.warning('目标设备没有可用端口'); return; } + if (compatibilityWarning?.type === 'error') { + message.error('端口类型不兼容,无法创建接线'); + return; + } } else if (currentStep === 2) { if (!sourcePort || !targetPort) { message.warning('请先选择源端口和目标端口'); @@ -299,10 +362,14 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed message.warning('源端口和目标端口不能相同'); return; } + if (compatibilityWarning?.type === 'error') { + message.error('端口类型不兼容,无法创建接线'); + return; + } } setCurrentStep(prev => prev + 1); - }, [currentStep, sourceDevice, targetDevice, sourcePort, targetPort, sourcePorts, targetPorts]); + }, [currentStep, sourceDevice, targetDevice, sourcePort, targetPort, sourcePorts, targetPorts, compatibilityWarning]); const handlePrevStep = useCallback(() => { setCurrentStep(prev => Math.max(0, prev - 1)); @@ -413,6 +480,7 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed sourcePorts={sourcePorts} sourcePort={sourcePort} onPortSelect={handleSourcePortSelect} + onDevicesChange={setDevices} /> ); @@ -428,6 +496,8 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed targetPort={targetPort} onPortSelect={handleTargetPortSelect} conflicts={conflicts} + compatibilityWarning={compatibilityWarning} + onDevicesChange={setDevices} /> ); @@ -462,6 +532,7 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed cableLength={selectedCableLength} cableLabel={cableLabel} cableDescription={cableDescription} + compatibilityWarning={compatibilityWarning} /> ); @@ -604,15 +675,8 @@ const Step1SourceDevice = ({ sourcePorts, sourcePort, onPortSelect, + onDevicesChange, }) => { - const [searchKeyword, setSearchKeyword] = useState(''); - - const handleSearch = (e) => { - const value = e.target.value; - setSearchKeyword(value); - onDeviceSearch?.(value); - }; - return (
- } - value={searchKeyword} - onChange={handleSearch} - style={{ width: '100%' }} +
@@ -855,15 +916,9 @@ const Step2TargetDevice = ({ targetPort, onPortSelect, conflicts, + compatibilityWarning, + onDevicesChange, }) => { - const [searchKeyword, setSearchKeyword] = useState(''); - - const handleSearch = (e) => { - const value = e.target.value; - setSearchKeyword(value); - onDeviceSearch?.(value); - }; - return (
- } - value={searchKeyword} - onChange={handleSearch} - style={{ width: '100%' }} +
@@ -1118,6 +1170,45 @@ const Step2TargetDevice = ({
)} + + {compatibilityWarning && ( +
+ + {compatibilityWarning.type === 'error' ? '❌ 端口类型不兼容' : '⚠️ 端口速率不匹配'} + +
+ {compatibilityWarning.message} +
+ {compatibilityWarning.type === 'error' && ( +
+ 请选择相同类型的端口(如 RJ45 电口只能连接 RJ45 电口) +
+ )} +
+ )} )} @@ -1164,116 +1255,198 @@ const Step3CableConfig = ({
-
- - 连接信息 - +
+
+
+ 源设备 +
+
+ {sourceDevice?.name} +
+ + {sourcePort?.portName} + +
-
-
-
源设备
-
{sourceDevice?.name}
-
- 端口: {sourcePort?.portName} -
-
- - - -
-
目标设备
-
{targetDevice?.name}
-
- 端口: {targetPort?.portName} -
-
+
+
- - - -
-
-
线缆类型
-
- {cableTypes.map(type => ( - onCableTypeChange(type.value)} - > - {type.icon} {type.label} - - ))} -
-
- -
-
线缆长度
-
- {cableLengths.map(length => ( - onCableLengthChange(length)} - > - {length}m - - ))} -
-
+
+ 建议长度: {estimatedLength}m
-
- - 建议长度: {estimatedLength}m +
+
+ 目标设备 +
+
+ {targetDevice?.name} +
+ + {targetPort?.portName} + +
+
+
+ +
+
+ + 线缆类型 + +
+ {cableTypes.map(type => ( +
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', + }} + > + {type.icon} +
+
{type.label}
+
{type.desc}
+
+
+ ))}
-
+
+ + 线缆长度 + +
+ {cableLengths.map(length => ( +
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 +
+ ))} +
+ 线缆属性 -
-
- +
+ 线缆标签 setCableLabel(e.target.value)} style={{ width: '100%' }} /> -
- 示例: CABLE-{sourceDevice?.deviceId.slice(-4)}-{targetDevice?.deviceId.slice(-4)}-{Date.now().toString().slice(-4)} -
-
- + 备注说明 setCableDescription(e.target.value)} - rows={6} + rows={3} style={{ width: '100%' }} />
+
+
-
- - 💡 提示 - -
- • 以太网线(Cat6): 适用于1G/10G短距离连接 -
- • 光纤(SMF/MMF): 适用于长距离或高带宽需求 -
- • 铜缆: 适用于电源或特殊设备连接 -
+
+ +
+ + 选择建议 + +
+ 以太网线适用于1G/10G短距离连接 · 光纤适用于长距离或高带宽需求 · 铜缆适用于电源或特殊设备
@@ -1337,6 +1508,7 @@ const Step4Preview = ({ cableLength, cableLabel, cableDescription, + compatibilityWarning, }) => { return (
@@ -1479,15 +1651,41 @@ const Step4Preview = ({ alignItems: 'center', gap: '8px', 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', - 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' ? ( + <> + + + ❌ 端口类型不兼容,无法创建接线 + + + ) : compatibilityWarning?.type === 'warning' ? ( + <> + + + ⚠️ {compatibilityWarning.message} + + + ) : ( + <> + + + ✅ 所有信息已确认,可以创建接线 + + + )}
diff --git a/frontend/src/components/FilterableDeviceSelect.jsx b/frontend/src/components/FilterableDeviceSelect.jsx new file mode 100644 index 0000000..04e0910 --- /dev/null +++ b/frontend/src/components/FilterableDeviceSelect.jsx @@ -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 ( +
+ + +
+ 机房: + +
+ + + +
+ 机柜: + +
+ + + +
+ 类型: + +
+ + + +
+ } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + allowClear + style={{ flex: 1 }} + /> + +
+ +
+ + {loadingDevices && ( +
+ +
+ )} + + {!loadingDevices && devices.length === 0 && (keyword || roomId || rackId || type) && ( +
+ +
+ )} +
+ ); +}; + +export default FilterableDeviceSelect; diff --git a/frontend/src/components/PortPanel.jsx b/frontend/src/components/PortPanel.jsx index e2b9ee3..caa8a9f 100644 --- a/frontend/src/components/PortPanel.jsx +++ b/frontend/src/components/PortPanel.jsx @@ -299,6 +299,20 @@ const PortPanel = ({
)}
+
+ ⚠️ 此端口已被占用,无法选择 +
)} @@ -419,10 +433,140 @@ const PortPanel = ({ > {paginatedPorts.map(port => { 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 isSelected = selectedPort?.portId === port.portId || selectedPort?.portName === port.portName; + const handlePortClick = () => { + if (isClickable) { + onPortClick(port); + } + }; + + const portElement = ( +
+ {isSelected && ( +
+ +
+ )} +
+
+
+ {getPortTypeIcon(port.portType)} +
+ {cable && ( +
+ )} +
+
+ {getPortDisplayName(port.portName)} +
+
+ ); + return ( -
{ - console.log('Port clicked:', port); - if (isClickable) { - console.log('Port is clickable, calling onPortClick'); - onPortClick(port); - } - }} - style={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: '6px 4px', - cursor: isClickable ? 'pointer' : 'not-allowed', - transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', - position: 'relative', - minWidth: '0', - pointerEvents: isClickable ? 'auto' : 'none', - transform: isSelected ? 'scale(1.08)' : 'scale(1)', - boxShadow: isSelected - ? '0 0 0 4px rgba(24,144,255,0.15), 0 8px 25px rgba(24,144,255,0.25)' - : isClickable - ? '0 0 0 0 rgba(24,144,255,0)' - : 'none', - }} - > - {/* 选中状态标记 - 更明显的视觉反馈 */} - {isSelected && ( -
- -
- )} - {/* LED 指示灯 - 在端口上方 */} -
- - {/* 端口主体 - 矩形样式 */} -
- {/* 端口内部图标 */} -
- {getPortTypeIcon(port.portType)} -
- - {/* 接线指示标记 */} - {cable && ( -
- )} -
- - {/* 端口名称 - 在端口下方 */} -
- {getPortDisplayName(port.portName)} -
-
+ {portElement} ); })}