feat: 端口管理页面添加网卡功能和服务器背板可视化

- 在端口管理页面添加网卡管理功能,与3D可视化页面同步
- 新增 ServerBackplanePanel 组件,按真实服务器背板布局展示网卡和端口
- 支持板载网卡、管理口、PCIe扩展插槽的可视化展示
- 添加网卡-端口层级展示,点击网卡可查看端口详情
- 更新 VirtualDeviceList 组件,集成服务器背板视图
This commit is contained in:
zhang1106
2026-01-30 17:53:05 +08:00
parent 5e3ac98bac
commit bc2f17890d
8 changed files with 2149 additions and 97 deletions
@@ -183,9 +183,13 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables,
return (
<Drawer
title={
<Space>
<CloudServerOutlined style={{ color: designTokens.colors.primary }} />
<span>设备详情 - {device.name}</span>
<Space style={{ maxWidth: '280px', overflow: 'hidden' }}>
<CloudServerOutlined style={{ color: designTokens.colors.primary, flexShrink: 0 }} />
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>设备详情 - {device.name}</span>
</Space>
}
placement="right"
+517
View File
@@ -0,0 +1,517 @@
import React, { useState } from 'react';
import { Tooltip, Badge, Divider, Pagination } from 'antd';
import { LinkOutlined, SwapRightOutlined, AimOutlined, NodeIndexOutlined } from '@ant-design/icons';
const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onPortClick, compact = false }) => {
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>
</>
)}
{/* 空闲端口提示 */}
{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';
const cable = findPortCable(port);
return (
<Tooltip
key={port.portId}
title={renderPortTooltip(port)}
placement="top"
color="#1e293b"
overlayStyle={{
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}
>
<div
onClick={() => isClickable && onPortClick(port)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '4px',
cursor: isClickable ? 'pointer' : 'not-allowed',
transition: 'all 0.2s ease',
position: 'relative',
minWidth: '0'
}}
>
{/* LED 指示灯 - 在端口上方 */}
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: statusColor,
boxShadow: `0 0 6px ${statusColor}, 0 0 12px ${statusColor}50`,
marginBottom: '4px',
animation: port.status === 'fault' ? 'pulse 1.5s infinite' : 'none'
}} />
{/* 端口主体 - 矩形样式 */}
<div style={{
width: '100%',
aspectRatio: '1 / 1.2',
background: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
border: `2px solid ${statusColor}`,
borderRadius: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)`
}}>
{/* 端口内部图标 */}
<div style={{
fontSize: '10px',
color: statusColor,
opacity: 0.8
}}>
{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: '9px',
fontWeight: 500,
color: 'rgba(255, 255, 255, 0.7)',
textAlign: 'center',
marginTop: '3px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%'
}}>
{getPortDisplayName(port.portName)}
</div>
</div>
</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;
@@ -0,0 +1,590 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Badge, Space, Typography, Spin, Empty, Button, Tooltip, Tag, Modal } from 'antd';
import {
CloudServerOutlined,
PlusOutlined,
ReloadOutlined,
ApiOutlined,
ThunderboltOutlined,
DesktopOutlined,
UsbOutlined,
MonitorOutlined,
SettingOutlined
} from '@ant-design/icons';
import PortPanel from './PortPanel';
import axios from 'axios';
const { Text } = Typography;
const designTokens = {
colors: {
primary: { main: '#667eea', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
metal: { light: '#9ca3af', DEFAULT: '#6b7280', dark: '#4b5563' },
slot: { empty: '#d1d5db', occupied: '#3b82f6' }
}
};
/**
* 服务器背板可视化组件
* 按照真实服务器背板布局展示网卡和端口
*
* @param {string} deviceId - 设备ID
* @param {string} deviceName - 设备名称
* @param {Object[]} cables - 接线列表
* @param {Object[]} allDevices - 所有设备列表
* @param {Function} onPortClick - 端口点击回调
* @param {Function} onManageNetworkCards - 网卡管理回调
*/
const ServerBackplanePanel = ({
deviceId,
deviceName,
cables,
allDevices,
onPortClick,
onManageNetworkCards
}) => {
const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedSlot, setSelectedSlot] = useState(null);
// 获取网卡及端口数据
const fetchData = useCallback(async () => {
if (!deviceId) return;
try {
setLoading(true);
const response = await axios.get(`/api/network-cards/device/${deviceId}/with-ports`);
const cardsData = response.data || [];
setCards(cardsData);
} catch (error) {
console.error('获取网卡数据失败:', error);
setCards([]);
} finally {
setLoading(false);
}
}, [deviceId]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 按类型和槽位号对网卡进行分类
const categorizeCards = () => {
const onboard = []; // 板载网卡
const management = []; // 管理口
const expansionSlots = []; // 扩展插槽
cards.forEach(card => {
const slotNum = card.slotNumber;
const name = (card.name || '').toLowerCase();
// 判断网卡类型
if (name.includes('idrac') || name.includes('ilo') || name.includes('bmc') || name.includes('mgmt') || name.includes('管理')) {
management.push({ ...card, type: 'management' });
} else if (slotNum === 0 || name.includes('onboard') || name.includes('板载') || name.includes('内置')) {
onboard.push({ ...card, type: 'onboard' });
} else {
expansionSlots.push({ ...card, type: 'expansion', slotIndex: slotNum });
}
});
// 按槽位号排序扩展插槽
expansionSlots.sort((a, b) => (a.slotNumber || 0) - (b.slotNumber || 0));
return { onboard, management, expansionSlots };
};
const { onboard, management, expansionSlots } = categorizeCards();
// 渲染管理口区域(左侧)
const renderManagementArea = () => {
const mgmtCard = management[0];
return (
<div
style={{
width: '80px',
background: 'linear-gradient(180deg, #374151 0%, #1f2937 100%)',
borderRadius: '4px',
padding: '8px 4px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
border: '2px solid #4b5563',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)'
}}
>
<div style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600 }}>MGMT</div>
{mgmtCard ? (
<div
onClick={() => setSelectedSlot(mgmtCard)}
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
borderRadius: '4px',
border: `2px solid ${mgmtCard.ports?.length > 0 ? designTokens.colors.success : '#6b7280'}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
}}
>
<SettingOutlined style={{ fontSize: 16, color: '#10b981' }} />
<span style={{ fontSize: '9px', color: '#9ca3af', marginTop: 2 }}>
{mgmtCard.ports?.length || 0}
</span>
</div>
) : (
<div
style={{
width: '48px',
height: '48px',
background: '#374151',
borderRadius: '4px',
border: '2px dashed #6b7280',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<PlusOutlined style={{ fontSize: 14, color: '#6b7280' }} />
</div>
)}
{/* 其他接口占位 */}
<div style={{ width: '48px', height: '24px', background: '#1f2937', borderRadius: '2px', border: '1px solid #4b5563' }}>
<Text style={{ fontSize: '8px', color: '#6b7280', display: 'block', textAlign: 'center', lineHeight: '22px' }}>VGA</Text>
</div>
<div style={{ width: '48px', height: '16px', background: '#1f2937', borderRadius: '2px', border: '1px solid #4b5563' }}>
<Text style={{ fontSize: '8px', color: '#6b7280', display: 'block', textAlign: 'center', lineHeight: '14px' }}>USB</Text>
</div>
</div>
);
};
// 渲染板载网卡区域
const renderOnboardArea = () => {
const onboardCard = onboard[0];
return (
<div
style={{
flex: 1,
background: 'linear-gradient(180deg, #4b5563 0%, #374151 100%)',
borderRadius: '4px',
padding: '12px',
border: '2px solid #6b7280',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>板载网卡 (Onboard)</Text>
{onboardCard && (
<Badge
count={onboardCard.ports?.length || 0}
style={{ backgroundColor: designTokens.colors.primary.main }}
/>
)}
</div>
{onboardCard ? (
<div
onClick={() => setSelectedSlot(onboardCard)}
style={{
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
borderRadius: '6px',
padding: '12px',
border: `2px solid ${onboardCard.ports?.length > 0 ? designTokens.colors.primary.main : '#6b7280'}`,
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
{/* 4个RJ45端口布局 */}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
{[0, 1, 2, 3].map((idx) => {
const port = onboardCard.ports?.[idx];
const hasPort = !!port;
const isOccupied = hasPort && port.status === 'occupied';
return (
<Tooltip key={idx} title={hasPort ? `${port.portName} - ${port.status}` : '未配置'}>
<div
style={{
width: '40px',
height: '32px',
background: hasPort
? 'linear-gradient(180deg, #374151 0%, #1f2937 100%)'
: '#374151',
borderRadius: '4px',
border: `2px solid ${hasPort
? (isOccupied ? designTokens.colors.success : designTokens.colors.metal.light)
: '#4b5563'
}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative'
}}
>
{/* LED指示灯 */}
<div
style={{
width: '4px',
height: '4px',
borderRadius: '50%',
background: hasPort
? (isOccupied ? '#10b981' : '#6b7280')
: '#374151',
position: 'absolute',
top: '2px',
right: '2px',
boxShadow: isOccupied ? '0 0 4px #10b981' : 'none'
}}
/>
<span style={{ fontSize: '8px', color: '#9ca3af' }}></span>
<span style={{ fontSize: '7px', color: '#6b7280', marginTop: '1px' }}>
{hasPort ? idx + 1 : '-'}
</span>
</div>
</Tooltip>
);
})}
</div>
</div>
) : (
<div
onClick={onManageNetworkCards}
style={{
background: '#374151',
borderRadius: '6px',
padding: '20px',
border: '2px dashed #6b7280',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
gap: '8px'
}}
>
<PlusOutlined style={{ fontSize: 20, color: '#6b7280' }} />
<Text style={{ fontSize: '11px', color: '#9ca3af' }}>添加板载网卡</Text>
</div>
)}
</div>
);
};
// 渲染扩展插槽区域
const renderExpansionSlots = () => {
// 标准2U服务器通常有4-8个PCIe插槽
const totalSlots = 6;
const slots = [];
for (let i = 1; i <= totalSlots; i++) {
const card = expansionSlots.find(c => c.slotNumber === i);
slots.push({ slotNumber: i, card });
}
return (
<div
style={{
flex: 1.5,
background: 'linear-gradient(180deg, #4b5563 0%, #374151 100%)',
borderRadius: '4px',
padding: '12px',
border: '2px solid #6b7280',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.1)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<Text style={{ fontSize: '11px', color: '#d1d5db', fontWeight: 600 }}>PCIe 扩展插槽</Text>
<Space size={4}>
<Badge count={expansionSlots.length} style={{ backgroundColor: designTokens.colors.primary.main }} />
<Text style={{ fontSize: '10px', color: '#9ca3af' }}>/{totalSlots}</Text>
</Space>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{slots.map(({ slotNumber, card }) => (
<Tooltip
key={slotNumber}
title={card ? `${card.name} (${card.ports?.length || 0}口)` : `插槽 ${slotNumber} (空闲)`}
>
<div
onClick={() => card && setSelectedSlot(card)}
style={{
width: '70px',
height: '90px',
background: card
? 'linear-gradient(145deg, #1f2937 0%, #111827 100%)'
: '#374151',
borderRadius: '4px',
border: `2px solid ${card ? designTokens.colors.slot.occupied : designTokens.colors.slot.empty}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px',
cursor: card ? 'pointer' : 'default',
transition: 'all 0.2s',
boxShadow: card ? '0 2px 8px rgba(59, 130, 246, 0.3)' : 'none'
}}
>
<Text style={{ fontSize: '9px', color: '#6b7280', fontWeight: 600 }}>Slot {slotNumber}</Text>
{card ? (
<>
<CloudServerOutlined style={{ fontSize: 20, color: '#3b82f6' }} />
<div style={{ display: 'flex', gap: '2px', flexWrap: 'wrap', justifyContent: 'center' }}>
{card.ports?.slice(0, 4).map((port, idx) => (
<div
key={idx}
style={{
width: '8px',
height: '8px',
borderRadius: '1px',
background: port.status === 'occupied' ? '#10b981' : '#6b7280',
boxShadow: port.status === 'occupied' ? '0 0 2px #10b981' : 'none'
}}
/>
))}
{card.ports?.length > 4 && (
<Text style={{ fontSize: '8px', color: '#9ca3af' }}>+{card.ports.length - 4}</Text>
)}
</div>
<Text style={{ fontSize: '8px', color: '#9ca3af' }}>{card.ports?.length || 0}</Text>
</>
) : (
<>
<div style={{ width: '40px', height: '40px', border: '2px dashed #4b5563', borderRadius: '4px' }} />
<Text style={{ fontSize: '8px', color: '#6b7280' }}>空闲</Text>
</>
)}
</div>
</Tooltip>
))}
</div>
</div>
);
};
// 渲染电源区域(右侧)
const renderPowerArea = () => {
return (
<div
style={{
width: '100px',
background: 'linear-gradient(180deg, #374151 0%, #1f2937 100%)',
borderRadius: '4px',
padding: '8px',
border: '2px solid #4b5563',
display: 'flex',
flexDirection: 'column',
gap: '8px'
}}
>
<Text style={{ fontSize: '10px', color: '#9ca3af', fontWeight: 600, textAlign: 'center' }}>电源</Text>
{[1, 2].map((psu) => (
<div
key={psu}
style={{
flex: 1,
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
borderRadius: '4px',
border: '2px solid #10b981',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '4px'
}}
>
<ThunderboltOutlined style={{ fontSize: 20, color: '#10b981' }} />
<Text style={{ fontSize: '9px', color: '#10b981' }}>PSU {psu}</Text>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: '#10b981',
boxShadow: '0 0 6px #10b981'
}}
/>
</div>
))}
</div>
);
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" tip="加载背板数据中..." />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* 工具栏 */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
background: '#f8fafc',
borderRadius: '8px',
border: '1px solid #e2e8f0'
}}
>
<Space>
<Badge count={cards.filter(c => !c.isUngrouped).length} style={{ backgroundColor: designTokens.colors.primary.main }} />
<Text type="secondary" style={{ fontSize: '13px' }}>个网卡</Text>
<Badge
count={cards.reduce((acc, card) => acc + (card.ports?.length || 0), 0)}
style={{ backgroundColor: '#667eea' }}
/>
<Text type="secondary" style={{ fontSize: '13px' }}>个端口</Text>
</Space>
<Space>
<Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>
刷新
</Button>
<Button
type="primary"
size="small"
icon={<CloudServerOutlined />}
onClick={onManageNetworkCards}
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
>
网卡管理
</Button>
</Space>
</div>
{/* 服务器背板主体 */}
<div
style={{
background: 'linear-gradient(180deg, #6b7280 0%, #4b5563 100%)',
borderRadius: '8px',
padding: '16px',
border: '3px solid #374151',
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.3)'
}}
>
{/* 服务器标识 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '12px',
padding: '6px 12px',
background: 'rgba(0,0,0,0.3)',
borderRadius: '4px'
}}
>
<DesktopOutlined style={{ fontSize: 14, color: '#9ca3af', marginRight: 8 }} />
<Text style={{ fontSize: '12px', color: '#d1d5db', fontWeight: 600 }}>
{deviceName || '服务器'} - 背板视图
</Text>
</div>
{/* 背板布局 */}
<div style={{ display: 'flex', gap: '12px', alignItems: 'stretch' }}>
{/* 左侧:管理口区域 */}
{renderManagementArea()}
{/* 中间左:板载网卡 */}
{renderOnboardArea()}
{/* 中间:扩展插槽 */}
{renderExpansionSlots()}
{/* 右侧:电源 */}
{renderPowerArea()}
</div>
</div>
{/* 选中插槽的端口详情模态框 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CloudServerOutlined style={{ color: '#667eea' }} />
<span>
{selectedSlot?.name}
{selectedSlot?.slotNumber > 0 && ` (Slot ${selectedSlot.slotNumber})`}
</span>
</div>
}
open={!!selectedSlot}
onCancel={() => setSelectedSlot(null)}
footer={null}
width={700}
destroyOnClose
>
{selectedSlot && (
<div>
<div style={{ marginBottom: '16px', padding: '12px', background: '#f8fafc', borderRadius: '8px' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">类型: {selectedSlot.type === 'onboard' ? '板载网卡' : selectedSlot.type === 'management' ? '管理口' : '扩展网卡'}</Text>
{selectedSlot.description && <Text type="secondary">描述: {selectedSlot.description}</Text>}
<div>
<Text type="secondary">端口统计: </Text>
<Space size={8}>
<Tag color="success">空闲: {selectedSlot.stats?.free || 0}</Tag>
<Tag color="processing">占用: {selectedSlot.stats?.occupied || 0}</Tag>
{selectedSlot.stats?.fault > 0 && <Tag color="error">故障: {selectedSlot.stats.fault}</Tag>}
<Tag color="blue">总计: {selectedSlot.ports?.length || 0}</Tag>
</Space>
</div>
</Space>
</div>
{selectedSlot.ports && selectedSlot.ports.length > 0 ? (
<PortPanel
ports={selectedSlot.ports}
deviceName={deviceName}
deviceId={deviceId}
cables={cables}
devices={allDevices}
onPortClick={onPortClick}
compact={true}
/>
) : (
<Empty
description={
<span>
该网卡暂无端口
<br />
<Button
type="link"
icon={<PlusOutlined />}
onClick={onManageNetworkCards}
style={{ padding: 0, marginTop: 8 }}
>
前往网卡管理添加端口
</Button>
</span>
}
/>
)}
</div>
)}
</Modal>
</div>
);
};
export default ServerBackplanePanel;
@@ -0,0 +1,345 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Button, Empty, Spin, Badge, Typography, Space, Checkbox, Tooltip } from 'antd';
import { DownOutlined, UpOutlined, EyeOutlined, EyeInvisibleOutlined, PlusOutlined, CloudServerOutlined } from '@ant-design/icons';
import ServerBackplanePanel from './ServerBackplanePanel';
const { Text } = Typography;
/**
* 虚拟设备列表组件
* 用于优化大量设备面板的渲染性能
*
* @param {Object[]} devices - 设备列表
* @param {Object} groupedPorts - 按设备分组的端口数据
* @param {Object[]} cables - 接线列表
* @param {Object[]} allDevices - 所有设备列表(用于查找设备信息)
* @param {Function} onPortClick - 端口点击回调
* @param {Function} onAddPort - 添加端口回调 (device) => void
* @param {Function} onManageNetworkCards - 网卡管理回调 (device) => void
* @param {number} initialVisibleCount - 初始显示数量
* @param {number} loadMoreCount - 每次加载更多数量
*/
const VirtualDeviceList = ({
devices,
groupedPorts,
cables,
allDevices,
onPortClick,
onAddPort,
onManageNetworkCards,
initialVisibleCount = 5,
loadMoreCount = 5
}) => {
const [visibleCount, setVisibleCount] = useState(initialVisibleCount);
const [loading, setLoading] = useState(false);
const [expandedDevices, setExpandedDevices] = useState({});
const [showAll, setShowAll] = useState(false);
const containerRef = useRef(null);
const observerRef = useRef(null);
// 初始化展开状态
useEffect(() => {
const initialExpanded = {};
devices.slice(0, initialVisibleCount).forEach((device, index) => {
initialExpanded[device.deviceId] = index < 3; // 前3个默认展开
});
setExpandedDevices(initialExpanded);
}, [devices, initialVisibleCount]);
// 无限滚动观察器
useEffect(() => {
if (showAll) return;
const options = {
root: null,
rootMargin: '100px',
threshold: 0.1
};
observerRef.current = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !loading && visibleCount < devices.length) {
loadMore();
}
});
}, options);
const loadMoreTrigger = document.getElementById('load-more-trigger');
if (loadMoreTrigger) {
observerRef.current.observe(loadMoreTrigger);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [visibleCount, devices.length, loading, showAll]);
const loadMore = useCallback(() => {
if (loading || visibleCount >= devices.length) return;
setLoading(true);
// 模拟异步加载,实际可以直接同步更新
setTimeout(() => {
setVisibleCount(prev => Math.min(prev + loadMoreCount, devices.length));
setLoading(false);
}, 100);
}, [loading, visibleCount, devices.length, loadMoreCount]);
const handleShowAll = useCallback(() => {
// 先显示所有设备
setVisibleCount(devices.length);
// 展开所有设备
const allExpanded = {};
devices.forEach(device => {
allExpanded[device.deviceId] = true;
});
setExpandedDevices(allExpanded);
setShowAll(true);
}, [devices]);
const handleCollapseAll = useCallback(() => {
// 收起所有面板(折叠所有设备),但保持当前显示的设备数量
const allCollapsed = {};
devices.forEach(device => {
allCollapsed[device.deviceId] = false;
});
setExpandedDevices(allCollapsed);
setShowAll(false);
}, [devices]);
const toggleDeviceExpand = (deviceId) => {
setExpandedDevices(prev => ({
...prev,
[deviceId]: !prev[deviceId]
}));
};
const visibleDevices = devices.slice(0, visibleCount);
const hasMore = visibleCount < devices.length;
if (devices.length === 0) {
return (
<Empty
description="暂无设备数据"
style={{ padding: '60px 0' }}
/>
);
}
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 控制栏 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
background: '#f8fafc',
borderRadius: '8px',
border: '1px solid #e2e8f0'
}}>
<Space align="center">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Text strong style={{ fontSize: '14px' }}>设备列表</Text>
<Badge
count={devices.length}
style={{ backgroundColor: '#667eea' }}
/>
</div>
<Text type="secondary" style={{ fontSize: '12px' }}>
显示 {visibleDevices.length} / {devices.length}
</Text>
</Space>
<Space>
<Button
size="small"
icon={showAll ? <UpOutlined /> : <DownOutlined />}
onClick={showAll ? handleCollapseAll : handleShowAll}
>
{showAll ? '收起全部' : '展开全部'}
</Button>
</Space>
</div>
{/* 设备面板列表 */}
{visibleDevices.map((device) => {
const deviceId = device.deviceId;
const data = groupedPorts[deviceId] || { device, ports: [] };
const isExpanded = expandedDevices[deviceId];
const portCount = data.ports?.length || 0;
const occupiedCount = data.ports?.filter(p => p.status === 'occupied').length || 0;
return (
<div
key={deviceId}
style={{
border: '1px solid #e2e8f0',
borderRadius: '12px',
overflow: 'hidden',
background: '#fff',
transition: 'all 0.3s ease'
}}
>
{/* 设备标题栏 */}
<div
onClick={() => toggleDeviceExpand(deviceId)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
background: isExpanded ? '#f1f5f9' : '#fff',
cursor: 'pointer',
borderBottom: isExpanded ? '1px solid #e2e8f0' : 'none',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
if (!isExpanded) {
e.currentTarget.style.background = '#fff';
}
}}
>
<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'
}}>
{device.type?.toLowerCase()?.includes('server') ? '🖥️' :
device.type?.toLowerCase()?.includes('switch') ? '🔀' :
device.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '15px', color: '#1e293b' }}>
{device.name || '未知设备'}
</div>
<div style={{ fontSize: '12px', color: '#64748b', marginTop: '2px' }}>
{device.deviceId} · {device.type || '未知类型'}
</div>
</div>
</div>
<Space size="middle">
<Space size="small">
<Badge
count={occupiedCount}
style={{ backgroundColor: '#3b82f6' }}
overflowCount={999}
/>
<Text type="secondary" style={{ fontSize: '12px' }}>
已用
</Text>
<Badge
count={portCount}
style={{ backgroundColor: '#10b981' }}
overflowCount={999}
/>
<Text type="secondary" style={{ fontSize: '12px' }}>
端口
</Text>
</Space>
{/* 网卡管理按钮 - 只有服务器显示 */}
{device.type?.toLowerCase()?.includes('server') && (
<Button
type="primary"
size="small"
icon={<CloudServerOutlined />}
onClick={(e) => {
e.stopPropagation(); // 防止触发折叠
onManageNetworkCards && onManageNetworkCards(device);
}}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none'
}}
>
网卡管理
</Button>
)}
{/* 添加端口按钮 */}
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation(); // 防止触发折叠
onAddPort && onAddPort(device);
}}
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
border: 'none'
}}
>
添加端口
</Button>
<Button
type="text"
size="small"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
style={{ color: '#64748b' }}
/>
</Space>
</div>
{/* 面板内容 - 可折叠 */}
{isExpanded && (
<div style={{ padding: '16px' }}>
<ServerBackplanePanel
deviceId={deviceId}
deviceName={device.name}
cables={cables}
allDevices={allDevices}
onPortClick={onPortClick}
onManageNetworkCards={() => onManageNetworkCards && onManageNetworkCards(device)}
/>
</div>
)}
</div>
);
})}
{/* 加载更多触发器 */}
{hasMore && !showAll && (
<div
id="load-more-trigger"
style={{
textAlign: 'center',
padding: '20px',
color: '#64748b'
}}
>
{loading ? (
<Spin size="small" tip="加载更多设备..." />
) : (
<Text type="secondary">
向下滚动加载更多 ({devices.length - visibleCount} 个设备)
</Text>
)}
</div>
)}
{/* 已显示全部提示 */}
{!hasMore && devices.length > initialVisibleCount && (
<div style={{ textAlign: 'center', padding: '20px', color: '#94a3b8' }}>
<Text type="secondary">已显示全部 {devices.length} 个设备</Text>
</div>
)}
</div>
);
};
export default VirtualDeviceList;
+211 -40
View File
@@ -73,16 +73,17 @@ function CableManagement() {
try {
setLoading(true);
const params = {};
if (filters.switchDeviceId) params.sourceDeviceId = filters.switchDeviceId;
if (filters.status !== 'all') params.status = filters.status;
if (filters.cableType !== 'all') params.cableType = filters.cableType;
const response = await axios.get('/api/cables', { params });
setCables(response.data.cables || []);
const cablesData = response.data.cables || [];
setCables(cablesData);
const grouped = {};
response.data.cables.forEach(cable => {
cablesData.forEach(cable => {
const switchId = cable.sourceDeviceId;
if (!grouped[switchId]) {
grouped[switchId] = {
@@ -93,22 +94,36 @@ function CableManagement() {
grouped[switchId].cables.push(cable);
});
setGroupedCables(grouped);
// 自动为每个交换机加载端口数据
const switchIds = Object.keys(grouped);
for (const switchId of switchIds) {
if (!devicePorts[switchId]) {
try {
const portsResponse = await axios.get(`/api/device-ports/device/${switchId}`);
setDevicePorts(prev => ({ ...prev, [switchId]: portsResponse.data || [] }));
} catch (error) {
console.error(`获取交换机 ${switchId} 端口失败:`, error);
}
}
}
} catch (error) {
message.error('获取接线列表失败');
console.error('获取接线列表失败:', error);
} finally {
setLoading(false);
}
}, [filters]);
}, [filters, devicePorts]);
const fetchDevices = useCallback(async () => {
try {
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
const response = await axios.get('/api/devices', { params: { pageSize: 100 } });
const allDevices = response.data.devices || [];
const switches = allDevices.filter(device => device.type === 'switch');
setDevices(allDevices);
setSwitchDevices(switches);
} catch (error) {
message.error('获取设备列表失败');
console.error('获取设备列表失败:', error);
}
}, []);
@@ -189,27 +204,87 @@ function CableManagement() {
}
};
const [conflictModalVisible, setConflictModalVisible] = useState(false);
const [conflictInfo, setConflictInfo] = useState(null);
const [pendingSubmitValues, setPendingSubmitValues] = useState(null);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// 如果是编辑模式,直接提交
if (editingCable) {
await axios.put(`/api/cables/${editingCable.cableId}`, values);
message.success('更新成功');
} else {
setModalVisible(false);
form.resetFields();
fetchCables();
return;
}
// 创建模式:先检查冲突
try {
const checkResponse = await axios.post('/api/cables/check-conflict', {
sourceDeviceId: values.sourceDeviceId,
sourcePort: values.sourcePort,
targetDeviceId: values.targetDeviceId,
targetPort: values.targetPort
});
if (checkResponse.data.hasConflict) {
setConflictInfo(checkResponse.data.conflicts);
setPendingSubmitValues(values);
setConflictModalVisible(true);
return;
}
// 无冲突,直接创建
await axios.post('/api/cables', values);
message.success('创建成功');
setModalVisible(false);
form.resetFields();
fetchCables();
} catch (error) {
if (error.response?.status === 409) {
// 冲突错误
setConflictInfo([{
type: 'unknown',
existingCable: error.response.data.existingCable
}]);
setPendingSubmitValues(values);
setConflictModalVisible(true);
} else {
throw error;
}
}
setModalVisible(false);
form.resetFields();
fetchCables();
} catch (error) {
message.error(editingCable ? '更新失败' : '创建失败');
console.error('提交失败:', error);
}
};
const handleForceSubmit = async () => {
try {
if (!pendingSubmitValues) return;
await axios.post('/api/cables', {
...pendingSubmitValues,
force: true
});
message.success('接线已强制接管并创建成功');
setConflictModalVisible(false);
setModalVisible(false);
form.resetFields();
setPendingSubmitValues(null);
setConflictInfo(null);
fetchCables();
} catch (error) {
message.error('强制接管失败');
console.error('强制接管失败:', error);
}
};
const handleImport = () => {
setImportModalVisible(true);
setImportPreview([]);
@@ -562,9 +637,12 @@ function CableManagement() {
onChange={(value) => setFilters(prev => ({ ...prev, switchDeviceId: value }))}
allowClear
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
filterOption={(input, option) => {
const device = switchDevices.find(d => d.deviceId === option.value);
if (!device) return false;
const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0;
}}
>
{switchDevices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
@@ -572,7 +650,7 @@ function CableManagement() {
</Option>
))}
</Select>
<Select
placeholder="线缆类型"
style={{ width: 120 }}
@@ -762,12 +840,15 @@ function CableManagement() {
label="源设备"
rules={[{ required: true, message: '请选择源设备' }]}
>
<Select
placeholder="请选择源设备"
<Select
placeholder="请选择源设备"
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
filterOption={(input, option) => {
const device = switchDevices.find(d => d.deviceId === option.value);
if (!device) return false;
const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0;
}}
onChange={(value) => {
fetchDevicePorts(value);
form.setFieldsValue({ sourcePort: undefined });
@@ -780,18 +861,22 @@ function CableManagement() {
))}
</Select>
</Form.Item>
<Form.Item
name="sourcePort"
label="源设备端口"
rules={[{ required: true, message: '请选择源设备端口' }]}
>
<Select
placeholder="请先选择源设备"
<Select
placeholder="请先选择源设备"
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
filterOption={(input, option) => {
const ports = devicePorts[form.getFieldValue('sourceDeviceId')] || [];
const port = ports.find(p => p.portName === option.value);
if (!port) return false;
const searchText = `${port.portName} ${port.portType} ${port.portSpeed}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0;
}}
disabled={!form.getFieldValue('sourceDeviceId')}
>
{(devicePorts[form.getFieldValue('sourceDeviceId')] || []).map(port => (
@@ -801,18 +886,21 @@ function CableManagement() {
))}
</Select>
</Form.Item>
<Form.Item
name="targetDeviceId"
label="目标设备"
rules={[{ required: true, message: '请选择目标设备' }]}
>
<Select
<Select
placeholder="请选择目标设备"
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
filterOption={(input, option) => {
const device = devices.find(d => d.deviceId === option.value);
if (!device) return false;
const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0;
}}
onChange={(value) => {
fetchDevicePorts(value);
form.setFieldsValue({ targetPort: undefined });
@@ -825,18 +913,22 @@ function CableManagement() {
))}
</Select>
</Form.Item>
<Form.Item
name="targetPort"
label="目标设备端口"
rules={[{ required: true, message: '请选择目标设备端口' }]}
>
<Select
placeholder="请先选择目标设备"
<Select
placeholder="请先选择目标设备"
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
filterOption={(input, option) => {
const ports = devicePorts[form.getFieldValue('targetDeviceId')] || [];
const port = ports.find(p => p.portName === option.value);
if (!port) return false;
const searchText = `${port.portName} ${port.portType} ${port.portSpeed}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0;
}}
disabled={!form.getFieldValue('targetDeviceId')}
>
{(devicePorts[form.getFieldValue('targetDeviceId')] || []).map(port => (
@@ -846,7 +938,7 @@ function CableManagement() {
))}
</Select>
</Form.Item>
<Form.Item
name="cableType"
label="线缆类型"
@@ -1052,6 +1144,85 @@ function CableManagement() {
)}
</div>
</Modal>
{/* 冲突提示弹窗 */}
<Modal
title="端口冲突警告"
open={conflictModalVisible}
onCancel={() => {
setConflictModalVisible(false);
setConflictInfo(null);
setPendingSubmitValues(null);
}}
footer={[
<Button
key="cancel"
onClick={() => {
setConflictModalVisible(false);
setConflictInfo(null);
setPendingSubmitValues(null);
}}
>
取消
</Button>,
<Button
key="force"
type="primary"
danger
onClick={handleForceSubmit}
>
强制接管
</Button>
]}
width={600}
>
{conflictInfo && (
<div>
<div style={{ marginBottom: 16, color: '#ef4444', fontWeight: 500 }}>
<span style={{ fontSize: 20, marginRight: 8 }}></span>
检测到端口冲突以下端口已被占用
</div>
{conflictInfo.map((conflict, index) => (
<Card
key={index}
size="small"
style={{ marginBottom: 12, background: '#fef2f2', border: '1px solid #fecaca' }}
>
<div style={{ marginBottom: 8 }}>
<Tag color="error">
{conflict.type === 'source' ? '源端口' : conflict.type === 'target' ? '目标端口' : '端口'}
</Tag>
<span style={{ fontWeight: 500, marginLeft: 8 }}>{conflict.port}</span>
</div>
{conflict.existingCable && (
<div style={{ fontSize: 13, color: '#666' }}>
<div>当前连接</div>
<div style={{ marginTop: 4, paddingLeft: 12 }}>
<div>
源设备{conflict.existingCable.sourceDevice?.name || conflict.existingCable.sourceDeviceId}
({conflict.existingCable.sourcePort})
</div>
<div style={{ marginTop: 2 }}>
目标设备{conflict.existingCable.targetDevice?.name || conflict.existingCable.targetDeviceId}
({conflict.existingCable.targetPort})
</div>
<div style={{ marginTop: 2 }}>
线缆类型{getCableTypeTag(conflict.existingCable.cableType)}
</div>
</div>
</div>
)}
</Card>
))}
<div style={{ marginTop: 16, padding: 12, background: '#fff7ed', borderRadius: 6, border: '1px solid #fed7aa' }}>
<span style={{ color: '#ea580c' }}>💡</span>
<span style={{ marginLeft: 8, color: '#9a3412' }}>
点击"强制接管"将断开原有连接并创建新接线此操作不可恢复
</span>
</div>
</div>
)}
</Modal>
</div>
);
}
+266 -35
View File
@@ -1,9 +1,14 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, InputNumber, Collapse, Empty, Spin, Upload, Progress, Checkbox } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon } from '@ant-design/icons';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, InputNumber, Collapse, Empty, Spin, Upload, Progress, Checkbox, Tabs, Badge, List, Typography } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon, AppstoreOutlined, UnorderedListOutlined, FilterOutlined, EyeOutlined, CompressOutlined, CloudServerOutlined } from '@ant-design/icons';
import axios from 'axios';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
import PortPanel from '../components/PortPanel';
import VirtualDeviceList from '../components/VirtualDeviceList';
import NetworkCardPanel from '../components/NetworkCardPanel';
import NetworkCardCreateModal from '../components/NetworkCardCreateModal';
import PortCreateModal from '../components/PortCreateModal';
const { Option } = Select;
const { Panel } = Collapse;
@@ -48,6 +53,7 @@ const designTokens = {
function PortManagement() {
const [ports, setPorts] = useState([]);
const [devices, setDevices] = useState([]);
const [cables, setCables] = useState([]);
const [groupedPorts, setGroupedPorts] = useState({});
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState({
@@ -59,7 +65,7 @@ function PortManagement() {
const [modalVisible, setModalVisible] = useState(false);
const [editingPort, setEditingPort] = useState(null);
const [form] = Form.useForm();
const [importModalVisible, setImportModalVisible] = useState(false);
const [importFileList, setImportFileList] = useState([]);
const [importPreview, setImportPreview] = useState([]);
@@ -68,10 +74,30 @@ function PortManagement() {
const [skipExisting, setSkipExisting] = useState(false);
const [updateExisting, setUpdateExisting] = useState(false);
// 视图模式:list 或 panel
const [viewMode, setViewMode] = useState('list');
// 面板视图优化状态
const [panelFilters, setPanelFilters] = useState({
deviceType: 'all',
searchText: '',
showOnlyOccupied: false
});
const [visibleDeviceCount, setVisibleDeviceCount] = useState(10);
const [expandedDevices, setExpandedDevices] = useState({});
// 网卡管理相关状态
const [networkCardModalVisible, setNetworkCardModalVisible] = useState(false);
const [portCreateModalVisible, setPortCreateModalVisible] = useState(false);
const [selectedDeviceForNic, setSelectedDeviceForNic] = useState(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const fetchPorts = useCallback(async () => {
try {
setLoading(true);
const params = {};
const params = {
pageSize: 1000 // 获取所有端口,不分页
};
if (filters.deviceId) params.deviceId = filters.deviceId;
if (filters.status !== 'all') params.status = filters.status;
@@ -90,17 +116,28 @@ function PortManagement() {
const fetchDevices = useCallback(async () => {
try {
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
const response = await axios.get('/api/devices', { params: { pageSize: 100 } });
setDevices(response.data.devices || response.data || []);
} catch (error) {
message.error('获取设备列表失败');
console.error('获取设备列表失败:', error);
}
}, []);
const fetchCables = useCallback(async () => {
try {
const response = await axios.get('/api/cables');
setCables(response.data.cables || response.data || []);
} catch (error) {
console.error('获取接线列表失败:', error);
}
}, []);
useEffect(() => {
fetchPorts();
fetchDevices();
}, [fetchPorts, fetchDevices]);
fetchCables();
}, [fetchPorts, fetchDevices, fetchCables]);
useEffect(() => {
const grouped = {};
@@ -114,6 +151,25 @@ function PortManagement() {
}
grouped[deviceId].ports.push(port);
});
// 对每个设备的端口按名称升序排序
Object.keys(grouped).forEach(deviceId => {
grouped[deviceId].ports.sort((a, b) => {
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);
});
});
setGroupedPorts(grouped);
}, [ports, devices]);
@@ -136,6 +192,41 @@ function PortManagement() {
setModalVisible(true);
};
const handleAddPortForDevice = (device) => {
setEditingPort(null);
form.resetFields();
// 自动选中当前设备
form.setFieldsValue({
deviceId: device.deviceId
});
setModalVisible(true);
};
// 打开网卡管理模态框
const handleManageNetworkCards = (device) => {
setSelectedDeviceForNic(device);
setNetworkCardModalVisible(true);
};
// 打开添加网卡模态框
const handleAddNetworkCard = (device) => {
setSelectedDeviceForNic(device);
setPortCreateModalVisible(true);
};
// 网卡/端口创建成功回调
const handleNicSuccess = () => {
message.success('操作成功');
setRefreshTrigger(prev => prev + 1);
fetchPorts();
};
const handlePortSuccess = () => {
message.success('端口添加成功');
setRefreshTrigger(prev => prev + 1);
fetchPorts();
};
const handleEdit = (port) => {
setEditingPort(port);
form.setFieldsValue({
@@ -162,6 +253,21 @@ function PortManagement() {
}
};
// 解析端口名称范围,例如 "1/0/1-1/0/48" -> ["1/0/1", "1/0/2", ..., "1/0/48"]
const parsePortRange = (portName) => {
const rangeMatch = portName.match(/^(.*?)\/(\d+)-\1\/(\d+)$/);
if (rangeMatch) {
const prefix = rangeMatch[1];
const start = parseInt(rangeMatch[2]);
const end = parseInt(rangeMatch[3]);
if (start <= end && end - start < 100) { // 限制最多100个端口
return Array.from({ length: end - start + 1 }, (_, i) => `${prefix}/${start + i}`);
}
}
return [portName]; // 如果不是范围格式,返回原名称
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
@@ -170,8 +276,35 @@ function PortManagement() {
await axios.put(`/api/device-ports/${editingPort.portId}`, values);
message.success('更新成功');
} else {
await axios.post('/api/device-ports', values);
message.success('创建成功');
// 解析端口名称范围
const portNames = parsePortRange(values.portName);
if (portNames.length > 1) {
// 批量创建端口
const portsData = portNames.map((name, index) => ({
portId: `PORT-${Date.now()}-${index}`,
deviceId: values.deviceId,
portName: name,
portType: values.portType,
portSpeed: values.portSpeed,
status: values.status,
vlanId: values.vlanId,
description: values.description
}));
const response = await axios.post('/api/device-ports/batch', { ports: portsData });
const { success, failed } = response.data;
if (failed > 0) {
message.warning(`批量创建完成!成功 ${success} 个,失败 ${failed}`);
} else {
message.success(`成功创建 ${success} 个端口`);
}
} else {
// 单个创建
await axios.post('/api/device-ports', values);
message.success('创建成功');
}
}
setModalVisible(false);
@@ -480,9 +613,12 @@ function PortManagement() {
onChange={(value) => setFilters(prev => ({ ...prev, deviceId: value }))}
allowClear
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
filterOption={(input, option) => {
const device = devices.find(d => d.deviceId === option.value);
if (!device) return false;
const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0;
}}
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
@@ -490,7 +626,7 @@ function PortManagement() {
</Option>
))}
</Select>
<Select
placeholder="端口类型"
style={{ width: 120 }}
@@ -548,32 +684,51 @@ function PortManagement() {
</Space>
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button
type="primary"
icon={<PlusOutlined />}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
>
新增端口
</Button>
<Button
type="primary"
icon={<ImportOutlined />}
<Button
type="primary"
icon={<ImportOutlined />}
onClick={handleImport}
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
>
批量导入
</Button>
<Button icon={<ExportOutlined />}>
导出
</Button>
</Space>
<Space>
<Button.Group>
<Button
type={viewMode === 'list' ? 'primary' : 'default'}
icon={<UnorderedListOutlined />}
onClick={() => setViewMode('list')}
>
列表
</Button>
<Button
type={viewMode === 'panel' ? 'primary' : 'default'}
icon={<AppstoreOutlined />}
onClick={() => setViewMode('panel')}
>
面板
</Button>
</Button.Group>
</Space>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" tip="加载端口数据中..." />
@@ -582,7 +737,21 @@ function PortManagement() {
<div style={{ textAlign: 'center', padding: '60px' }}>
<Empty description="暂无端口数据" />
</div>
) : viewMode === 'panel' ? (
// 面板视图 - 使用虚拟滚动优化
<VirtualDeviceList
devices={Object.values(groupedPorts).map(g => g.device).filter(Boolean)}
groupedPorts={groupedPorts}
cables={cables}
allDevices={devices}
onPortClick={(port) => handleEdit(port)}
onAddPort={(device) => handleAddPortForDevice(device)}
onManageNetworkCards={(device) => handleManageNetworkCards(device)}
initialVisibleCount={5}
loadMoreCount={5}
/>
) : (
// 列表视图
<Collapse
defaultActiveKey={Object.keys(groupedPorts).slice(0, 5)}
style={{ background: '#f5f5f5' }}
@@ -593,7 +762,7 @@ function PortManagement() {
const freeCount = devicePorts.filter(p => p.status === 'free').length;
const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length;
const faultCount = devicePorts.filter(p => p.status === 'fault').length;
return (
<Panel
key={deviceId}
@@ -611,8 +780,8 @@ function PortManagement() {
color: '#fff',
fontSize: '18px'
}}>
{device?.type?.toLowerCase()?.includes('server') ? '🖥️' :
device?.type?.toLowerCase()?.includes('switch') ? '🔀' :
{device?.type?.toLowerCase()?.includes('server') ? '🖥️' :
device?.type?.toLowerCase()?.includes('switch') ? '🔀' :
device?.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'}
</div>
<div>
@@ -629,6 +798,21 @@ function PortManagement() {
<Tag color="processing">占用: {occupiedCount}</Tag>
<Tag color="error">故障: {faultCount}</Tag>
<Tag color="blue">总计: {devicePorts.length}</Tag>
{/* 网卡管理按钮 - 只有服务器显示 */}
{device?.type?.toLowerCase()?.includes('server') && (
<Button
type="primary"
size="small"
icon={<CloudServerOutlined />}
onClick={(e) => {
e.stopPropagation();
handleManageNetworkCards(device);
}}
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
>
网卡管理
</Button>
)}
</Space>
</div>
}
@@ -637,7 +821,12 @@ function PortManagement() {
columns={portColumns}
dataSource={devicePorts}
rowKey="portId"
pagination={false}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total} 个端口`,
pageSizeOptions: ['10', '20', '50', '100']
}}
size="small"
scroll={{ x: 1000 }}
/>
@@ -666,12 +855,15 @@ function PortManagement() {
label="设备"
rules={[{ required: true, message: '请选择设备' }]}
>
<Select
placeholder="请选择设备"
<Select
placeholder="请选择设备"
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
filterOption={(input, option) => {
const device = devices.find(d => d.deviceId === option.value);
if (!device) return false;
const searchText = `${device.name} ${device.deviceId}`.toLowerCase();
return searchText.indexOf(input.toLowerCase()) >= 0;
}}
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
@@ -685,8 +877,9 @@ function PortManagement() {
name="portName"
label="端口名称"
rules={[{ required: true, message: '请输入端口名称' }]}
extra={!editingPort && "支持批量添加,例如: 1/0/1-1/0/48 将创建 48 个端口"}
>
<Input placeholder="例如: eth0/1" />
<Input placeholder="例如: eth0/1 或 1/0/1-1/0/48" />
</Form.Item>
<Form.Item
@@ -896,8 +1089,8 @@ function PortManagement() {
<div style={{ textAlign: 'center', padding: '24px' }}>
<Spin size="large" tip="导入中..." />
<div style={{ marginTop: 16 }}>
<Progress
percent={Math.round((importProgress.current / importProgress.total) * 100)}
<Progress
percent={Math.round((importProgress.current / importProgress.total) * 100)}
status="active"
strokeColor={{
'0%': designTokens.colors.primary.main,
@@ -919,6 +1112,44 @@ function PortManagement() {
)}
</div>
</Modal>
{/* 网卡管理模态框 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CloudServerOutlined style={{ color: '#667eea' }} />
<span>网卡管理 - {selectedDeviceForNic?.name}</span>
</div>
}
open={networkCardModalVisible}
onCancel={() => {
setNetworkCardModalVisible(false);
setSelectedDeviceForNic(null);
}}
footer={null}
width={800}
destroyOnClose
>
{selectedDeviceForNic && (
<NetworkCardPanel
deviceId={selectedDeviceForNic.deviceId}
deviceName={selectedDeviceForNic.name}
onRefresh={fetchPorts}
refreshTrigger={refreshTrigger}
/>
)}
</Modal>
{/* 创建网卡模态框 */}
<NetworkCardCreateModal
device={selectedDeviceForNic}
visible={portCreateModalVisible}
onClose={() => {
setPortCreateModalVisible(false);
setSelectedDeviceForNic(null);
}}
onSuccess={handleNicSuccess}
/>
</div>
);
}