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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user