feat(网卡管理): 新增批量导入网卡功能及关联组件
refactor(端口管理): 优化端口批量导入逻辑,增加服务器端口网卡关联验证 feat(前端组件): 新增批量导入模态框、网卡导入模态框和端口导出模态框 style(设备管理): 优化设备表格加载性能,添加无限滚动功能 docs(.gitignore): 添加数据文件忽略规则
This commit is contained in:
@@ -45,3 +45,7 @@ lerna-debug.log*
|
|||||||
# Backup files (generated by maintenance scripts)
|
# Backup files (generated by maintenance scripts)
|
||||||
backend/backups/
|
backend/backups/
|
||||||
*.backup.json
|
*.backup.json
|
||||||
|
|
||||||
|
#数据文件
|
||||||
|
*.xlsx
|
||||||
|
*.csv
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取端口列表失败:', error);
|
console.error('获取端口列表失败:', error);
|
||||||
res.status(500).json({ error: error.message });
|
console.error('Error name:', error.name);
|
||||||
|
res.status(500).json({ error: error.message, errorType: error.name });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,6 +168,48 @@ router.post('/batch', async (req, res) => {
|
|||||||
throw new Error('缺少必填字段');
|
throw new Error('缺少必填字段');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const device = await Device.findByPk(portData.deviceId, { transaction });
|
||||||
|
if (!device) {
|
||||||
|
throw new Error(`设备 ${portData.deviceId} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isServer = device.type && device.type.toLowerCase().includes('server');
|
||||||
|
|
||||||
|
if (isServer) {
|
||||||
|
if (!portData.nicId && !portData.网卡名称) {
|
||||||
|
throw new Error(`服务器 ${portData.deviceId} 的端口必须关联网卡,请先在网卡管理中添加网卡`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nicId = portData.nicId;
|
||||||
|
|
||||||
|
if (!nicId && portData.网卡名称) {
|
||||||
|
const networkCard = await NetworkCard.findOne({
|
||||||
|
where: { deviceId: portData.deviceId, name: portData.网卡名称 },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (!networkCard) {
|
||||||
|
throw new Error(`服务器 ${portData.deviceId} 的网卡"${portData.网卡名称}"不存在,请先在网卡管理中添加该网卡`);
|
||||||
|
}
|
||||||
|
nicId = networkCard.nicId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nicId) {
|
||||||
|
const networkCard = await NetworkCard.findByPk(nicId, { transaction });
|
||||||
|
if (!networkCard) {
|
||||||
|
throw new Error(`网卡 ${nicId} 不存在`);
|
||||||
|
}
|
||||||
|
if (networkCard.deviceId !== portData.deviceId) {
|
||||||
|
throw new Error(`网卡 ${nicId} 不属于设备 ${portData.deviceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
portData.nicId = nicId;
|
||||||
|
} else {
|
||||||
|
if (portData.nicId || portData.网卡名称) {
|
||||||
|
portData.nicId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingPort = await DevicePort.findOne({
|
const existingPort = await DevicePort.findOne({
|
||||||
where: { deviceId: portData.deviceId, portName: portData.portName },
|
where: { deviceId: portData.deviceId, portName: portData.portName },
|
||||||
transaction
|
transaction
|
||||||
@@ -183,7 +226,8 @@ router.post('/batch', async (req, res) => {
|
|||||||
portSpeed: portData.portSpeed || existingPort.portSpeed,
|
portSpeed: portData.portSpeed || existingPort.portSpeed,
|
||||||
status: portData.status || existingPort.status,
|
status: portData.status || existingPort.status,
|
||||||
vlanId: portData.vlanId !== undefined ? portData.vlanId : existingPort.vlanId,
|
vlanId: portData.vlanId !== undefined ? portData.vlanId : existingPort.vlanId,
|
||||||
description: portData.description !== undefined ? portData.description : existingPort.description
|
description: portData.description !== undefined ? portData.description : existingPort.description,
|
||||||
|
nicId: portData.nicId !== undefined ? portData.nicId : existingPort.nicId
|
||||||
}, {
|
}, {
|
||||||
where: { portId: existingPort.portId },
|
where: { portId: existingPort.portId },
|
||||||
transaction
|
transaction
|
||||||
@@ -213,6 +257,8 @@ router.post('/batch', async (req, res) => {
|
|||||||
results.errors.push({
|
results.errors.push({
|
||||||
index: i + 1,
|
index: i + 1,
|
||||||
portId: portData.portId,
|
portId: portData.portId,
|
||||||
|
deviceId: portData.deviceId,
|
||||||
|
portName: portData.portName,
|
||||||
error: error.message
|
error: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -363,4 +409,120 @@ router.get('/:portId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/export/all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { keyword, status, portType, portSpeed, deviceId, page = 1, pageSize = 5000 } = req.query;
|
||||||
|
|
||||||
|
const parsedPage = Math.max(1, parseInt(page) || 1);
|
||||||
|
const parsedPageSize = Math.min(10000, Math.max(1, parseInt(pageSize) || 5000));
|
||||||
|
const offset = (parsedPage - 1) * parsedPageSize;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (deviceId) {
|
||||||
|
where.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portType && portType !== 'all') {
|
||||||
|
where.portType = portType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portSpeed && portSpeed !== 'all') {
|
||||||
|
where.portSpeed = portSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = 30000;
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('查询超时,请尝试缩小查询范围或减少pageSize')), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await Promise.race([
|
||||||
|
DevicePort.findAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Device,
|
||||||
|
as: 'device',
|
||||||
|
attributes: ['deviceId', 'name', 'type', 'rackId'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: require('../models/Rack'),
|
||||||
|
as: 'rack',
|
||||||
|
attributes: ['rackId', 'name'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: require('../models/Room'),
|
||||||
|
as: 'room',
|
||||||
|
attributes: ['roomId', 'name']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: NetworkCard,
|
||||||
|
as: 'networkCard',
|
||||||
|
attributes: ['nicId', 'name']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: parsedPageSize,
|
||||||
|
offset: offset,
|
||||||
|
subQuery: false
|
||||||
|
}),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ports = countResult;
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
free: '空闲',
|
||||||
|
occupied: '占用',
|
||||||
|
fault: '故障'
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportData = ports.map(port => ({
|
||||||
|
端口ID: port.portId,
|
||||||
|
设备ID: port.deviceId,
|
||||||
|
设备名称: port.device?.name || '-',
|
||||||
|
设备类型: port.device?.type || '-',
|
||||||
|
机房: port.device?.rack?.room?.name || '-',
|
||||||
|
机架: port.device?.rack?.name || '-',
|
||||||
|
网卡名称: port.networkCard?.name || '-',
|
||||||
|
端口名称: port.portName,
|
||||||
|
端口类型: port.portType,
|
||||||
|
端口速率: port.portSpeed,
|
||||||
|
状态: statusMap[port.status] || port.status,
|
||||||
|
VLAN_ID: port.vlanId || '-',
|
||||||
|
描述: port.description || '-',
|
||||||
|
创建时间: port.createdAt ? new Date(port.createdAt).toLocaleString('zh-CN') : '-'
|
||||||
|
}));
|
||||||
|
|
||||||
|
let filteredExportData = exportData;
|
||||||
|
if (keyword) {
|
||||||
|
const searchLower = keyword.toLowerCase();
|
||||||
|
filteredExportData = exportData.filter(item =>
|
||||||
|
item.端口名称?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.端口类型?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.设备名称?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.描述?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
page: parsedPage,
|
||||||
|
pageSize: parsedPageSize,
|
||||||
|
total: filteredExportData.length,
|
||||||
|
ports: filteredExportData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出端口失败:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -231,6 +231,111 @@ router.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/batch', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { networkCards, skipExisting = false, updateExisting = false } = req.body;
|
||||||
|
|
||||||
|
if (!networkCards || !Array.isArray(networkCards) || networkCards.length === 0) {
|
||||||
|
return res.status(400).json({ error: '请提供有效的网卡数据' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
total: networkCards.length,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
updated: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const transaction = await NetworkCard.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < networkCards.length; i++) {
|
||||||
|
const cardData = networkCards[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!cardData.deviceId || !cardData.name) {
|
||||||
|
throw new Error('缺少必填字段:设备ID和网卡名称');
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await Device.findByPk(cardData.deviceId, { transaction });
|
||||||
|
if (!device) {
|
||||||
|
throw new Error(`设备 ${cardData.deviceId} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isServer = device.type && device.type.toLowerCase().includes('server');
|
||||||
|
const isSwitch = device.type && device.type.toLowerCase().includes('switch');
|
||||||
|
if (!isServer && !isSwitch) {
|
||||||
|
throw new Error(`设备类型 ${device.type} 不支持网卡管理`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCard = await NetworkCard.findOne({
|
||||||
|
where: { deviceId: cardData.deviceId, name: cardData.name },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCard) {
|
||||||
|
if (skipExisting) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (updateExisting) {
|
||||||
|
await NetworkCard.update({
|
||||||
|
slotNumber: cardData.slotNumber !== undefined ? cardData.slotNumber : existingCard.slotNumber,
|
||||||
|
model: cardData.model !== undefined ? cardData.model : existingCard.model,
|
||||||
|
manufacturer: cardData.manufacturer !== undefined ? cardData.manufacturer : existingCard.manufacturer,
|
||||||
|
description: cardData.description !== undefined ? cardData.description : existingCard.description,
|
||||||
|
status: cardData.status || existingCard.status
|
||||||
|
}, {
|
||||||
|
where: { nicId: existingCard.nicId },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
results.updated++;
|
||||||
|
results.success++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error('该设备已存在同名网卡');
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoNicId = cardData.nicId || `NIC-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
|
||||||
|
await NetworkCard.create({
|
||||||
|
nicId: autoNicId,
|
||||||
|
deviceId: cardData.deviceId,
|
||||||
|
name: cardData.name,
|
||||||
|
slotNumber: cardData.slotNumber,
|
||||||
|
model: cardData.model,
|
||||||
|
manufacturer: cardData.manufacturer,
|
||||||
|
description: cardData.description,
|
||||||
|
status: cardData.status || 'normal',
|
||||||
|
portCount: 0
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
results.success++;
|
||||||
|
} catch (error) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push({
|
||||||
|
index: i + 1,
|
||||||
|
deviceId: cardData.deviceId,
|
||||||
|
name: cardData.name,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
res.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量创建网卡失败:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/:nicId', async (req, res) => {
|
router.put('/:nicId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [updated] = await NetworkCard.update(req.body, {
|
const [updated] = await NetworkCard.update(req.body, {
|
||||||
|
|||||||
@@ -261,16 +261,16 @@ const AppLayout = ({ children }) => {
|
|||||||
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
||||||
label: <Link to="/fields">字段管理</Link>,
|
label: <Link to="/fields">字段管理</Link>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'cables',
|
|
||||||
icon: <ApiOutlined style={{ fontSize: '16px' }} />,
|
|
||||||
label: <Link to="/cables">接线管理</Link>,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'ports',
|
key: 'ports',
|
||||||
icon: <PartitionOutlined style={{ fontSize: '16px' }} />,
|
icon: <PartitionOutlined style={{ fontSize: '16px' }} />,
|
||||||
label: <Link to="/ports">端口管理</Link>,
|
label: <Link to="/ports">端口管理</Link>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'cables',
|
||||||
|
icon: <ApiOutlined style={{ fontSize: '16px' }} />,
|
||||||
|
label: <Link to="/cables">接线管理</Link>,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Button, Typography, Divider } from 'antd';
|
||||||
|
import {
|
||||||
|
ImportOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { designTokens } from '../config/theme';
|
||||||
|
import CloseButton from './CloseButton';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
function BatchImportModal({ visible, onClose, onImportNetworkCard, onImportPort }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
background: designTokens.colors.primary.gradient,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImportOutlined />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '18px', fontWeight: 600 }}>批量导入</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
closeIcon={<CloseButton />}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
icon={<CloudServerOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onImportNetworkCard();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: '80px',
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 600 }}>批量导入网卡</div>
|
||||||
|
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], fontWeight: 400 }}>
|
||||||
|
用于服务器设备
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
icon={<ApiOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onImportPort();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: '80px',
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 600 }}>批量导入端口</div>
|
||||||
|
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], fontWeight: 400 }}>
|
||||||
|
用于所有设备
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '0 0 16px 0' }} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: designTokens.colors.info.bg,
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
border: `1px solid ${designTokens.colors.info.light}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||||
|
<InfoCircleOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: designTokens.colors.info.main,
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: designTokens.colors.info.dark,
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
交换机与服务器导入说明
|
||||||
|
</div>
|
||||||
|
<Paragraph
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: designTokens.colors.neutral[700],
|
||||||
|
lineHeight: '1.8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '4px' }}>
|
||||||
|
<strong>交换机端口:</strong>可直接批量导入端口,无需先导入网卡
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>服务器端口:</strong>必须先在"网卡管理"中添加网卡,才能导入端口。
|
||||||
|
服务器的网卡和端口是层级关系:设备 → 网卡 → 端口
|
||||||
|
</div>
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BatchImportModal;
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Modal, Form, Input, InputNumber, Select, message, Space, Tooltip } from 'antd';
|
import { Modal, Form, Input, Select, message, Space, Tooltip, Card, Alert, AutoComplete } from 'antd';
|
||||||
import { PlusOutlined, InfoCircleOutlined, CloudServerOutlined } from '@ant-design/icons';
|
import { CloudServerOutlined, InfoCircleOutlined, QuestionCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { designTokens } from '../config/theme';
|
import { designTokens } from '../config/theme';
|
||||||
import CloseButton from './CloseButton';
|
import CloseButton from './CloseButton';
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const SLOT_TYPES = [
|
||||||
|
{ value: 'LOM', label: '板载网卡 (LOM)', description: '主板集成网卡,编号0' },
|
||||||
|
{ value: 'OCP', label: 'OCP 网卡', description: '开放式计算项目网卡槽位' },
|
||||||
|
{ value: '1', label: '插槽 1', description: 'PCIe x16/x8 插槽' },
|
||||||
|
{ value: '2', label: '插槽 2', description: 'PCIe x8 插槽' },
|
||||||
|
{ value: '3', label: '插槽 3', description: 'PCIe x4 插槽(需 Riser 卡)' },
|
||||||
|
{ value: '4', label: '插槽 4', description: 'PCIe x4 插槽(需 Riser 卡)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MANUFACTURER_OPTIONS = [
|
||||||
|
{ value: 'Intel', label: 'Intel' },
|
||||||
|
{ value: 'Broadcom', label: 'Broadcom' },
|
||||||
|
{ value: 'Mellanox', label: 'Mellanox (NVIDIA)' },
|
||||||
|
{ value: 'Realtek', label: 'Realtek' },
|
||||||
|
{ value: 'Cisco', label: 'Cisco' },
|
||||||
|
{ value: 'HP', label: 'HP/HPE' },
|
||||||
|
{ value: 'Dell', label: 'Dell' },
|
||||||
|
{ value: 'Qlogic', label: 'Qlogic' },
|
||||||
|
{ value: 'Marvell', label: 'Marvell' },
|
||||||
|
{ value: 'Other', label: '其他' },
|
||||||
|
];
|
||||||
|
|
||||||
function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [visible, form]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
@@ -24,7 +51,6 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
|||||||
description: values.description,
|
description: values.description,
|
||||||
model: values.model,
|
model: values.model,
|
||||||
manufacturer: values.manufacturer,
|
manufacturer: values.manufacturer,
|
||||||
status: values.status,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
message.success('网卡创建成功');
|
message.success('网卡创建成功');
|
||||||
@@ -62,67 +88,157 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
|||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
okText="创建"
|
okText="创建"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
width={480}
|
width={800}
|
||||||
styles={{ body: { padding: '20px 24px' } }}
|
styles={{ body: { padding: '0 24px 24px' } }}
|
||||||
>
|
>
|
||||||
|
<div style={{
|
||||||
|
margin: '0 -24px 20px',
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: `linear-gradient(135deg, ${designTokens.colors.primary.main}18 0%, ${designTokens.colors.primary.light}18 100%)`,
|
||||||
|
borderBottom: `1px solid ${designTokens.colors.primary.light}30`,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[700], lineHeight: 1.6 }}>
|
||||||
|
为服务器添加新的网卡,网卡创建后可关联端口
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message={
|
||||||
|
<div>
|
||||||
|
<strong>插槽编号参考</strong>
|
||||||
|
<ul style={{ margin: '8px 0 0', paddingLeft: '18px', lineHeight: 1.8 }}>
|
||||||
|
<li><strong>LOM (LAN on Motherboard)</strong>:主板集成网卡,编号通常为 0</li>
|
||||||
|
<li><strong>OCP (Open Compute Project)</strong>:服务器前端维护网卡专用槽位</li>
|
||||||
|
<li><strong>PCIe 插槽</strong>:从 1 开始编号,对应服务器物理插槽位置</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
style={{
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: designTokens.colors.info.bg,
|
||||||
|
border: `1px solid ${designTokens.colors.info.light}40`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
>
|
||||||
status: 'normal',
|
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<ThunderboltOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '14px' }}>基础信息</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
}}
|
}}
|
||||||
|
styles={{ body: { padding: '16px 20px' } }}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label={
|
label={<span style={{ fontWeight: 500 }}>网卡名称 <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||||
<Space>
|
|
||||||
网卡名称
|
|
||||||
<Tooltip title="如: 网卡1、eth0、Primary NIC、LAN1">
|
|
||||||
<InfoCircleOutlined style={{ color: '#999' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请输入网卡名称' },
|
{ required: true, message: '请输入网卡名称' },
|
||||||
{ max: 50, message: '名称不能超过50个字符' },
|
{ max: 50, message: '名称不能超过50个字符' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="例如: 网卡1、eth0、LAN1" />
|
<Input placeholder="例如: 网卡1、eth0、LAN1" size="large" />
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Space style={{ display: 'flex', width: '100%' }}>
|
|
||||||
<Form.Item name="slotNumber" label="插槽编号" style={{ flex: 1 }}>
|
|
||||||
<InputNumber placeholder="可选" min={1} max={100} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="status"
|
name="slotNumber"
|
||||||
label="状态"
|
label={
|
||||||
rules={[{ required: true, message: '请选择状态' }]}
|
<Space>
|
||||||
style={{ flex: 1 }}
|
<span style={{ fontWeight: 500 }}>插槽位置</span>
|
||||||
|
<Tooltip title="参考上方提示选择或输入插槽位置">
|
||||||
|
<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400], cursor: 'help' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select placeholder="请选择">
|
<AutoComplete
|
||||||
<Option value="normal">正常</Option>
|
placeholder="选择或输入插槽位置"
|
||||||
<Option value="warning">警告</Option>
|
allowClear
|
||||||
<Option value="fault">故障</Option>
|
size="large"
|
||||||
<Option value="offline">离线</Option>
|
options={SLOT_TYPES.map(slot => ({
|
||||||
</Select>
|
value: slot.value,
|
||||||
|
label: (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{slot.label}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
|
||||||
|
{slot.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
option.value.toLowerCase().includes(input.toLowerCase()) ||
|
||||||
|
option.label.props.children[0].props.children.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<InfoCircleOutlined style={{ color: designTokens.colors.success.main }} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '14px' }}>规格信息</span>
|
||||||
</Space>
|
</Space>
|
||||||
|
}
|
||||||
<Space style={{ display: 'flex', width: '100%' }}>
|
style={{
|
||||||
<Form.Item name="manufacturer" label="制造商" style={{ flex: 1 }}>
|
flex: 1,
|
||||||
<Input placeholder="如: Intel、Realtek、Broadcom" />
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '16px 20px' } }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '12px' }}>
|
||||||
|
<Form.Item name="manufacturer" label={<span style={{ fontWeight: 500 }}>制造商</span>}>
|
||||||
|
<AutoComplete
|
||||||
|
placeholder="选择或输入"
|
||||||
|
options={MANUFACTURER_OPTIONS.map(m => ({ value: m.value, label: m.label }))}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="model" label="型号" style={{ flex: 1 }}>
|
<Form.Item name="model" label={<span style={{ fontWeight: 500 }}>型号</span>}>
|
||||||
<Input placeholder="如: X520-DA2" />
|
<Input placeholder="例如: X520-DA2" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<InfoCircleOutlined style={{ color: designTokens.colors.secondary.main }} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '14px' }}>附加信息</span>
|
||||||
</Space>
|
</Space>
|
||||||
|
}
|
||||||
<Form.Item name="description" label="描述">
|
style={{
|
||||||
<TextArea rows={2} placeholder="请输入描述信息(可选)" />
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '16px 20px' } }}
|
||||||
|
>
|
||||||
|
<Form.Item name="description" label={<span style={{ fontWeight: 500 }}>描述</span>}>
|
||||||
|
<TextArea rows={3} placeholder="请输入描述信息(可选)" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Upload,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Checkbox,
|
||||||
|
Alert,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Progress,
|
||||||
|
Spin,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UploadOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
ImportOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import api from '../api';
|
||||||
|
import { designTokens } from '../config/theme';
|
||||||
|
import CloseButton from './CloseButton';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
function NetworkCardImportModal({ visible, onClose, onSuccess }) {
|
||||||
|
const [importPreview, setImportPreview] = useState([]);
|
||||||
|
const [importErrors, setImportErrors] = useState([]);
|
||||||
|
const [importProgress, setImportProgress] = useState({ current: 0, total: 0 });
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [skipExisting, setSkipExisting] = useState(false);
|
||||||
|
const [updateExisting, setUpdateExisting] = useState(false);
|
||||||
|
|
||||||
|
const generateUniqueNicId = () => {
|
||||||
|
return `NIC-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (file, onSuccess) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async e => {
|
||||||
|
try {
|
||||||
|
const data = e.target.result;
|
||||||
|
let parsedData = [];
|
||||||
|
|
||||||
|
if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
|
||||||
|
const workbook = XLSX.read(data, { type: 'binary' });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
|
parsedData = XLSX.utils.sheet_to_json(worksheet);
|
||||||
|
} else if (file.name.endsWith('.csv')) {
|
||||||
|
parsedData = Papa.parse(data, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
}).data;
|
||||||
|
} else {
|
||||||
|
message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedData.length === 0) {
|
||||||
|
message.warning('文件内容为空,请检查文件内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedResult = await validateImportData(parsedData);
|
||||||
|
setImportPreview(validatedResult.validData);
|
||||||
|
setImportErrors(validatedResult.errors);
|
||||||
|
setImportProgress({ current: 0, total: validatedResult.validData.length });
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('文件解析失败');
|
||||||
|
console.error('文件解析失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (file.name.endsWith('.csv')) {
|
||||||
|
reader.readAsText(file);
|
||||||
|
} else {
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateImportData = async data => {
|
||||||
|
const validData = [];
|
||||||
|
const allErrors = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const row = data[i];
|
||||||
|
const result = await validateNetworkCardRow(row, i);
|
||||||
|
|
||||||
|
if (result.valid) {
|
||||||
|
validData.push(row);
|
||||||
|
} else {
|
||||||
|
allErrors.push(...result.errors.map(err => ({ ...err, originalRow: row })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
message.warning({
|
||||||
|
content: `发现 ${allErrors.length} 个数据错误,请查看错误详情并修正后重新导入`,
|
||||||
|
duration: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { validData, errors: allErrors };
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateNetworkCardRow = async (row, index) => {
|
||||||
|
const rowNum = index + 2;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!row['设备ID']) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: '设备ID',
|
||||||
|
value: row['设备ID'] || '(空)',
|
||||||
|
error: '缺少必填字段',
|
||||||
|
suggestion: '请填写设备ID,格式如:DEV001',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row['网卡名称']) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: '网卡名称',
|
||||||
|
value: row['网卡名称'] || '(空)',
|
||||||
|
error: '缺少必填字段',
|
||||||
|
suggestion: '请填写网卡名称,格式如:网卡1、eth0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row['插槽编号']) {
|
||||||
|
const slotPattern = /^\d+$/;
|
||||||
|
if (!slotPattern.test(row['插槽编号'])) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: '插槽编号',
|
||||||
|
value: row['插槽编号'],
|
||||||
|
error: '插槽编号格式错误',
|
||||||
|
suggestion: '插槽编号必须为数字,如:1、2',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchImport = async () => {
|
||||||
|
if (importPreview.length === 0) {
|
||||||
|
message.warning('请先选择要导入的数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
setImportProgress({ current: 0, total: importPreview.length });
|
||||||
|
|
||||||
|
let progressInterval;
|
||||||
|
let currentProgress = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkCardsData = importPreview.map((row, index) => ({
|
||||||
|
nicId: generateUniqueNicId(),
|
||||||
|
deviceId: row['设备ID'],
|
||||||
|
name: row['网卡名称'],
|
||||||
|
slotNumber: row['插槽编号'] ? parseInt(row['插槽编号']) : null,
|
||||||
|
model: row['网卡型号'] || null,
|
||||||
|
manufacturer: row['制造商'] || null,
|
||||||
|
description: row['描述'] || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
progressInterval = setInterval(() => {
|
||||||
|
currentProgress = Math.min(currentProgress + Math.random() * 15, 85);
|
||||||
|
setImportProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
current: Math.floor((currentProgress / 100) * importPreview.length),
|
||||||
|
}));
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
const response = await api.post('/network-cards/batch', {
|
||||||
|
networkCards: networkCardsData,
|
||||||
|
skipExisting,
|
||||||
|
updateExisting,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { total, success, failed, skipped = 0, updated = 0, errors } = response;
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setImportProgress({ current: importPreview.length, total: importPreview.length });
|
||||||
|
|
||||||
|
let msgContent = '';
|
||||||
|
if (updated > 0) {
|
||||||
|
msgContent += `更新 ${updated} 个,`;
|
||||||
|
}
|
||||||
|
if (skipped > 0) {
|
||||||
|
msgContent += `跳过 ${skipped} 个,`;
|
||||||
|
}
|
||||||
|
if (success > 0) {
|
||||||
|
msgContent += `新增 ${success - updated} 个,`;
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
msgContent += `失败 ${failed} 个`;
|
||||||
|
console.error('导入错误:', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed > 0 && success === 0 && skipped === 0 && updated === 0) {
|
||||||
|
message.error(`导入失败!${msgContent}`);
|
||||||
|
} else {
|
||||||
|
message.success({
|
||||||
|
content: `导入完成!${msgContent}`,
|
||||||
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
setImportPreview([]);
|
||||||
|
setImportErrors([]);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
console.error('批量导入失败:', error);
|
||||||
|
message.error('批量导入失败,请检查数据格式');
|
||||||
|
} finally {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
const templateData = [
|
||||||
|
{
|
||||||
|
设备ID: 'DEV001',
|
||||||
|
网卡名称: '网卡1',
|
||||||
|
插槽编号: '1',
|
||||||
|
网卡型号: 'Intel X710',
|
||||||
|
制造商: 'Intel',
|
||||||
|
描述: '主网卡',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
设备ID: 'DEV001',
|
||||||
|
网卡名称: '网卡2',
|
||||||
|
插槽编号: '2',
|
||||||
|
网卡型号: 'Intel X710',
|
||||||
|
制造商: 'Intel',
|
||||||
|
描述: '备网卡',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(templateData);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, '网卡数据');
|
||||||
|
XLSX.writeFile(workbook, '网卡导入模板.xlsx');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setImportPreview([]);
|
||||||
|
setImportErrors([]);
|
||||||
|
setImportProgress({ current: 0, total: 0 });
|
||||||
|
setSkipExisting(false);
|
||||||
|
setUpdateExisting(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloudServerOutlined />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '18px', fontWeight: 600 }}>批量导入网卡</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
closeIcon={<CloseButton />}
|
||||||
|
onCancel={handleClose}
|
||||||
|
width={900}
|
||||||
|
footer={[
|
||||||
|
<Button
|
||||||
|
key="cancel"
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="download"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
||||||
|
>
|
||||||
|
下载模板
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="import"
|
||||||
|
type="primary"
|
||||||
|
icon={<ImportOutlined />}
|
||||||
|
onClick={handleBatchImport}
|
||||||
|
loading={importing}
|
||||||
|
disabled={importPreview.length === 0}
|
||||||
|
style={{
|
||||||
|
background: designTokens.colors.primary.gradient,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: designTokens.borderRadius.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
开始导入
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<Alert
|
||||||
|
message="操作说明"
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '12px', lineHeight: '1.8' }}>
|
||||||
|
<div>• 网卡批量导入用于服务器设备,请确保先在设备管理中添加服务器</div>
|
||||||
|
<div>• 模板中的"设备ID"必须与已存在的设备对应</div>
|
||||||
|
<div>• 请先下载模板,按模板格式填写数据后再上传</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
background: designTokens.colors.info.bg,
|
||||||
|
border: `1px solid ${designTokens.colors.info.light}40`,
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Upload.Dragger
|
||||||
|
name="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={file => {
|
||||||
|
handleFileUpload(file, null);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderRadius: designTokens.borderRadius.lg,
|
||||||
|
border: `2px dashed ${designTokens.colors.primary.light}`,
|
||||||
|
background: designTokens.colors.primary.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<UploadOutlined style={{ fontSize: '48px', color: designTokens.colors.primary.main }} />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text" style={{ fontSize: '16px', color: designTokens.colors.neutral[700] }}>
|
||||||
|
点击或拖拽文件到此处上传
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-hint" style={{ color: designTokens.colors.neutral[500] }}>
|
||||||
|
支持 .xlsx, .xls, .csv 格式文件
|
||||||
|
</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '24px',
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: designTokens.colors.neutral[50],
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)}>
|
||||||
|
跳过已存在的网卡
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox checked={updateExisting} onChange={e => setUpdateExisting(e.target.checked)}>
|
||||||
|
更新已存在的网卡
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importErrors.length > 0 && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<Alert
|
||||||
|
message={`发现 ${importErrors.length} 个错误`}
|
||||||
|
description={
|
||||||
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '行号',
|
||||||
|
dataIndex: 'row',
|
||||||
|
key: 'row',
|
||||||
|
width: 70,
|
||||||
|
render: row => <Tag color="red">{row}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '字段',
|
||||||
|
dataIndex: 'field',
|
||||||
|
key: 'field',
|
||||||
|
width: 100,
|
||||||
|
render: field => <Text strong>{field}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '错误值',
|
||||||
|
dataIndex: 'value',
|
||||||
|
key: 'value',
|
||||||
|
width: 120,
|
||||||
|
render: val => <Text code>{val}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '错误原因',
|
||||||
|
dataIndex: 'error',
|
||||||
|
key: 'error',
|
||||||
|
render: err => <Text type="danger">{err}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '修正建议',
|
||||||
|
dataIndex: 'suggestion',
|
||||||
|
key: 'suggestion',
|
||||||
|
render: sug => <Text type="secondary">{sug}</Text>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={importErrors}
|
||||||
|
rowKey={(record, index) => `error-${index}`}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
size: 'small',
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: total => `共 ${total} 条错误`,
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
style={{ marginTop: '8px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ borderRadius: designTokens.borderRadius.md }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importPreview.length > 0 && (
|
||||||
|
<div style={{ marginTop: '24px' }}>
|
||||||
|
<Alert
|
||||||
|
message={
|
||||||
|
<span>
|
||||||
|
成功解析 {importPreview.length} 条有效数据
|
||||||
|
{importErrors.length > 0 && (
|
||||||
|
<span style={{ color: '#ff4d4f', marginLeft: 8 }}>
|
||||||
|
({importErrors.length} 条错误)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
type={importErrors.length > 0 ? 'warning' : 'success'}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px', borderRadius: designTokens.borderRadius.md }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginBottom: '8px', fontWeight: 500, color: designTokens.colors.neutral[700] }}>
|
||||||
|
数据预览(前10条)
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={[
|
||||||
|
{ title: '设备ID', dataIndex: '设备ID', key: 'deviceId', width: 120 },
|
||||||
|
{ title: '网卡名称', dataIndex: '网卡名称', key: 'name', width: 120 },
|
||||||
|
{ title: '插槽编号', dataIndex: '插槽编号', key: 'slotNumber', width: 100 },
|
||||||
|
{ title: '网卡型号', dataIndex: '网卡型号', key: 'model', width: 120 },
|
||||||
|
{ title: '制造商', dataIndex: '制造商', key: 'manufacturer', width: 100 },
|
||||||
|
{ title: '描述', dataIndex: '描述', key: 'description', ellipsis: true },
|
||||||
|
]}
|
||||||
|
dataSource={importPreview.slice(0, 10)}
|
||||||
|
rowKey={(record, index) => `import-row-${index}`}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 700 }}
|
||||||
|
style={{ borderRadius: designTokens.borderRadius.md }}
|
||||||
|
/>
|
||||||
|
{importPreview.length > 10 && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '12px', color: designTokens.colors.neutral[500] }}>
|
||||||
|
仅显示前10条数据,共 {importPreview.length} 条
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importing && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 24px' }}>
|
||||||
|
<Spin size="large" tip="导入中..." />
|
||||||
|
<div style={{ marginTop: '24px' }}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round((importProgress.current / importProgress.total) * 100)}
|
||||||
|
status="active"
|
||||||
|
strokeColor={{
|
||||||
|
'0%': designTokens.colors.primary.main,
|
||||||
|
'100%': designTokens.colors.success.main,
|
||||||
|
}}
|
||||||
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '16px', color: designTokens.colors.neutral[600] }}>
|
||||||
|
正在导入 {importProgress.current} / {importProgress.total} 条数据...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NetworkCardImportModal;
|
||||||
@@ -426,6 +426,8 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
|||||||
}}
|
}}
|
||||||
onSuccess={handleCreatePortSuccess}
|
onSuccess={handleCreatePortSuccess}
|
||||||
defaultNicId={selectedCard?.nicId}
|
defaultNicId={selectedCard?.nicId}
|
||||||
|
networkCard={selectedCard}
|
||||||
|
disableNicChange={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Button, Space, Divider, Alert } from 'antd';
|
||||||
|
import {
|
||||||
|
SwapOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import CloseButton from './CloseButton';
|
||||||
|
import { designTokens } from '../config/theme';
|
||||||
|
|
||||||
|
const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
|
||||||
|
const handleSelectSwitch = () => {
|
||||||
|
onSelectType('switch');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectServer = () => {
|
||||||
|
onSelectType('server');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={visible}
|
||||||
|
closeIcon={<CloseButton />}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={560}
|
||||||
|
zIndex={1050}
|
||||||
|
style={{ borderRadius: '16px', top: 80 }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.primary.dark} 100%)`,
|
||||||
|
padding: '28px 24px',
|
||||||
|
borderRadius: '16px 16px 0 0',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '52px',
|
||||||
|
height: '52px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
}}>
|
||||||
|
<SwapOutlined style={{ fontSize: '26px' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
选择端口类型
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
margin: '4px 0 0',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}>
|
||||||
|
请选择要添加的端口类型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectSwitch}
|
||||||
|
style={{
|
||||||
|
padding: '24px 20px',
|
||||||
|
border: `2px solid ${designTokens.colors.success.light}40`,
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: designTokens.colors.success.bg,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.borderColor = designTokens.colors.success.main;
|
||||||
|
e.currentTarget.style.boxShadow = `0 4px 16px ${designTokens.colors.success.light}30`;
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.borderColor = `${designTokens.colors.success.light}40`;
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: designTokens.colors.success.gradient,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
|
||||||
|
}}>
|
||||||
|
<SwapOutlined style={{ fontSize: '28px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: designTokens.colors.neutral[800],
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
交换机端口
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: designTokens.colors.neutral[500],
|
||||||
|
}}>
|
||||||
|
直接添加端口
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
color: designTokens.colors.success.main,
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<span>立即添加</span>
|
||||||
|
<ArrowRightOutlined style={{ fontSize: '11px' }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSelectServer}
|
||||||
|
style={{
|
||||||
|
padding: '24px 20px',
|
||||||
|
border: `2px solid ${designTokens.colors.primary.light}40`,
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: designTokens.colors.primary.bg,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.borderColor = designTokens.colors.primary.main;
|
||||||
|
e.currentTarget.style.boxShadow = `0 4px 16px ${designTokens.colors.primary.light}30`;
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.borderColor = `${designTokens.colors.primary.light}40`;
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: designTokens.colors.primary.gradient,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
|
||||||
|
}}>
|
||||||
|
<CloudServerOutlined style={{ fontSize: '28px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: designTokens.colors.neutral[800],
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
服务器端口
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: designTokens.colors.neutral[500],
|
||||||
|
}}>
|
||||||
|
需关联网卡
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
color: designTokens.colors.primary.main,
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<span>立即添加</span>
|
||||||
|
<ArrowRightOutlined style={{ fontSize: '11px' }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '0 0 20px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: designTokens.colors.neutral[400],
|
||||||
|
fontWeight: 400,
|
||||||
|
}}>
|
||||||
|
端口类型说明
|
||||||
|
</span>
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: designTokens.colors.neutral[50],
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<CheckCircleOutlined style={{
|
||||||
|
color: designTokens.colors.success.main,
|
||||||
|
fontSize: '16px',
|
||||||
|
marginTop: '2px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: designTokens.colors.neutral[800],
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}>
|
||||||
|
交换机端口
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: designTokens.colors.neutral[500],
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
|
交换机端口用于网络设备间的连接,可以直接创建端口,无需关联网卡。适用于创建 Uplink 端口、Trunk 端口等。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<ExclamationCircleOutlined style={{
|
||||||
|
color: designTokens.colors.warning.main,
|
||||||
|
fontSize: '16px',
|
||||||
|
marginTop: '2px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: designTokens.colors.neutral[800],
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}>
|
||||||
|
服务器端口
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: designTokens.colors.neutral[500],
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
|
服务器端口必须关联网卡(Network Card),每个端口需要对应一个物理或虚拟网卡。请先在网卡管理中添加网卡。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message=""
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '12px', lineHeight: 1.6 }}>
|
||||||
|
<strong>提示:</strong>如果服务器尚未添加网卡,系统会引导您先前往网卡管理添加网卡后再创建端口。
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
icon={<ExclamationCircleOutlined />}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: designTokens.colors.info.bg,
|
||||||
|
border: `1px solid ${designTokens.colors.info.light}40`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: '20px',
|
||||||
|
}}>
|
||||||
|
<Button onClick={onClose} style={{ borderRadius: '8px' }}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PortAddGuideModal;
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Tag,
|
Tag,
|
||||||
Divider,
|
Divider,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -89,34 +91,38 @@ function generatePortNames(portName) {
|
|||||||
return [portName];
|
return [portName];
|
||||||
}
|
}
|
||||||
|
|
||||||
function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, networkCards = [] }) {
|
function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, networkCards = [], networkCard, disableNicChange = false }) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [previewPorts, setPreviewPorts] = useState([]);
|
const [previewPorts, setPreviewPorts] = useState([]);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [nicList, setNicList] = useState([]);
|
const [nicList, setNicList] = useState([]);
|
||||||
const [activeStep, setActiveStep] = useState(1);
|
|
||||||
const prevVisibleRef = useRef(false);
|
const prevVisibleRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && !prevVisibleRef.current) {
|
if (visible && !prevVisibleRef.current) {
|
||||||
setPreviewPorts([]);
|
setPreviewPorts([]);
|
||||||
setShowPreview(false);
|
setShowPreview(false);
|
||||||
setActiveStep(1);
|
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
|
||||||
|
if (networkCard) {
|
||||||
|
setNicList([networkCard]);
|
||||||
|
if (networkCard.nicId) {
|
||||||
|
form.setFieldsValue({ nicId: networkCard.nicId });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (defaultNicId) {
|
if (defaultNicId) {
|
||||||
form.setFieldsValue({ nicId: defaultNicId });
|
form.setFieldsValue({ nicId: defaultNicId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (device?.deviceId && (!networkCards || networkCards.length === 0)) {
|
if (device?.deviceId && (!networkCards || networkCards.length === 0)) {
|
||||||
fetchNetworkCards();
|
fetchNetworkCards();
|
||||||
} else if (networkCards && networkCards.length > 0) {
|
} else if (networkCards && networkCards.length > 0) {
|
||||||
setNicList(networkCards);
|
setNicList(networkCards);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
prevVisibleRef.current = visible;
|
prevVisibleRef.current = visible;
|
||||||
}, [visible, device, defaultNicId, networkCards, form]);
|
}, [visible, device, defaultNicId, networkCards, networkCard, form]);
|
||||||
|
|
||||||
const fetchNetworkCards = async () => {
|
const fetchNetworkCards = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -147,11 +153,12 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const portNames = generatePortNames(values.portName);
|
const portNames = generatePortNames(values.portName);
|
||||||
|
const finalNicId = disableNicChange && defaultNicId ? defaultNicId : (values.nicId || null);
|
||||||
|
|
||||||
if (portNames.length === 1) {
|
if (portNames.length === 1) {
|
||||||
await axios.post('/api/device-ports', {
|
await axios.post('/api/device-ports', {
|
||||||
deviceId: device.deviceId,
|
deviceId: device.deviceId,
|
||||||
nicId: values.nicId || null,
|
nicId: finalNicId,
|
||||||
portName: portNames[0],
|
portName: portNames[0],
|
||||||
portType: values.portType,
|
portType: values.portType,
|
||||||
portSpeed: values.portSpeed,
|
portSpeed: values.portSpeed,
|
||||||
@@ -164,7 +171,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
const portsData = portNames.map((portName, index) => ({
|
const portsData = portNames.map((portName, index) => ({
|
||||||
portId: `PORT-${Date.now()}-${index}`,
|
portId: `PORT-${Date.now()}-${index}`,
|
||||||
deviceId: device.deviceId,
|
deviceId: device.deviceId,
|
||||||
nicId: values.nicId || null,
|
nicId: finalNicId,
|
||||||
portName,
|
portName,
|
||||||
portType: values.portType,
|
portType: values.portType,
|
||||||
portSpeed: values.portSpeed,
|
portSpeed: values.portSpeed,
|
||||||
@@ -232,61 +239,37 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
padding: '24px',
|
padding: '24px',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
},
|
},
|
||||||
stepContainer: {
|
twoColumnLayout: {
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
column: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
flexDirection: 'column',
|
||||||
marginBottom: '24px',
|
gap: '16px',
|
||||||
},
|
height: '100%',
|
||||||
step: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '8px 20px',
|
|
||||||
borderRadius: '20px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
},
|
|
||||||
stepActive: {
|
|
||||||
background: designTokens.colors.primary.bg,
|
|
||||||
color: designTokens.colors.primary.main,
|
|
||||||
},
|
|
||||||
stepInactive: {
|
|
||||||
background: designTokens.colors.neutral[100],
|
|
||||||
color: designTokens.colors.neutral[500],
|
|
||||||
},
|
|
||||||
stepNumber: {
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
background: designTokens.colors.neutral[50],
|
background: designTokens.colors.neutral[50],
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '20px',
|
padding: '16px',
|
||||||
marginBottom: '16px',
|
|
||||||
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: designTokens.colors.neutral[800],
|
color: designTokens.colors.neutral[800],
|
||||||
marginBottom: '16px',
|
marginBottom: '12px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
borderBottom: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
},
|
},
|
||||||
fieldLabel: {
|
fieldLabel: {
|
||||||
fontSize: '13px',
|
fontSize: '12px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: designTokens.colors.neutral[700],
|
color: designTokens.colors.neutral[700],
|
||||||
marginBottom: '6px',
|
marginBottom: '4px',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
@@ -299,16 +282,16 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
},
|
},
|
||||||
previewCard: {
|
previewCard: {
|
||||||
background: `linear-gradient(135deg, ${designTokens.colors.info.bg} 0%, ${designTokens.colors.primary.bg} 100%)`,
|
background: `linear-gradient(135deg, ${designTokens.colors.info.bg} 0%, ${designTokens.colors.primary.bg} 100%)`,
|
||||||
borderRadius: '12px',
|
borderRadius: '10px',
|
||||||
padding: '16px',
|
padding: '12px',
|
||||||
marginTop: '16px',
|
marginTop: '12px',
|
||||||
border: `1px solid ${designTokens.colors.primary.light}20`,
|
border: `1px solid ${designTokens.colors.primary.light}20`,
|
||||||
},
|
},
|
||||||
previewTitle: {
|
previewTitle: {
|
||||||
fontSize: '13px',
|
fontSize: '12px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: designTokens.colors.primary.dark,
|
color: designTokens.colors.primary.dark,
|
||||||
marginBottom: '12px',
|
marginBottom: '8px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '6px',
|
gap: '6px',
|
||||||
@@ -316,15 +299,16 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
previewTags: {
|
previewTags: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: '6px',
|
gap: '4px',
|
||||||
},
|
},
|
||||||
previewTag: {
|
previewTag: {
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: `1px solid ${designTokens.colors.primary.light}`,
|
border: `1px solid ${designTokens.colors.primary.light}`,
|
||||||
color: designTokens.colors.primary.main,
|
color: designTokens.colors.primary.main,
|
||||||
borderRadius: '6px',
|
borderRadius: '4px',
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
padding: '2px 6px',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
padding: '16px 24px',
|
padding: '16px 24px',
|
||||||
@@ -343,7 +327,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
gap: '12px',
|
gap: '12px',
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
height: '40px',
|
height: '38px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
@@ -357,28 +341,33 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
background: designTokens.colors.success.bg,
|
background: designTokens.colors.success.bg,
|
||||||
border: `1px solid ${designTokens.colors.success.light}`,
|
border: `1px solid ${designTokens.colors.success.light}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '12px 16px',
|
padding: '10px 12px',
|
||||||
marginTop: '8px',
|
marginTop: '8px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '10px',
|
gap: '8px',
|
||||||
},
|
},
|
||||||
nicSelectedIcon: {
|
nicSelectedIcon: {
|
||||||
color: designTokens.colors.success.main,
|
color: designTokens.colors.success.main,
|
||||||
fontSize: '18px',
|
fontSize: '16px',
|
||||||
},
|
},
|
||||||
nicSelectedInfo: {
|
nicSelectedInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
nicSelectedName: {
|
nicSelectedName: {
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: designTokens.colors.success.dark,
|
color: designTokens.colors.success.dark,
|
||||||
},
|
},
|
||||||
nicSelectedSlot: {
|
nicSelectedSlot: {
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
color: designTokens.colors.success.main,
|
color: designTokens.colors.success.main,
|
||||||
},
|
},
|
||||||
|
alertBox: {
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: designTokens.colors.info.bg,
|
||||||
|
border: `1px solid ${designTokens.colors.info.light}40`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -387,7 +376,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
closeIcon={<CloseButton />}
|
closeIcon={<CloseButton />}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={600}
|
width={720}
|
||||||
zIndex={1050}
|
zIndex={1050}
|
||||||
style={styles.modal}
|
style={styles.modal}
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
@@ -411,49 +400,31 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.body}>
|
<div style={styles.body}>
|
||||||
<div style={styles.stepContainer}>
|
|
||||||
<div style={{ ...styles.step, ...(activeStep === 1 ? styles.stepActive : styles.stepInactive) }}>
|
|
||||||
<div style={{
|
|
||||||
...styles.stepNumber,
|
|
||||||
background: activeStep === 1 ? designTokens.colors.primary.main : 'transparent',
|
|
||||||
color: activeStep === 1 ? '#fff' : designTokens.colors.neutral[500],
|
|
||||||
border: activeStep === 1 ? 'none' : `1px solid ${designTokens.colors.neutral[400]}`,
|
|
||||||
}}>
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<span>基本信息</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '2px',
|
|
||||||
background: activeStep === 2 ? designTokens.colors.primary.main : designTokens.colors.neutral[300],
|
|
||||||
margin: '0 8px',
|
|
||||||
alignSelf: 'center',
|
|
||||||
}} />
|
|
||||||
<div style={{ ...styles.step, ...(activeStep === 2 ? styles.stepActive : styles.stepInactive) }}>
|
|
||||||
<div style={{
|
|
||||||
...styles.stepNumber,
|
|
||||||
background: activeStep === 2 ? designTokens.colors.primary.main : 'transparent',
|
|
||||||
color: activeStep === 2 ? '#fff' : designTokens.colors.neutral[500],
|
|
||||||
border: activeStep === 2 ? 'none' : `1px solid ${designTokens.colors.neutral[400]}`,
|
|
||||||
}}>
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<span>高级配置</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
portType: 'RJ45',
|
portType: 'RJ45',
|
||||||
portSpeed: '1G',
|
portSpeed: '1G',
|
||||||
status: 'free',
|
status: 'occupied',
|
||||||
}}
|
}}
|
||||||
onValuesChange={handleValuesChange}
|
|
||||||
>
|
>
|
||||||
<div style={styles.section}>
|
<Alert
|
||||||
|
message="格式说明"
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '11px', lineHeight: '1.6' }}>
|
||||||
|
<div>• <strong>单个端口:</strong>eth0/1、gigabitethernet1/0/1</div>
|
||||||
|
<div>• <strong>端口范围:</strong>1/0/1-1/0/48(创建 1/0/1 到 1/0/48 共48个端口)</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ ...styles.alertBox, marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Row gutter={20} style={{ marginBottom: '16px' }}>
|
||||||
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
|
<div style={{ ...styles.section, height: '100%' }}>
|
||||||
<div style={styles.sectionTitle}>
|
<div style={styles.sectionTitle}>
|
||||||
<TagOutlined style={{ color: designTokens.colors.primary.main }} />
|
<TagOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||||
端口标识
|
端口标识
|
||||||
@@ -480,13 +451,13 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
size="small"
|
||||||
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
|
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
|
||||||
prefix={<TagOutlined style={{ color: designTokens.colors.neutral[400] }} />}
|
prefix={<TagOutlined style={{ color: designTokens.colors.neutral[400], fontSize: '12px' }} />}
|
||||||
style={{ borderRadius: '8px' }}
|
style={{ borderRadius: '6px' }}
|
||||||
suffix={
|
suffix={
|
||||||
<Tooltip title="支持单个端口或端口范围(如 1/0/1-1/0/48)">
|
<Tooltip title="支持单个端口或端口范围(如 1/0/1-1/0/48)">
|
||||||
<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400] }} />
|
<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400], fontSize: '11px' }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -496,7 +467,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
<div style={styles.previewCard}>
|
<div style={styles.previewCard}>
|
||||||
<div style={styles.previewTitle}>
|
<div style={styles.previewTitle}>
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
预览:将创建 {previewPorts.length} 个端口
|
将创建 {previewPorts.length} 个端口
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.previewTags}>
|
<div style={styles.previewTags}>
|
||||||
{previewPorts.map((port, index) => (
|
{previewPorts.map((port, index) => (
|
||||||
@@ -513,30 +484,51 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<div style={styles.section}>
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
|
<div style={{ ...styles.section, height: '100%' }}>
|
||||||
<div style={styles.sectionTitle}>
|
<div style={styles.sectionTitle}>
|
||||||
<LinkOutlined style={{ color: designTokens.colors.primary.main }} />
|
<LinkOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||||
网卡关联
|
网卡关联
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{disableNicChange && defaultNicId ? (
|
||||||
|
<div style={{ ...styles.nicSelectedCard, background: designTokens.colors.success.bg, border: `1px solid ${designTokens.colors.success.light}` }}>
|
||||||
|
<div style={{ ...styles.nicSelectedIcon, color: designTokens.colors.success.main }}>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
</div>
|
||||||
|
<div style={styles.nicSelectedInfo}>
|
||||||
|
<div style={{ ...styles.nicSelectedName, color: designTokens.colors.success.dark }}>
|
||||||
|
{nicList.find(nic => nic.nicId === defaultNicId)?.name || '管理口'}
|
||||||
|
</div>
|
||||||
|
{nicList.find(nic => nic.nicId === defaultNicId)?.slotNumber && (
|
||||||
|
<div style={{ ...styles.nicSelectedSlot, color: designTokens.colors.success.main }}>
|
||||||
|
插槽 {nicList.find(nic => nic.nicId === defaultNicId)?.slotNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tag color="success">已绑定</Tag>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Form.Item name="nicId" style={{ marginBottom: 0 }}>
|
<Form.Item name="nicId" style={{ marginBottom: 0 }}>
|
||||||
<Select
|
<Select
|
||||||
size="large"
|
size="small"
|
||||||
placeholder="选择网卡(可选)"
|
placeholder="选择网卡(可选)"
|
||||||
allowClear
|
allowClear
|
||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
style={{ width: '100%', borderRadius: '8px' }}
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
suffixIcon={<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400] }} />}
|
suffixIcon={<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400] }} />}
|
||||||
>
|
>
|
||||||
{nicList.map(nic => (
|
{nicList.map(nic => (
|
||||||
<Option key={nic.nicId} value={nic.nicId}>
|
<Option key={nic.nicId} value={nic.nicId}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 0' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 0' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{nic.name}</div>
|
<div style={{ fontWeight: 500, fontSize: '12px' }}>{nic.name}</div>
|
||||||
{nic.slotNumber && (
|
{nic.slotNumber && (
|
||||||
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
|
<div style={{ fontSize: '11px', color: designTokens.colors.neutral[500] }}>
|
||||||
插槽 {nic.slotNumber}
|
插槽 {nic.slotNumber}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -547,85 +539,77 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{selectedNic && (
|
<div style={{ fontSize: '11px', color: designTokens.colors.neutral[500], marginTop: '6px' }}>
|
||||||
<div style={styles.nicSelectedCard}>
|
|
||||||
<div style={styles.nicSelectedIcon}>
|
|
||||||
<CheckCircleOutlined />
|
|
||||||
</div>
|
|
||||||
<div style={styles.nicSelectedInfo}>
|
|
||||||
<div style={styles.nicSelectedName}>{selectedNic.name}</div>
|
|
||||||
{selectedNic.slotNumber && (
|
|
||||||
<div style={styles.nicSelectedSlot}>插槽 {selectedNic.slotNumber}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Tag color="success">已选择</Tag>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '8px' }}>
|
|
||||||
<InfoCircleOutlined style={{ marginRight: '4px' }} />
|
<InfoCircleOutlined style={{ marginRight: '4px' }} />
|
||||||
不选择则端口不归属于任何网卡
|
不选择则端口不归属于任何网卡
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<div style={styles.section}>
|
<div style={{ ...styles.section, marginBottom: '16px' }}>
|
||||||
<div style={styles.sectionTitle}>
|
<div style={styles.sectionTitle}>
|
||||||
<ThunderboltOutlined style={{ color: designTokens.colors.primary.main }} />
|
<ThunderboltOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||||
端口属性
|
端口属性
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="portType"
|
name="portType"
|
||||||
label={<span style={styles.fieldLabel}>端口类型</span>}
|
label={<span style={styles.fieldLabel}>端口类型</span>}
|
||||||
rules={[{ required: true, message: '请选择端口类型' }]}
|
rules={[{ required: true, message: '请选择' }]}
|
||||||
>
|
>
|
||||||
<Select size="large" style={{ width: '100%', borderRadius: '8px' }}>
|
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
|
||||||
<Option value="RJ45">
|
<Option value="RJ45">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: designTokens.colors.device.server }} />
|
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.device.server }} />
|
||||||
RJ45
|
RJ45
|
||||||
</div>
|
</div>
|
||||||
</Option>
|
</Option>
|
||||||
<Option value="SFP">
|
<Option value="SFP">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: designTokens.colors.device.switch }} />
|
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.device.switch }} />
|
||||||
SFP
|
SFP
|
||||||
</div>
|
</div>
|
||||||
</Option>
|
</Option>
|
||||||
<Option value="SFP+">
|
<Option value="SFP+">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: designTokens.colors.purple.main }} />
|
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.purple.main }} />
|
||||||
SFP+
|
SFP+
|
||||||
</div>
|
</div>
|
||||||
</Option>
|
</Option>
|
||||||
<Option value="SFP28">
|
<Option value="SFP28">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: designTokens.colors.info.main }} />
|
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.info.main }} />
|
||||||
SFP28
|
SFP28
|
||||||
</div>
|
</div>
|
||||||
</Option>
|
</Option>
|
||||||
<Option value="QSFP">
|
<Option value="QSFP">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: designTokens.colors.warning.main }} />
|
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.warning.main }} />
|
||||||
QSFP
|
QSFP
|
||||||
</div>
|
</div>
|
||||||
</Option>
|
</Option>
|
||||||
<Option value="QSFP28">
|
<Option value="QSFP28">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: designTokens.colors.secondary.main }} />
|
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.secondary.main }} />
|
||||||
QSFP28
|
QSFP28
|
||||||
</div>
|
</div>
|
||||||
</Option>
|
</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="portSpeed"
|
name="portSpeed"
|
||||||
label={<span style={styles.fieldLabel}>端口速率</span>}
|
label={<span style={styles.fieldLabel}>端口速率</span>}
|
||||||
rules={[{ required: true, message: '请选择端口速率' }]}
|
rules={[{ required: true, message: '请选择' }]}
|
||||||
>
|
>
|
||||||
<Select size="large" style={{ width: '100%', borderRadius: '8px' }}>
|
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
|
||||||
<Option value="100M">100M</Option>
|
<Option value="100M">100M</Option>
|
||||||
<Option value="1G">1G</Option>
|
<Option value="1G">1G</Option>
|
||||||
<Option value="10G">10G</Option>
|
<Option value="10G">10G</Option>
|
||||||
@@ -634,40 +618,43 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
<Option value="100G">100G</Option>
|
<Option value="100G">100G</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="vlanId"
|
name="vlanId"
|
||||||
label={<span style={styles.fieldLabel}>VLAN ID</span>}
|
label={<span style={styles.fieldLabel}>VLAN ID</span>}
|
||||||
>
|
>
|
||||||
<InputNumber
|
<Input
|
||||||
size="large"
|
size="small"
|
||||||
placeholder="1-4094"
|
placeholder="1-4094"
|
||||||
min={1}
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
max={4094}
|
|
||||||
style={{ width: '100%', borderRadius: '8px' }}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="status"
|
name="status"
|
||||||
label={<span style={styles.fieldLabel}>状态</span>}
|
label={<span style={styles.fieldLabel}>状态</span>}
|
||||||
rules={[{ required: true, message: '请选择状态' }]}
|
rules={[{ required: true, message: '请选择' }]}
|
||||||
>
|
>
|
||||||
<Select size="large" style={{ width: '100%', borderRadius: '8px' }}>
|
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
|
||||||
<Option value="free">
|
<Option value="free">
|
||||||
<Tag color="success">空闲</Tag>
|
<Tag color="success" style={{ margin: 0 }}>空闲</Tag>
|
||||||
</Option>
|
</Option>
|
||||||
<Option value="occupied">
|
<Option value="occupied">
|
||||||
<Tag color="warning">占用</Tag>
|
<Tag color="warning" style={{ margin: 0 }}>占用</Tag>
|
||||||
</Option>
|
</Option>
|
||||||
<Option value="fault">
|
<Option value="fault">
|
||||||
<Tag color="error">故障</Tag>
|
<Tag color="error" style={{ margin: 0 }}>故障</Tag>
|
||||||
</Option>
|
</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.section}>
|
<div style={styles.section}>
|
||||||
@@ -678,30 +665,12 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
|
|
||||||
<Form.Item name="description" style={{ marginBottom: 0 }}>
|
<Form.Item name="description" style={{ marginBottom: 0 }}>
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={3}
|
rows={2}
|
||||||
placeholder="请输入描述信息(可选)"
|
placeholder="请输入描述信息(可选)"
|
||||||
style={{ borderRadius: '8px', resize: 'none' }}
|
style={{ borderRadius: '6px', resize: 'none' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Alert
|
|
||||||
message="格式说明"
|
|
||||||
description={
|
|
||||||
<div style={{ fontSize: '12px', lineHeight: '1.8' }}>
|
|
||||||
<div>• <strong>单个端口:</strong>eth0/1、gigabitethernet1/0/1</div>
|
|
||||||
<div>• <strong>端口范围:</strong>1/0/1-1/0/48(创建 1/0/1 到 1/0/48 共48个端口)</div>
|
|
||||||
<div>• <strong>简单范围:</strong>eth1-eth24(创建 eth1 到 eth24 共24个端口)</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: designTokens.colors.info.bg,
|
|
||||||
border: `1px solid ${designTokens.colors.info.light}40`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -715,19 +684,19 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
|
|||||||
</div>
|
</div>
|
||||||
<div style={styles.footerRight}>
|
<div style={styles.footerRight}>
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="middle"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
style={{ ...styles.button, minWidth: '80px' }}
|
style={{ ...styles.button, minWidth: '70px' }}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="middle"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
style={{ ...styles.button, ...styles.buttonPrimary, minWidth: '120px' }}
|
style={{ ...styles.button, ...styles.buttonPrimary, minWidth: '100px' }}
|
||||||
>
|
>
|
||||||
{portCount > 1 ? `创建 ${portCount} 个` : '创建'}
|
{portCount > 1 ? `创建 ${portCount} 个` : '创建'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal, Form, Select, Button, Typography, Alert } from 'antd';
|
||||||
|
import { ExportOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { designTokens } from '../config/theme';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const modalHeaderStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PortExportModal = ({
|
||||||
|
visible,
|
||||||
|
filters,
|
||||||
|
totalCount,
|
||||||
|
currentPageCount,
|
||||||
|
selectedCount,
|
||||||
|
onExport,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [exportScope, setExportScope] = useState('filtered');
|
||||||
|
const [exportFormat, setExportFormat] = useState('xlsx');
|
||||||
|
const [exportLoading, setExportLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
await onExport({
|
||||||
|
scope: exportScope,
|
||||||
|
format: exportFormat,
|
||||||
|
});
|
||||||
|
onCancel();
|
||||||
|
} finally {
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScopeLabel = () => {
|
||||||
|
switch (exportScope) {
|
||||||
|
case 'selected':
|
||||||
|
return `已选端口 (${selectedCount} 个)`;
|
||||||
|
case 'currentPage':
|
||||||
|
return `当前页 (${currentPageCount} 个)`;
|
||||||
|
case 'filtered':
|
||||||
|
return `筛选结果 (${totalCount} 个)`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveFilterInfo = () => {
|
||||||
|
const activeFilters = [];
|
||||||
|
if (filters.deviceId) activeFilters.push(`设备ID: ${filters.deviceId}`);
|
||||||
|
if (filters.status && filters.status !== 'all') activeFilters.push(`状态: ${filters.status}`);
|
||||||
|
if (filters.portType && filters.portType !== 'all') activeFilters.push(`类型: ${filters.portType}`);
|
||||||
|
if (filters.portSpeed && filters.portSpeed !== 'all') activeFilters.push(`速率: ${filters.portSpeed}`);
|
||||||
|
if (filters.searchText) activeFilters.push(`搜索: ${filters.searchText}`);
|
||||||
|
return activeFilters;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFilters = getActiveFilterInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
||||||
|
<ExportOutlined style={{ color: '#fa8c16' }} />
|
||||||
|
导出端口数据
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={[
|
||||||
|
<Button
|
||||||
|
key="cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: designTokens.borderRadius.sm,
|
||||||
|
border: `1px solid ${designTokens.colors.border.light}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={exportLoading}
|
||||||
|
onClick={handleExport}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: designTokens.borderRadius.sm,
|
||||||
|
background: designTokens.colors.primary.gradient,
|
||||||
|
border: 'none',
|
||||||
|
color: '#ffffff',
|
||||||
|
boxShadow: designTokens.shadows.small,
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
destroyOnHidden
|
||||||
|
styles={{
|
||||||
|
header: {
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
padding: '16px 24px',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
body: { padding: '24px' },
|
||||||
|
}}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="导出范围">
|
||||||
|
<Select value={exportScope} onChange={setExportScope} style={{ width: '100%' }}>
|
||||||
|
<Option value="filtered">{getScopeLabel()}</Option>
|
||||||
|
<Option value="currentPage">当前页 ({currentPageCount} 个)</Option>
|
||||||
|
<Option value="all">全部端口</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="导出格式">
|
||||||
|
<Select value={exportFormat} onChange={setExportFormat} style={{ width: '100%' }}>
|
||||||
|
<Option value="xlsx">Excel 格式 (.xlsx)</Option>
|
||||||
|
<Option value="csv">CSV 格式 (.csv)</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{activeFilters.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: '13px' }}>
|
||||||
|
当前筛选条件:
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
{activeFilters.map((filter, index) => (
|
||||||
|
<span key={index} style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: designTokens.colors.neutral[100],
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginRight: '4px',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
{filter}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
message={
|
||||||
|
<div>
|
||||||
|
<Text style={{ fontSize: '13px' }}>
|
||||||
|
导出将包含以下字段:端口ID、设备ID、设备名称、设备类型、机房、机架、网卡名称、端口名称、端口类型、端口速率、状态、VLAN ID、描述、创建时间
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ marginTop: '12px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{totalCount > 50000 && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={
|
||||||
|
<Text style={{ fontSize: '13px' }}>
|
||||||
|
当前数据量较大({totalCount} 条),导出可能需要较长时间。系统最大支持导出 50000 条数据。
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
style={{ marginTop: '12px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(PortExportModal);
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Tag, Badge, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
CloudServerOutlined,
|
||||||
|
GatewayOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
AudioOutlined,
|
||||||
|
QuestionOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { designTokens } from '../config/theme';
|
||||||
|
|
||||||
|
const DEVICE_TYPE_CONFIG = {
|
||||||
|
server: {
|
||||||
|
icon: <CloudServerOutlined />,
|
||||||
|
color: designTokens.colors.device.server,
|
||||||
|
label: '服务器',
|
||||||
|
},
|
||||||
|
switch: {
|
||||||
|
icon: <GatewayOutlined />,
|
||||||
|
color: designTokens.colors.device.switch,
|
||||||
|
label: '交换机',
|
||||||
|
},
|
||||||
|
router: {
|
||||||
|
icon: <GatewayOutlined />,
|
||||||
|
color: designTokens.colors.device.router,
|
||||||
|
label: '路由器',
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
icon: <DatabaseOutlined />,
|
||||||
|
color: designTokens.colors.device.storage,
|
||||||
|
label: '存储设备',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
icon: <QuestionOutlined />,
|
||||||
|
color: designTokens.colors.device.other,
|
||||||
|
label: '其他设备',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
running: { color: '#10b981', text: '运行中', bg: '#ecfdf5' },
|
||||||
|
maintenance: { color: '#3b82f6', text: '维护中', bg: '#eff6ff' },
|
||||||
|
offline: { color: '#6b7280', text: '离线', bg: '#f3f4f6' },
|
||||||
|
fault: { color: '#ef4444', text: '故障', bg: '#fef2f2' },
|
||||||
|
idle: { color: '#36cfc9', text: '空闲', bg: '#e6fffb' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ServerNicCard({ server, onManage }) {
|
||||||
|
const typeConfig = DEVICE_TYPE_CONFIG[server.type] || DEVICE_TYPE_CONFIG.other;
|
||||||
|
const statusConfig = STATUS_CONFIG[server.status] || STATUS_CONFIG.offline;
|
||||||
|
|
||||||
|
const nicCount = server.nicCount || (server.nics?.length || 0);
|
||||||
|
const totalPortCount = server.nics?.reduce((sum, nic) => sum + (nic.portCount || 0), 0) || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={onManage}
|
||||||
|
style={{
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
border: `1px solid ${designTokens.colors.border.light}`,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
body: { padding: '16px' },
|
||||||
|
}}
|
||||||
|
className="server-nic-card"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: `linear-gradient(135deg, ${typeConfig.color} 0%, ${typeConfig.color}cc 100%)`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '20px',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: `0 4px 12px ${typeConfig.color}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeConfig.icon}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Tooltip title={server.name}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '14px',
|
||||||
|
color: designTokens.colors.text.primary,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={server.deviceId}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: designTokens.colors.text.tertiary,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ID: {server.deviceId}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
style={{
|
||||||
|
background: `${typeConfig.color}15`,
|
||||||
|
color: typeConfig.color,
|
||||||
|
border: `1px solid ${typeConfig.color}30`,
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeConfig.icon} {typeConfig.label}
|
||||||
|
</Tag>
|
||||||
|
<Tag
|
||||||
|
style={{
|
||||||
|
background: statusConfig.bg,
|
||||||
|
color: statusConfig.color,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusConfig.text}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
background: designTokens.colors.background.secondary,
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 700, color: designTokens.colors.primary.main }}>
|
||||||
|
{nicCount}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: designTokens.colors.text.secondary }}>
|
||||||
|
网卡
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', borderLeft: `1px solid ${designTokens.colors.border.light}`, borderRight: `1px solid ${designTokens.colors.border.light}` }}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 700, color: designTokens.colors.info.main }}>
|
||||||
|
{totalPortCount}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: designTokens.colors.text.secondary }}>
|
||||||
|
端口
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Badge
|
||||||
|
status={server.nicCount > 0 ? 'success' : 'default'}
|
||||||
|
text={
|
||||||
|
<span style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
|
||||||
|
{server.nicCount > 0 ? '已配置' : '未配置'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: designTokens.colors.primary.main,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onManage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理 →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.server-nic-card:hover {
|
||||||
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15) !important;
|
||||||
|
border-color: ${designTokens.colors.primary.light} !important;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(ServerNicCard);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
|
Spin,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -130,10 +131,19 @@ function DeviceManagement() {
|
|||||||
|
|
||||||
const debouncedKeyword = useDebounce(keyword, DEBOUNCE_DELAY);
|
const debouncedKeyword = useDebounce(keyword, DEBOUNCE_DELAY);
|
||||||
|
|
||||||
|
const loadMoreRef = useRef(null);
|
||||||
|
const hasMoreRef = useRef(true);
|
||||||
|
const isLoadingRef = useRef(false);
|
||||||
|
const [deviceLoadingMore, setDeviceLoadingMore] = useState(false);
|
||||||
|
|
||||||
const fetchDevices = useCallback(
|
const fetchDevices = useCallback(
|
||||||
async (page = 1, pageSize = 10, forceRefresh = false) => {
|
async (page = 1, pageSize = 50, append = false) => {
|
||||||
try {
|
try {
|
||||||
|
if (append) {
|
||||||
|
setDeviceLoadingMore(true);
|
||||||
|
} else {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
page,
|
page,
|
||||||
@@ -150,13 +160,19 @@ function DeviceManagement() {
|
|||||||
|
|
||||||
const processedDevices = deviceList.map(processDeviceData);
|
const processedDevices = deviceList.map(processDeviceData);
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setAllDevices(prev => [...prev, ...processedDevices]);
|
||||||
|
} else {
|
||||||
setAllDevices(processedDevices);
|
setAllDevices(processedDevices);
|
||||||
|
}
|
||||||
setPagination((prev) => ({ ...prev, current: page, pageSize, total }));
|
setPagination((prev) => ({ ...prev, current: page, pageSize, total }));
|
||||||
|
hasMoreRef.current = page * pageSize < total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取设备列表失败');
|
message.error('获取设备列表失败');
|
||||||
console.error('获取设备列表失败:', error);
|
console.error('获取设备列表失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setDeviceLoadingMore(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[debouncedKeyword, status, type, roomId, rackId]
|
[debouncedKeyword, status, type, roomId, rackId]
|
||||||
@@ -257,6 +273,39 @@ function DeviceManagement() {
|
|||||||
}
|
}
|
||||||
}, [allDevices, pagination.current, pagination.pageSize]);
|
}, [allDevices, pagination.current, pagination.pageSize]);
|
||||||
|
|
||||||
|
const handleLoadMoreDevices = useCallback(() => {
|
||||||
|
if (!hasMoreRef.current || isLoadingRef.current || deviceLoadingMore) return;
|
||||||
|
if (debouncedKeyword || status !== 'all' || type !== 'all' || roomId !== 'all' || rackId !== 'all') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isLoadingRef.current = true;
|
||||||
|
const nextPage = pagination.current + 1;
|
||||||
|
fetchDevices(nextPage, pagination.pageSize, true).then(() => {
|
||||||
|
isLoadingRef.current = false;
|
||||||
|
});
|
||||||
|
}, [pagination.current, pagination.pageSize, debouncedKeyword, status, type, roomId, rackId, deviceLoadingMore, fetchDevices]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadMoreRef.current) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
if (entries[0].isIntersecting && hasMoreRef.current && !deviceLoadingMore) {
|
||||||
|
if (!debouncedKeyword && status === 'all' && type === 'all' && roomId === 'all' && rackId === 'all') {
|
||||||
|
handleLoadMoreDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '100px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(loadMoreRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [handleLoadMoreDevices, deviceLoadingMore, debouncedKeyword, status, type, roomId, rackId]);
|
||||||
|
|
||||||
const showModal = (device = null) => {
|
const showModal = (device = null) => {
|
||||||
setEditingDevice(device);
|
setEditingDevice(device);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
@@ -278,7 +327,7 @@ function DeviceManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
fetchDevices();
|
fetchDevices(1, pagination.pageSize, false);
|
||||||
setEditingDevice(null);
|
setEditingDevice(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error.response?.data?.error || error.message || '未知错误';
|
const errorMsg = error.response?.data?.error || error.message || '未知错误';
|
||||||
@@ -322,7 +371,7 @@ function DeviceManagement() {
|
|||||||
const currentPageData = allDevices.slice(start, end);
|
const currentPageData = allDevices.slice(start, end);
|
||||||
setCurrentPageDevices(currentPageData);
|
setCurrentPageDevices(currentPageData);
|
||||||
|
|
||||||
fetchDevices(newPagination.current, newPagination.pageSize);
|
fetchDevices(newPagination.current, newPagination.pageSize, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
@@ -340,7 +389,7 @@ function DeviceManagement() {
|
|||||||
message.success(response.data.message || '批量删除成功');
|
message.success(response.data.message || '批量删除成功');
|
||||||
setSelectedDevices([]);
|
setSelectedDevices([]);
|
||||||
setSelectAll(false);
|
setSelectAll(false);
|
||||||
fetchDevices(1, 10, true);
|
fetchDevices(1, 50, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('批量删除失败');
|
message.error('批量删除失败');
|
||||||
console.error('批量删除设备失败:', error);
|
console.error('批量删除设备失败:', error);
|
||||||
@@ -367,7 +416,7 @@ function DeviceManagement() {
|
|||||||
message.success(response.data.message || '成功删除所有设备');
|
message.success(response.data.message || '成功删除所有设备');
|
||||||
setSelectedDevices([]);
|
setSelectedDevices([]);
|
||||||
setSelectAll(false);
|
setSelectAll(false);
|
||||||
fetchDevices(1, 10, true);
|
fetchDevices(1, 50, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('删除所有设备失败');
|
message.error('删除所有设备失败');
|
||||||
console.error('删除所有设备失败:', error);
|
console.error('删除所有设备失败:', error);
|
||||||
@@ -395,7 +444,7 @@ function DeviceManagement() {
|
|||||||
message.success(response.data.message || '设备已标记为空闲');
|
message.success(response.data.message || '设备已标记为空闲');
|
||||||
setSelectedDevices([]);
|
setSelectedDevices([]);
|
||||||
setSelectAll(false);
|
setSelectAll(false);
|
||||||
fetchDevices(1, 10, true);
|
fetchDevices(1, 50, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.error || '标记为空闲失败');
|
message.error(error.response?.data?.error || '标记为空闲失败');
|
||||||
console.error('标记为空闲失败:', error);
|
console.error('标记为空闲失败:', error);
|
||||||
@@ -415,7 +464,7 @@ function DeviceManagement() {
|
|||||||
try {
|
try {
|
||||||
await axios.delete(`/api/devices/${deviceId}`);
|
await axios.delete(`/api/devices/${deviceId}`);
|
||||||
message.success('设备删除成功');
|
message.success('设备删除成功');
|
||||||
fetchDevices();
|
fetchDevices(1, pagination.pageSize, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('设备删除失败');
|
message.error('设备删除失败');
|
||||||
console.error('设备删除失败:', error);
|
console.error('设备删除失败:', error);
|
||||||
@@ -461,7 +510,7 @@ function DeviceManagement() {
|
|||||||
setBatchStatusModalVisible(false);
|
setBatchStatusModalVisible(false);
|
||||||
setSelectedDevices([]);
|
setSelectedDevices([]);
|
||||||
setSelectAll(false);
|
setSelectAll(false);
|
||||||
fetchDevices();
|
fetchDevices(1, pagination.pageSize, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('批量状态变更失败');
|
message.error('批量状态变更失败');
|
||||||
console.error('批量状态变更失败:', error);
|
console.error('批量状态变更失败:', error);
|
||||||
@@ -552,7 +601,7 @@ function DeviceManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetchDevices();
|
fetchDevices(1, pagination.pageSize, false);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let errorMessage = '导入失败';
|
let errorMessage = '导入失败';
|
||||||
@@ -1055,7 +1104,7 @@ function DeviceManagement() {
|
|||||||
</Space>
|
</Space>
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={() => fetchDevices(1, pagination.pageSize, true)}
|
onClick={() => fetchDevices(1, pagination.pageSize, false)}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: designTokens.borderRadius.medium,
|
borderRadius: designTokens.borderRadius.medium,
|
||||||
border: `1px solid ${designTokens.colors.border.light}`,
|
border: `1px solid ${designTokens.colors.border.light}`,
|
||||||
@@ -1234,6 +1283,16 @@ function DeviceManagement() {
|
|||||||
return index % 2 === 0 ? 'ant-table-row-even' : 'ant-table-row-odd';
|
return index % 2 === 0 ? 'ant-table-row-even' : 'ant-table-row-odd';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{hasMoreRef.current && !debouncedKeyword && status === 'all' && type === 'all' && roomId === 'all' && rackId === 'all' && (
|
||||||
|
<div ref={loadMoreRef} style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
{deviceLoadingMore && <Spin tip="加载更多设备..." />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasMoreRef.current && allDevices.length > 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '16px', color: '#999' }}>
|
||||||
|
已加载全部 {pagination.total} 个设备
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user