diff --git a/.gitignore b/.gitignore
index 9a09151..7f2f261 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,4 +44,8 @@ lerna-debug.log*
# Backup files (generated by maintenance scripts)
backend/backups/
-*.backup.json
\ No newline at end of file
+*.backup.json
+
+#数据文件
+*.xlsx
+*.csv
diff --git a/backend/routes/devicePorts.js b/backend/routes/devicePorts.js
index d3ff830..9982365 100644
--- a/backend/routes/devicePorts.js
+++ b/backend/routes/devicePorts.js
@@ -60,7 +60,8 @@ router.get('/', async (req, res) => {
});
} catch (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('缺少必填字段');
}
+ 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({
where: { deviceId: portData.deviceId, portName: portData.portName },
transaction
@@ -183,7 +226,8 @@ router.post('/batch', async (req, res) => {
portSpeed: portData.portSpeed || existingPort.portSpeed,
status: portData.status || existingPort.status,
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 },
transaction
@@ -213,6 +257,8 @@ router.post('/batch', async (req, res) => {
results.errors.push({
index: i + 1,
portId: portData.portId,
+ deviceId: portData.deviceId,
+ portName: portData.portName,
error: error.message
});
}
@@ -351,11 +397,11 @@ router.get('/:portId', async (req, res) => {
}
]
});
-
+
if (!port) {
return res.status(404).json({ error: '端口不存在' });
}
-
+
res.json(port);
} catch (error) {
console.error('获取端口详情失败:', error);
@@ -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;
diff --git a/backend/routes/networkCards.js b/backend/routes/networkCards.js
index add8c33..05d2f85 100644
--- a/backend/routes/networkCards.js
+++ b/backend/routes/networkCards.js
@@ -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) => {
try {
const [updated] = await NetworkCard.update(req.body, {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 508d9d0..3c42935 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -261,16 +261,16 @@ const AppLayout = ({ children }) => {
icon: ,
label: 字段管理,
},
- {
- key: 'cables',
- icon: ,
- label: 接线管理,
- },
{
key: 'ports',
icon: ,
label: 端口管理,
},
+ {
+ key: 'cables',
+ icon: ,
+ label: 接线管理,
+ },
],
},
{
diff --git a/frontend/src/components/BatchImportModal.jsx b/frontend/src/components/BatchImportModal.jsx
new file mode 100644
index 0000000..70e9d66
--- /dev/null
+++ b/frontend/src/components/BatchImportModal.jsx
@@ -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 (
+
+
+
+
+ 批量导入
+
+ }
+ open={visible}
+ closeIcon={}
+ onCancel={onClose}
+ footer={null}
+ width={500}
+ >
+
+
+
}
+ onClick={() => {
+ onClose();
+ onImportNetworkCard();
+ }}
+ style={{
+ flex: 1,
+ height: '80px',
+ borderRadius: designTokens.borderRadius.md,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '8px',
+ }}
+ >
+
批量导入网卡
+
+ 用于服务器设备
+
+
+
+
}
+ onClick={() => {
+ onClose();
+ onImportPort();
+ }}
+ style={{
+ flex: 1,
+ height: '80px',
+ borderRadius: designTokens.borderRadius.md,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '8px',
+ }}
+ >
+
批量导入端口
+
+ 用于所有设备
+
+
+
+
+
+
+
+
+
+
+
+ 交换机与服务器导入说明
+
+
+
+ 交换机端口:可直接批量导入端口,无需先导入网卡
+
+
+ 服务器端口:必须先在"网卡管理"中添加网卡,才能导入端口。
+ 服务器的网卡和端口是层级关系:设备 → 网卡 → 端口
+
+
+
+
+
+
+
+ );
+}
+
+export default BatchImportModal;
diff --git a/frontend/src/components/NetworkCardCreateModal.jsx b/frontend/src/components/NetworkCardCreateModal.jsx
index 8b097e0..a073c79 100644
--- a/frontend/src/components/NetworkCardCreateModal.jsx
+++ b/frontend/src/components/NetworkCardCreateModal.jsx
@@ -1,17 +1,44 @@
-import React, { useState, useCallback } from 'react';
-import { Modal, Form, Input, InputNumber, Select, message, Space, Tooltip } from 'antd';
-import { PlusOutlined, InfoCircleOutlined, CloudServerOutlined } from '@ant-design/icons';
+import React, { useState, useCallback, useEffect } from 'react';
+import { Modal, Form, Input, Select, message, Space, Tooltip, Card, Alert, AutoComplete } from 'antd';
+import { CloudServerOutlined, InfoCircleOutlined, QuestionCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import axios from 'axios';
import { designTokens } from '../config/theme';
import CloseButton from './CloseButton';
-const { Option } = Select;
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 }) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
+ useEffect(() => {
+ if (visible) {
+ form.resetFields();
+ }
+ }, [visible, form]);
+
const handleSubmit = useCallback(async () => {
try {
const values = await form.validateFields();
@@ -24,7 +51,6 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
description: values.description,
model: values.model,
manufacturer: values.manufacturer,
- status: values.status,
});
message.success('网卡创建成功');
@@ -62,67 +88,157 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
confirmLoading={loading}
okText="创建"
cancelText="取消"
- width={480}
- styles={{ body: { padding: '20px 24px' } }}
+ width={800}
+ styles={{ body: { padding: '0 24px 24px' } }}
>
+
+
+ 为服务器添加新的网卡,网卡创建后可关联端口
+
+
+
+
+ 插槽编号参考
+
+ - LOM (LAN on Motherboard):主板集成网卡,编号通常为 0
+ - OCP (Open Compute Project):服务器前端维护网卡专用槽位
+ - PCIe 插槽:从 1 开始编号,对应服务器物理插槽位置
+
+
+ }
+ type="info"
+ style={{
+ marginBottom: 20,
+ borderRadius: 10,
+ background: designTokens.colors.info.bg,
+ border: `1px solid ${designTokens.colors.info.light}40`,
+ }}
+ />
+
+
+
+ 基础信息
+
+ }
+ style={{
+ flex: 1,
+ borderRadius: 12,
+ border: `1px solid ${designTokens.colors.neutral[200]}`,
+ }}
+ styles={{ body: { padding: '16px 20px' } }}
+ >
+ 网卡名称 *}
+ rules={[
+ { required: true, message: '请输入网卡名称' },
+ { max: 50, message: '名称不能超过50个字符' },
+ ]}
+ >
+
+
+
+
+ 插槽位置
+
+
+
+
+ }
+ >
+ ({
+ value: slot.value,
+ label: (
+
+
{slot.label}
+
+ {slot.description}
+
+
+ ),
+ }))}
+ filterOption={(input, option) =>
+ option.value.toLowerCase().includes(input.toLowerCase()) ||
+ option.label.props.children[0].props.children.toLowerCase().includes(input.toLowerCase())
+ }
+ />
+
+
+
+
+
+ 规格信息
+
+ }
+ style={{
+ flex: 1,
+ borderRadius: 12,
+ border: `1px solid ${designTokens.colors.neutral[200]}`,
+ }}
+ styles={{ body: { padding: '16px 20px' } }}
+ >
+
+
制造商}>
+ ({ value: m.value, label: m.label }))}
+ filterOption={(input, option) =>
+ option.label.toLowerCase().includes(input.toLowerCase())
+ }
+ size="large"
+ />
+
+
+
型号}>
+
+
+
+
+
+
+
- 网卡名称
-
-
-
+
+ 附加信息
}
- rules={[
- { required: true, message: '请输入网卡名称' },
- { max: 50, message: '名称不能超过50个字符' },
- ]}
+ style={{
+ borderRadius: 12,
+ border: `1px solid ${designTokens.colors.neutral[200]}`,
+ }}
+ styles={{ body: { padding: '16px 20px' } }}
>
-
-
-
-
-
-
+ 描述}>
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
);
diff --git a/frontend/src/components/NetworkCardImportModal.jsx b/frontend/src/components/NetworkCardImportModal.jsx
new file mode 100644
index 0000000..e7f66be
--- /dev/null
+++ b/frontend/src/components/NetworkCardImportModal.jsx
@@ -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: ,
+ });
+ }
+
+ 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 (
+
+
+
+
+ 批量导入网卡
+
+ }
+ open={visible}
+ closeIcon={}
+ onCancel={handleClose}
+ width={900}
+ footer={[
+ ,
+ }
+ onClick={handleDownloadTemplate}
+ style={{ borderRadius: designTokens.borderRadius.sm }}
+ >
+ 下载模板
+ ,
+ }
+ onClick={handleBatchImport}
+ loading={importing}
+ disabled={importPreview.length === 0}
+ style={{
+ background: designTokens.colors.primary.gradient,
+ border: 'none',
+ borderRadius: designTokens.borderRadius.sm,
+ }}
+ >
+ 开始导入
+ ,
+ ]}
+ >
+
+
+ • 网卡批量导入用于服务器设备,请确保先在设备管理中添加服务器
+ • 模板中的"设备ID"必须与已存在的设备对应
+ • 请先下载模板,按模板格式填写数据后再上传
+
+ }
+ type="info"
+ showIcon
+ style={{
+ borderRadius: designTokens.borderRadius.md,
+ background: designTokens.colors.info.bg,
+ border: `1px solid ${designTokens.colors.info.light}40`,
+ marginBottom: '16px',
+ }}
+ />
+
+ {
+ handleFileUpload(file, null);
+ return false;
+ }}
+ style={{
+ borderRadius: designTokens.borderRadius.lg,
+ border: `2px dashed ${designTokens.colors.primary.light}`,
+ background: designTokens.colors.primary.bg,
+ }}
+ >
+
+
+
+
+ 点击或拖拽文件到此处上传
+
+
+ 支持 .xlsx, .xls, .csv 格式文件
+
+
+
+
+ setSkipExisting(e.target.checked)}>
+ 跳过已存在的网卡
+
+ setUpdateExisting(e.target.checked)}>
+ 更新已存在的网卡
+
+
+
+ {importErrors.length > 0 && (
+
+
+ {row},
+ },
+ {
+ title: '字段',
+ dataIndex: 'field',
+ key: 'field',
+ width: 100,
+ render: field => {field},
+ },
+ {
+ title: '错误值',
+ dataIndex: 'value',
+ key: 'value',
+ width: 120,
+ render: val => {val},
+ },
+ {
+ title: '错误原因',
+ dataIndex: 'error',
+ key: 'error',
+ render: err => {err},
+ },
+ {
+ title: '修正建议',
+ dataIndex: 'suggestion',
+ key: 'suggestion',
+ render: sug => {sug},
+ },
+ ]}
+ 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' }}
+ />
+
+ }
+ type="error"
+ showIcon
+ style={{ borderRadius: designTokens.borderRadius.md }}
+ />
+
+ )}
+
+ {importPreview.length > 0 && (
+
+
+ 成功解析 {importPreview.length} 条有效数据
+ {importErrors.length > 0 && (
+
+ ({importErrors.length} 条错误)
+
+ )}
+
+ }
+ type={importErrors.length > 0 ? 'warning' : 'success'}
+ showIcon
+ style={{ marginBottom: '16px', borderRadius: designTokens.borderRadius.md }}
+ />
+
+ 数据预览(前10条)
+
+ `import-row-${index}`}
+ pagination={false}
+ size="small"
+ scroll={{ x: 700 }}
+ style={{ borderRadius: designTokens.borderRadius.md }}
+ />
+ {importPreview.length > 10 && (
+
+ 仅显示前10条数据,共 {importPreview.length} 条
+
+ )}
+
+ )}
+
+ {importing && (
+
+
+
+
+
+ 正在导入 {importProgress.current} / {importProgress.total} 条数据...
+
+
+
+ )}
+
+
+ );
+}
+
+export default NetworkCardImportModal;
diff --git a/frontend/src/components/NetworkCardPanel.jsx b/frontend/src/components/NetworkCardPanel.jsx
index 05f94db..eebdb6c 100644
--- a/frontend/src/components/NetworkCardPanel.jsx
+++ b/frontend/src/components/NetworkCardPanel.jsx
@@ -426,6 +426,8 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
}}
onSuccess={handleCreatePortSuccess}
defaultNicId={selectedCard?.nicId}
+ networkCard={selectedCard}
+ disableNicChange={true}
/>
);
diff --git a/frontend/src/components/PortAddGuideModal.jsx b/frontend/src/components/PortAddGuideModal.jsx
new file mode 100644
index 0000000..224dec4
--- /dev/null
+++ b/frontend/src/components/PortAddGuideModal.jsx
@@ -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 (
+ }
+ onCancel={onClose}
+ footer={null}
+ width={560}
+ zIndex={1050}
+ style={{ borderRadius: '16px', top: 80 }}
+ styles={{ body: { padding: 0 } }}
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+ 选择端口类型
+
+
+ 请选择要添加的端口类型
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 端口类型说明
+
+
+
+
+
+
+
+
+
+ 交换机端口
+
+
+ 交换机端口用于网络设备间的连接,可以直接创建端口,无需关联网卡。适用于创建 Uplink 端口、Trunk 端口等。
+
+
+
+
+
+
+
+
+ 服务器端口
+
+
+ 服务器端口必须关联网卡(Network Card),每个端口需要对应一个物理或虚拟网卡。请先在网卡管理中添加网卡。
+
+
+
+
+
+
+
+ 提示:如果服务器尚未添加网卡,系统会引导您先前往网卡管理添加网卡后再创建端口。
+
+ }
+ type="info"
+ showIcon
+ icon={}
+ style={{
+ marginTop: '16px',
+ borderRadius: '8px',
+ background: designTokens.colors.info.bg,
+ border: `1px solid ${designTokens.colors.info.light}40`,
+ }}
+ />
+
+
+
+
+
+
+ );
+};
+
+export default PortAddGuideModal;
diff --git a/frontend/src/components/PortCreateModal.jsx b/frontend/src/components/PortCreateModal.jsx
index fbdb7d2..efa6b6e 100644
--- a/frontend/src/components/PortCreateModal.jsx
+++ b/frontend/src/components/PortCreateModal.jsx
@@ -12,6 +12,8 @@ import {
Alert,
Tag,
Divider,
+ Row,
+ Col,
} from 'antd';
import {
PlusOutlined,
@@ -89,34 +91,38 @@ function generatePortNames(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 [loading, setLoading] = useState(false);
const [previewPorts, setPreviewPorts] = useState([]);
const [showPreview, setShowPreview] = useState(false);
const [nicList, setNicList] = useState([]);
- const [activeStep, setActiveStep] = useState(1);
const prevVisibleRef = useRef(false);
useEffect(() => {
if (visible && !prevVisibleRef.current) {
setPreviewPorts([]);
setShowPreview(false);
- setActiveStep(1);
form.resetFields();
- if (defaultNicId) {
- form.setFieldsValue({ nicId: defaultNicId });
- }
-
- if (device?.deviceId && (!networkCards || networkCards.length === 0)) {
- fetchNetworkCards();
- } else if (networkCards && networkCards.length > 0) {
- setNicList(networkCards);
+ if (networkCard) {
+ setNicList([networkCard]);
+ if (networkCard.nicId) {
+ form.setFieldsValue({ nicId: networkCard.nicId });
+ }
+ } else {
+ if (defaultNicId) {
+ form.setFieldsValue({ nicId: defaultNicId });
+ }
+ if (device?.deviceId && (!networkCards || networkCards.length === 0)) {
+ fetchNetworkCards();
+ } else if (networkCards && networkCards.length > 0) {
+ setNicList(networkCards);
+ }
}
}
prevVisibleRef.current = visible;
- }, [visible, device, defaultNicId, networkCards, form]);
+ }, [visible, device, defaultNicId, networkCards, networkCard, form]);
const fetchNetworkCards = async () => {
try {
@@ -147,11 +153,12 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
setLoading(true);
const portNames = generatePortNames(values.portName);
+ const finalNicId = disableNicChange && defaultNicId ? defaultNicId : (values.nicId || null);
if (portNames.length === 1) {
await axios.post('/api/device-ports', {
deviceId: device.deviceId,
- nicId: values.nicId || null,
+ nicId: finalNicId,
portName: portNames[0],
portType: values.portType,
portSpeed: values.portSpeed,
@@ -164,7 +171,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
const portsData = portNames.map((portName, index) => ({
portId: `PORT-${Date.now()}-${index}`,
deviceId: device.deviceId,
- nicId: values.nicId || null,
+ nicId: finalNicId,
portName,
portType: values.portType,
portSpeed: values.portSpeed,
@@ -232,61 +239,37 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
padding: '24px',
background: '#fff',
},
- stepContainer: {
+ twoColumnLayout: {
+ marginBottom: '16px',
+ },
+ column: {
display: 'flex',
- justifyContent: 'center',
- marginBottom: '24px',
- },
- 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,
+ flexDirection: 'column',
+ gap: '16px',
+ height: '100%',
},
section: {
background: designTokens.colors.neutral[50],
borderRadius: '12px',
- padding: '20px',
- marginBottom: '16px',
+ padding: '16px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
},
sectionTitle: {
- fontSize: '14px',
+ fontSize: '13px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
- marginBottom: '16px',
+ marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
+ paddingBottom: '8px',
+ borderBottom: `1px solid ${designTokens.colors.neutral[200]}`,
},
fieldLabel: {
- fontSize: '13px',
+ fontSize: '12px',
fontWeight: 500,
color: designTokens.colors.neutral[700],
- marginBottom: '6px',
+ marginBottom: '4px',
},
input: {
borderRadius: '8px',
@@ -299,16 +282,16 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
},
previewCard: {
background: `linear-gradient(135deg, ${designTokens.colors.info.bg} 0%, ${designTokens.colors.primary.bg} 100%)`,
- borderRadius: '12px',
- padding: '16px',
- marginTop: '16px',
+ borderRadius: '10px',
+ padding: '12px',
+ marginTop: '12px',
border: `1px solid ${designTokens.colors.primary.light}20`,
},
previewTitle: {
- fontSize: '13px',
+ fontSize: '12px',
fontWeight: 600,
color: designTokens.colors.primary.dark,
- marginBottom: '12px',
+ marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '6px',
@@ -316,15 +299,16 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
previewTags: {
display: 'flex',
flexWrap: 'wrap',
- gap: '6px',
+ gap: '4px',
},
previewTag: {
background: '#fff',
border: `1px solid ${designTokens.colors.primary.light}`,
color: designTokens.colors.primary.main,
- borderRadius: '6px',
- fontSize: '12px',
+ borderRadius: '4px',
+ fontSize: '11px',
fontWeight: 500,
+ padding: '2px 6px',
},
footer: {
padding: '16px 24px',
@@ -343,7 +327,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
gap: '12px',
},
button: {
- height: '40px',
+ height: '38px',
borderRadius: '8px',
fontWeight: 500,
transition: 'all 0.2s ease',
@@ -357,28 +341,33 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
background: designTokens.colors.success.bg,
border: `1px solid ${designTokens.colors.success.light}`,
borderRadius: '8px',
- padding: '12px 16px',
+ padding: '10px 12px',
marginTop: '8px',
display: 'flex',
alignItems: 'center',
- gap: '10px',
+ gap: '8px',
},
nicSelectedIcon: {
color: designTokens.colors.success.main,
- fontSize: '18px',
+ fontSize: '16px',
},
nicSelectedInfo: {
flex: 1,
},
nicSelectedName: {
- fontSize: '14px',
+ fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.success.dark,
},
nicSelectedSlot: {
- fontSize: '12px',
+ fontSize: '11px',
color: designTokens.colors.success.main,
},
+ alertBox: {
+ borderRadius: '8px',
+ background: designTokens.colors.info.bg,
+ border: `1px solid ${designTokens.colors.info.light}40`,
+ },
};
return (
@@ -387,7 +376,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
closeIcon={}
onCancel={handleCancel}
footer={null}
- width={600}
+ width={720}
zIndex={1050}
style={styles.modal}
styles={{ body: { padding: 0 } }}
@@ -411,221 +400,216 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne