Files
yunrui_asset/frontend/src/components/PortPanel.jsx
T
zhang1106 bfe6ca4744 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
2026-04-07 17:05:19 +08:00

636 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { Tooltip, Badge, Divider, Pagination } from 'antd';
import { LinkOutlined, SwapRightOutlined, AimOutlined, NodeIndexOutlined, CheckCircleOutlined } from '@ant-design/icons';
const PortPanel = ({
ports,
deviceName,
deviceId,
cables = [],
devices = [],
onPortClick,
compact = false,
selectedPort = null,
}) => {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(48); // 默认每页48个端口
// 按端口名称排序(升序)
const sortedPorts = [...ports].sort((a, b) => {
// 尝试按数字部分排序,支持格式如:1/0/1, eth0/1, GigabitEthernet1/0/1 等
const extractNumbers = str => {
const matches = str.match(/\d+/g);
return matches ? matches.map(Number) : [];
};
const numsA = extractNumbers(a.portName);
const numsB = extractNumbers(b.portName);
// 逐个比较数字部分
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
if (numsA[i] !== numsB[i]) {
return numsA[i] - numsB[i];
}
}
// 如果数字部分相同,按字符串排序
return a.portName.localeCompare(b.portName);
});
// 分页数据
const totalPorts = sortedPorts.length;
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedPorts = sortedPorts.slice(startIndex, endIndex);
// 获取端口状态颜色
const getPortStatusColor = status => {
switch (status) {
case 'free':
return '#6b7280'; // 灰色 - 空闲
case 'occupied':
return '#10b981'; // 绿色 - 占用
case 'fault':
return '#ef4444'; // 红色 - 故障
case 'disabled':
return '#374151'; // 深灰色 - 禁用
default:
return '#6b7280';
}
};
// 获取端口状态文本
const getPortStatusText = status => {
switch (status) {
case 'free':
return '空闲';
case 'occupied':
return '已连接';
case 'fault':
return '故障';
case 'disabled':
return '禁用';
default:
return '空闲';
}
};
// 获取端口类型图标 - 使用更真实的端口符号
const getPortTypeIcon = portType => {
switch (portType) {
case 'RJ45':
return '⬡'; // 六边形表示网口
case 'SFP':
case 'SFP+':
case 'SFP28':
return '▭'; // 矩形表示SFP
case 'QSFP':
case 'QSFP28':
return '▯'; // 宽矩形表示QSFP
default:
return '⬡';
}
};
// 获取简化端口显示名称(只显示数字)
const getPortDisplayName = portName => {
// 提取最后的数字
const match = portName.match(/(\d+)$/);
if (match) {
return match[1];
}
// 如果没有数字,返回原名称
return portName;
};
// 获取线缆类型文本
const getCableTypeText = cableType => {
const typeMap = {
ethernet: '网线',
fiber: '光纤',
copper: '铜缆',
power: '电源线',
};
return typeMap[cableType] || cableType || '未知';
};
// 获取线缆类型颜色
const getCableTypeColor = cableType => {
const colorMap = {
ethernet: '#52c41a',
fiber: '#1890ff',
copper: '#faad14',
power: '#ff4d4f',
};
return colorMap[cableType] || '#999';
};
// 查找端口关联的接线
const findPortCable = port => {
if (!cables || cables.length === 0) return null;
return cables.find(
cable =>
(cable.sourceDeviceId === deviceId && cable.sourcePortId === port.portId) ||
(cable.targetDeviceId === deviceId && cable.targetPortId === port.portId) ||
(cable.sourceDeviceId === deviceId && cable.sourcePort === port.portName) ||
(cable.targetDeviceId === deviceId && cable.targetPort === port.portName)
);
};
// 获取连接的对端信息
const getPeerInfo = (cable, currentPort) => {
if (!cable) return null;
const isSource =
cable.sourceDeviceId === deviceId ||
(cable.sourcePortId && cable.sourcePortId === currentPort.portId) ||
cable.sourcePort === currentPort.portName;
if (isSource) {
// 当前是源端,返回目标端信息
const targetDevice = devices.find(d => d.deviceId === cable.targetDeviceId);
return {
deviceName: targetDevice?.name || cable.targetDeviceId,
deviceId: cable.targetDeviceId,
portName: cable.targetPort || cable.targetPortId,
direction: 'out',
};
} else {
// 当前是目标端,返回源端信息
const sourceDevice = devices.find(d => d.deviceId === cable.sourceDeviceId);
return {
deviceName: sourceDevice?.name || cable.sourceDeviceId,
deviceId: cable.sourceDeviceId,
portName: cable.sourcePort || cable.sourcePortId,
direction: 'in',
};
}
};
// 渲染端口详情提示
const renderPortTooltip = port => {
const cable = findPortCable(port);
const peerInfo = cable ? getPeerInfo(cable, port) : null;
return (
<div style={{ padding: '8px 4px', minWidth: '220px' }}>
{/* 端口基本信息 */}
<div
style={{
fontWeight: 600,
fontSize: 14,
marginBottom: 8,
borderBottom: '1px solid rgba(255,255,255,0.2)',
paddingBottom: 4,
}}
>
<NodeIndexOutlined style={{ marginRight: 6 }} />
{port.portName}
</div>
<div style={{ fontSize: 12, lineHeight: '1.8' }}>
<div>
<span style={{ opacity: 0.7 }}>端口类型:</span> {port.portType}
</div>
<div>
<span style={{ opacity: 0.7 }}>端口速率:</span> {port.portSpeed}
</div>
<div>
<span style={{ opacity: 0.7 }}>状态:</span>
<span
style={{
color: getPortStatusColor(port.status),
marginLeft: 4,
fontWeight: 500,
}}
>
{getPortStatusText(port.status)}
</span>
</div>
{port.vlanId && (
<div>
<span style={{ opacity: 0.7 }}>VLAN:</span> {port.vlanId}
</div>
)}
{port.description && (
<div>
<span style={{ opacity: 0.7 }}>描述:</span> {port.description}
</div>
)}
</div>
{/* 接线信息 */}
{cable && peerInfo && (
<>
<Divider style={{ margin: '12px 0', borderColor: 'rgba(255,255,255,0.1)' }} />
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8, color: '#69c0ff' }}>
<LinkOutlined style={{ marginRight: 6 }} />
接线详情
</div>
<div style={{ fontSize: 12, lineHeight: '1.8' }}>
{/* 线缆类型和长度 */}
<div style={{ marginBottom: 6 }}>
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: '4px',
background: getCableTypeColor(cable.cableType) + '20',
color: getCableTypeColor(cable.cableType),
fontSize: '11px',
fontWeight: 500,
}}
>
{getCableTypeText(cable.cableType)}
</span>
{cable.cableLength && (
<span style={{ marginLeft: 8, opacity: 0.8 }}>{cable.cableLength}m</span>
)}
</div>
{/* 连接方向 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
background: 'rgba(255,255,255,0.05)',
borderRadius: '6px',
marginTop: '8px',
}}
>
<div style={{ textAlign: 'center' }}>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: peerInfo.direction === 'out' ? '#52c41a20' : '#1890ff20',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
}}
>
{peerInfo.direction === 'out' ? '📤' : '📥'}
</div>
<div style={{ fontSize: '10px', marginTop: '2px', opacity: 0.6 }}>
{peerInfo.direction === 'out' ? '输出' : '输入'}
</div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, color: '#fff' }}>{peerInfo.deviceName}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>端口: {peerInfo.portName}</div>
<div style={{ fontSize: '10px', opacity: 0.5 }}>ID: {peerInfo.deviceId}</div>
</div>
</div>
{/* 线缆标签/备注 */}
{cable.label && (
<div style={{ marginTop: 8, opacity: 0.8 }}>
<span style={{ opacity: 0.7 }}>标签:</span> {cable.label}
</div>
)}
{cable.notes && (
<div style={{ marginTop: 4, opacity: 0.8 }}>
<span style={{ opacity: 0.7 }}>备注:</span> {cable.notes}
</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>
</>
)}
{/* 空闲端口提示 */}
{port.status === 'free' && !cable && (
<>
<Divider style={{ margin: '12px 0', borderColor: 'rgba(255,255,255,0.1)' }} />
<div style={{ fontSize: 12, opacity: 0.6, textAlign: 'center', padding: '4px 0' }}>
<AimOutlined style={{ marginRight: 4 }} />
端口空闲暂无接线
</div>
</>
)}
</div>
);
};
return (
<div
style={{
background: 'linear-gradient(145deg, #1e293b 0%, #0f172a 100%)',
borderRadius: compact ? '12px' : '16px',
padding: compact ? '16px' : '24px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
{/* 设备标题 - compact 模式下隐藏 */}
{!compact && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '20px',
paddingBottom: '16px',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
}}
>
🔌
</div>
<div>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#fff' }}>
{deviceName || '交换机'}
</div>
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.5)', marginTop: '2px' }}>
{sortedPorts.length} 个端口
</div>
</div>
</div>
{/* 状态图例 */}
<div style={{ display: 'flex', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#6b7280',
boxShadow: '0 0 8px #6b7280',
}}
/>
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>空闲</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#10b981',
boxShadow: '0 0 8px #10b981',
}}
/>
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>已连接</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#ef4444',
boxShadow: '0 0 8px #ef4444',
}}
/>
<span style={{ fontSize: '12px', color: 'rgba(255,255,255,0.6)' }}>故障</span>
</div>
</div>
</div>
)}
{/* 端口网格 - 固定每行24个端口 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(24, 1fr)',
gap: '8px',
padding: '16px',
background: 'rgba(0, 0, 0, 0.3)',
borderRadius: '12px',
border: '1px solid rgba(255, 255, 255, 0.05)',
}}
>
{paginatedPorts.map(port => {
const statusColor = getPortStatusColor(port.status);
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}
title={renderPortTooltip(port)}
placement="top"
color="#1e293b"
overlayStyle={{
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
{portElement}
</Tooltip>
);
})}
</div>
{/* 分页 */}
{totalPorts > pageSize && (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '16px 0 0 0',
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
marginTop: '16px',
}}
>
<Pagination
current={currentPage}
total={totalPorts}
pageSize={pageSize}
onChange={(page, size) => {
setCurrentPage(page);
if (size) setPageSize(size);
}}
showSizeChanger
showQuickJumper
showTotal={total => `共 ${total} 个端口`}
pageSizeOptions={['24', '48', '96']}
size="small"
style={{
color: 'rgba(255, 255, 255, 0.8)',
}}
/>
</div>
)}
{/* 添加脉冲动画 */}
<style>{`
@keyframes pulse {
0%, 100% {
opacity: 1;
box-shadow: 0 0 10px #ef4444, 0 0 20px #ef444450;
}
50% {
opacity: 0.5;
box-shadow: 0 0 5px #ef4444, 0 0 10px #ef444450;
}
}
`}</style>
</div>
);
};
export default PortPanel;