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:
zhang1106
2026-04-07 17:05:19 +08:00
parent 4f8a44f6b5
commit bfe6ca4744
9 changed files with 1012 additions and 362 deletions
+186 -1
View File
@@ -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 },
],
+10 -8
View File
@@ -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;
+11 -18
View File
@@ -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();
}
});
+5 -34
View File
@@ -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(
<group key={`u-label-${u}`}>
{/* 左前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
<mesh
position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
geometry={planeGeometry}
>
<meshBasicMaterial
map={leftTexture}
map={texture}
transparent={true}
opacity={1}
side={THREE.DoubleSide}
/>
</mesh>
{/* 右前侧柱子上的U位标识 - 贴在柱子正侧面(面向前方) */}
<mesh
position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.001]}
geometry={planeGeometry}
>
<meshBasicMaterial
map={rightTexture}
map={texture}
transparent={true}
opacity={1}
side={THREE.DoubleSide}
/>
</mesh>
{/* 左前侧柱子上的刻度线 */}
<mesh position={[leftPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
<meshBasicMaterial color={tickColor} />
</mesh>
{/* 右前侧柱子上的刻度线 */}
<mesh position={[rightPostX, yPos, frontPostZ + postWidth / 2 + 0.002]}>
<boxGeometry args={[postWidth, tickHeight, 0.001]} />
<meshBasicMaterial color={tickColor} />
@@ -138,7 +109,7 @@ const RackModel = ({
);
}
return labels;
}, [rackHeight, uHeight, width, depth, deviceGroupOffset]);
}, [rackHeight]);
// 生成机柜框架
const frame = useMemo(() => {
+2 -3
View File
@@ -165,7 +165,6 @@ const Scene = forwardRef(
subTitle="3D 场景在渲染过程中遇到错误,请检查浏览器是否支持 WebGL"
>
<Canvas
shadows
dpr={deviceDpr}
performance={{ min: 0.5 }}
gl={{
@@ -178,12 +177,12 @@ const Scene = forwardRef(
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
<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
position={[10, 10, 5]}
intensity={1}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-mapSize={[512, 512]}
shadow-camera-far={20}
shadow-camera-left={-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;
+358 -160
View File
@@ -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 (
<div style={{ padding: '20px 0' }}>
<Card
@@ -632,12 +696,9 @@ const Step1SourceDevice = ({
</div>
<div style={{ marginBottom: '16px' }}>
<Input
placeholder="搜索设备名称或ID..."
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={handleSearch}
style={{ width: '100%' }}
<FilterableDeviceSelect
filterType="switch"
onDeviceListChange={onDevicesChange}
/>
</div>
@@ -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 (
<div style={{ padding: '20px 0' }}>
<Card
@@ -883,12 +938,9 @@ const Step2TargetDevice = ({
</div>
<div style={{ marginBottom: '16px' }}>
<Input
placeholder="搜索设备名称或ID..."
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={handleSearch}
style={{ width: '100%' }}
<FilterableDeviceSelect
filterType=""
onDeviceListChange={onDevicesChange}
/>
</div>
@@ -1118,6 +1170,45 @@ const Step2TargetDevice = ({
</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>
</motion.div>
)}
@@ -1164,116 +1255,198 @@ const Step3CableConfig = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '24px',
gridTemplateColumns: 'repeat(12, 1fr)',
gap: '16px',
}}
>
<div>
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
连接信息
</Typography.Text>
<div
style={{
gridColumn: 'span 12',
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
style={{
background: '#f5f5f5',
borderRadius: '12px',
padding: '16px',
marginBottom: '16px',
border: '1px solid #d9d9d9',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '12px' }}>
<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>
<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
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>
<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
key={type.value}
color={type.color}
style={{
cursor: 'pointer',
marginRight: '8px',
marginBottom: '8px',
border: selectedCableType === type.value
? `2px solid ${type.color}`
: '1px solid #d9d9d9',
}}
onClick={() => onCableTypeChange(type.value)}
>
{type.icon} {type.label}
</Tag>
))}
</div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>线缆长度</div>
<div style={{ marginTop: '8px' }}>
{cableLengths.map(length => (
<Tag
key={length}
color={selectedCableLength === length ? 'blue' : 'default'}
style={{
cursor: 'pointer',
marginRight: '8px',
marginBottom: '8px',
border: selectedCableLength === length
? '2px solid #1890ff'
: '1px solid #d9d9d9',
}}
onClick={() => onCableLengthChange(length)}
>
{length}m
</Tag>
))}
</div>
</div>
<div
style={{
fontSize: '12px',
color: '#8c8c8c',
textAlign: 'center',
}}
>
建议长度: <Text strong style={{ color: '#1890ff' }}>{estimatedLength}m</Text>
</div>
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
<InfoCircleOutlined style={{ marginRight: '4px' }} />
建议长度: <Text code>{estimatedLength}m</Text>
<div
style={{
background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 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' }}>
{targetDevice?.name}
</div>
<Tag
style={{
background: 'rgba(255,255,255,0.2)',
border: 'none',
color: '#fff',
}}
>
{targetPort?.portName}
</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 style={{ gridColumn: 'span 7' }}>
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
线缆长度
</Typography.Text>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '16px',
}}
>
{cableLengths.map(length => (
<div
key={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
</div>
))}
</div>
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
线缆属性
</Typography.Text>
<div
style={{
background: '#f5f5f5',
background: '#fafafa',
borderRadius: '12px',
padding: '16px',
border: '1px solid #d9d9d9',
border: '1px solid #e8e8e8',
}}
>
<div style={{ marginBottom: '16px' }}>
<Typography.Text style={{ fontSize: '13px', display: 'block', marginBottom: '8px' }}>
<div style={{ marginBottom: '12px' }}>
<Typography.Text style={{ fontSize: '12px', color: '#8c8c8c', display: 'block', marginBottom: '6px' }}>
线缆标签
</Typography.Text>
<Input
@@ -1282,44 +1455,42 @@ const Step3CableConfig = ({
onChange={e => setCableLabel(e.target.value)}
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>
<Typography.Text style={{ fontSize: '13px', display: 'block', marginBottom: '8px' }}>
<Typography.Text style={{ fontSize: '12px', color: '#8c8c8c', display: 'block', marginBottom: '6px' }}>
备注说明
</Typography.Text>
<Input.TextArea
placeholder="添加接线说明..."
value={cableDescription}
onChange={e => setCableDescription(e.target.value)}
rows={6}
rows={3}
style={{ width: '100%' }}
/>
</div>
</div>
</div>
</div>
<div
style={{
marginTop: '16px',
padding: '12px',
background: 'rgba(24,144,255,0.1)',
borderRadius: '8px',
border: '1px solid rgba(24,144,255,0.3)',
}}
>
<Typography.Text style={{ fontWeight: 600, color: '#1890ff' }}>
💡 提示
</Typography.Text>
<div style={{ fontSize: '12px', marginTop: '8px', lineHeight: '1.6' }}>
以太网线Cat6: 适用于1G/10G短距离连接
<br />
光纤SMF/MMF: 适用于长距离或高带宽需求
<br />
铜缆: 适用于电源或特殊设备连接
</div>
<div
style={{
marginTop: '16px',
padding: '14px 16px',
background: '#f0f5ff',
borderRadius: '10px',
border: '1px solid #adc6ff',
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
}}
>
<InfoCircleOutlined style={{ color: '#1890ff', fontSize: '16px', marginTop: '2px' }} />
<div>
<Typography.Text style={{ fontWeight: 600, color: '#1890ff', fontSize: '13px' }}>
选择建议
</Typography.Text>
<div style={{ fontSize: '12px', marginTop: '4px', lineHeight: '1.6', color: '#595959' }}>
以太网线适用于1G/10G短距离连接 · 光纤适用于长距离或高带宽需求 · 铜缆适用于电源或特殊设备
</div>
</div>
</div>
@@ -1337,6 +1508,7 @@ const Step4Preview = ({
cableLength,
cableLabel,
cableDescription,
compatibilityWarning,
}) => {
return (
<div style={{ padding: '20px 0' }}>
@@ -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)'}`,
}}
>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Typography.Text style={{ fontWeight: 600, color: '#52c41a' }}>
所有信息已确认可以创建接线
</Typography.Text>
{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' }} />
<Typography.Text style={{ fontWeight: 600, color: '#52c41a' }}>
所有信息已确认可以创建接线
</Typography.Text>
</>
)}
</div>
</div>
</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;
+146 -138
View File
@@ -299,6 +299,20 @@ const PortPanel = ({
</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,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 = (
<div
onClick={handlePortClick}
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',
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 && (
<div
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
border: '3px solid #fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 20,
boxShadow: '0 4px 12px rgba(24,144,255,0.4), 0 2px 6px rgba(0,0,0,0.15)',
}}
>
<CheckCircleOutlined
style={{
color: '#fff',
fontSize: '14px',
fontWeight: 'bold',
}}
/>
</div>
)}
<div
style={{
width: isSelected ? '8px' : '6px',
height: isSelected ? '8px' : '6px',
borderRadius: '50%',
background: isSelected ? '#1890ff' : statusColor,
boxShadow: isSelected
? `0 0 8px #1890ff, 0 0 16px #1890ff50`
: `0 0 6px ${statusColor}, 0 0 12px ${statusColor}50`,
marginBottom: '6px',
transition: 'all 0.2s ease',
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none',
}}
/>
<div
style={{
width: '100%',
aspectRatio: '1 / 1.2',
background: isSelected
? 'linear-gradient(180deg, #e6f7ff 0%, #bae7ff 100%)'
: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
border: `2px solid ${isSelected ? '#1890ff' : statusColor}`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: isSelected
? 'inset 0 2px 4px rgba(24,144,255,0.2), 0 4px 12px rgba(24,144,255,0.15)'
: 'inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
}}
>
<div
style={{
fontSize: isSelected ? '12px' : '10px',
color: isSelected ? '#1890ff' : statusColor,
opacity: 0.9,
transition: 'all 0.2s ease',
}}
>
{getPortTypeIcon(port.portType)}
</div>
{cable && (
<div
style={{
position: 'absolute',
top: '1px',
right: '1px',
width: '4px',
height: '4px',
borderRadius: '50%',
background: getCableTypeColor(cable.cableType),
boxShadow: `0 0 3px ${getCableTypeColor(cable.cableType)}`,
}}
/>
)}
</div>
<div
style={{
fontSize: isSelected ? '11px' : '9px',
fontWeight: isSelected ? 600 : 500,
color: isSelected ? '#1890ff' : 'rgba(255, 255, 255, 0.7)',
textAlign: 'center',
marginTop: '4px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
transition: 'all 0.2s ease',
}}
>
{getPortDisplayName(port.portName)}
</div>
</div>
);
return (
<Tooltip
key={port.portId}
@@ -434,143 +578,7 @@ const PortPanel = ({
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<div
onClick={() => {
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 && (
<div
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
border: '3px solid #fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 20,
boxShadow: '0 4px 12px rgba(24,144,255,0.4), 0 2px 6px rgba(0,0,0,0.15)',
}}
>
<CheckCircleOutlined
style={{
color: '#fff',
fontSize: '14px',
fontWeight: 'bold',
}}
/>
</div>
)}
{/* LED 指示灯 - 在端口上方 */}
<div
style={{
width: isSelected ? '8px' : '6px',
height: isSelected ? '8px' : '6px',
borderRadius: '50%',
background: isSelected ? '#1890ff' : statusColor,
boxShadow: isSelected
? `0 0 8px #1890ff, 0 0 16px #1890ff50`
: `0 0 6px ${statusColor}, 0 0 12px ${statusColor}50`,
marginBottom: '6px',
transition: 'all 0.2s ease',
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none',
}}
/>
{/* 端口主体 - 矩形样式 */}
<div
style={{
width: '100%',
aspectRatio: '1 / 1.2',
background: isSelected
? 'linear-gradient(180deg, #e6f7ff 0%, #bae7ff 100%)'
: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
border: `2px solid ${isSelected ? '#1890ff' : statusColor}`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: isSelected
? 'inset 0 2px 4px rgba(24,144,255,0.2), 0 4px 12px rgba(24,144,255,0.15)'
: 'inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
}}
>
{/* 端口内部图标 */}
<div
style={{
fontSize: isSelected ? '12px' : '10px',
color: isSelected ? '#1890ff' : statusColor,
opacity: 0.9,
transition: 'all 0.2s ease',
}}
>
{getPortTypeIcon(port.portType)}
</div>
{/* 接线指示标记 */}
{cable && (
<div
style={{
position: 'absolute',
top: '1px',
right: '1px',
width: '4px',
height: '4px',
borderRadius: '50%',
background: getCableTypeColor(cable.cableType),
boxShadow: `0 0 3px ${getCableTypeColor(cable.cableType)}`,
}}
/>
)}
</div>
{/* 端口名称 - 在端口下方 */}
<div
style={{
fontSize: isSelected ? '11px' : '9px',
fontWeight: isSelected ? 600 : 500,
color: isSelected ? '#1890ff' : 'rgba(255, 255, 255, 0.7)',
textAlign: 'center',
marginTop: '4px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
transition: 'all 0.2s ease',
}}
>
{getPortDisplayName(port.portName)}
</div>
</div>
{portElement}
</Tooltip>
);
})}