Files
yunrui_asset/frontend/src/pages/PortManagement.jsx
T
zhang1106 755712bdb6 feat(ports): add device-grouped port list API and frontend implementation
1. 新增按设备分组的端口查询后端API,修复端口过多时部分设备不显示的分页问题
2. 重构前端端口管理页面,改为设备维度分页并移除旧的端口级分页逻辑
3. 统一端口名称自然排序逻辑,优化页面展示顺序
4. 更新部署文档中的容器目录路径
5. 升级项目所有package.json版本到2.1.1
6. 新增两个提案文档到gitignore
2026-06-17 10:21:19 +08:00

4056 lines
150 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
message,
Card,
Space,
Popconfirm,
Tag,
Tooltip,
InputNumber,
Collapse,
Empty,
Spin,
Upload,
Progress,
Checkbox,
Badge,
Row,
Col,
Skeleton,
Alert,
Typography,
Divider,
Pagination,
Segmented,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
ExportOutlined,
ImportOutlined,
DownloadOutlined,
UploadOutlined as UploadIcon,
AppstoreOutlined,
FilterOutlined,
ClearOutlined,
CloudServerOutlined,
ApiOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
DisconnectOutlined,
UpOutlined,
DownOutlined,
TagOutlined,
ThunderboltOutlined,
FileTextOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import api from '../api';
import { roomAPI, rackAPI } from '../api/cache';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
import { motion, AnimatePresence } from 'framer-motion';
import NetworkCardPanel from '../components/NetworkCardPanel';
import NetworkCardCreateModal from '../components/NetworkCardCreateModal';
import NetworkCardImportModal from '../components/NetworkCardImportModal';
import BatchImportModal from '../components/BatchImportModal';
import PortAddGuideModal from '../components/PortAddGuideModal';
import ServerNicCard from '../components/ServerNicCard';
import PortExportModal from '../components/PortExportModal';
import { designTokens } from '../config/theme';
import CloseButton from '../components/CloseButton';
import { debounce } from '../utils/common';
const { Option } = Select;
const { Panel } = Collapse;
const { Text, Title } = Typography;
const { TextArea } = Input;
// 动画配置
const animations = {
container: {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.1,
},
},
},
item: {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
},
};
/**
* 按端口名称中的数字段进行自然排序
* @param {Array} ports - 端口数组
* @returns {Array} 排序后的新数组
*/
function sortPortsByName(ports) {
const extractNumbers = str => {
const matches = str ? String(str).match(/\d+/g) : null;
return matches ? matches.map(Number) : [];
};
return [...ports].sort((a, b) => {
const numsA = extractNumbers(a.portName);
const numsB = extractNumbers(b.portName);
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
if (numsA[i] !== numsB[i]) {
return numsA[i] - numsB[i];
}
}
return String(a.portName).localeCompare(String(b.portName));
});
}
function PortManagement() {
const [ports, setPorts] = useState([]);
const [devices, setDevices] = useState([]);
const [deviceSearching, setDeviceSearching] = useState(false);
// 按设备分组的端口列表(数组,每项 { device, ports }
const [groupedPorts, setGroupedPorts] = useState([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState({
deviceId: '',
});
const [modalVisible, setModalVisible] = useState(false);
const [editingPort, setEditingPort] = useState(null);
const [form] = Form.useForm();
const [importModalVisible, setImportModalVisible] = useState(false);
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);
// 视图模式:list 或 panel
// const [viewMode, setViewMode] = useState('list');
// 网卡管理相关状态
const [networkCardModalVisible, setNetworkCardModalVisible] = useState(false);
const [serverNicListVisible, setServerNicListVisible] = useState(false);
const [serverNicList, setServerNicList] = useState([]);
const [serverNicLoading, setServerNicLoading] = useState(false);
const [portCreateModalVisible, setPortCreateModalVisible] = useState(false);
const [selectedDeviceForNic, setSelectedDeviceForNic] = useState(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [networkCardImportModalVisible, setNetworkCardImportModalVisible] = useState(false);
const [batchImportModalVisible, setBatchImportModalVisible] = useState(false);
const [portAddGuideModalVisible, setPortAddGuideModalVisible] = useState(false);
const [portExportModalVisible, setPortExportModalVisible] = useState(false);
const [importDeviceType, setImportDeviceType] = useState(null);
// 展开的设备
const [expandedKeys, setExpandedKeys] = useState([]);
const [selectDeviceModalVisible, setSelectDeviceModalVisible] = useState(false);
const [selectedDeviceForPort, setSelectedDeviceForPort] = useState(null);
const [nicList, setNicList] = useState([]);
const [portMode, setPortMode] = useState('quick'); // 'range' | 'quick'
const [parseError, setParseError] = useState(null);
const [parseResult, setParseResult] = useState(null);
const [previewPorts, setPreviewPorts] = useState([]);
const [showPreview, setShowPreview] = useState(false);
// 批量选择相关状态
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 设备选择弹窗 Tab 状态
const [deviceFilterType, setDeviceFilterType] = useState('all');
const [guidedDeviceType, setGuidedDeviceType] = useState(null);
const [devicePage, setDevicePage] = useState(1);
// 机房机柜筛选状态
const [roomList, setRoomList] = useState([]);
const [rackList, setRackList] = useState([]);
const [selectedRoomId, setSelectedRoomId] = useState(null);
const [selectedRackId, setSelectedRackId] = useState(null);
const loadMoreRef = useRef(null);
const hasMoreRef = useRef(true);
const isLoadingRef = useRef(false);
// 设备卡片分页状态(按设备维度分页)
const [deviceCardPage, setDeviceCardPage] = useState(1);
const deviceCardPageSize = 10;
const [deviceCardTotal, setDeviceCardTotal] = useState(0);
// 端口总数(所有设备端口总和,用于统计与导出弹窗)
const [portTotal, setPortTotal] = useState(0);
// 服务器网卡卡片列表状态
const [serverNicSearchText, setServerNicSearchText] = useState('');
const [serverNicRoomFilter, setServerNicRoomFilter] = useState('all');
const [serverNicRackFilter, setServerNicRackFilter] = useState('all');
const [serverNicCardPage, setServerNicCardPage] = useState(1);
const serverNicCardPageSize = 12;
// 按设备分组获取端口(设备维度分页,避免端口数过多时部分设备不显示)
const fetchPorts = useCallback(
async (page = 1) => {
const validPage = Number.isInteger(page) && page > 0 ? page : 1;
try {
setLoading(true);
const params = {
page: validPage,
pageSize: deviceCardPageSize,
};
if (filters.deviceId) params.deviceId = filters.deviceId;
const response = await api.get('/device-ports/grouped', { params });
const groups = response.groups || [];
// 对每个设备的端口按名称自然排序,并展开为扁平数组(供统计、导出当前页使用)
const flatPorts = [];
const sortedGroups = groups.map(g => {
const sortedPorts = sortPortsByName(g.ports || []);
sortedPorts.forEach(p => flatPorts.push(p));
return { device: g.device, ports: sortedPorts };
});
setPorts(flatPorts);
setGroupedPorts(sortedGroups);
setDeviceCardTotal(response.total || 0);
setPortTotal(response.totalPorts || 0);
setDeviceCardPage(validPage);
} catch (error) {
message.error('获取端口列表失败');
console.error('获取端口列表失败:', error);
} finally {
setLoading(false);
}
},
[filters, deviceCardPageSize]
);
const fetchDevices = useCallback(async (keyword = '') => {
try {
setDeviceSearching(true);
if (keyword && keyword.trim()) {
const params = { keyword: keyword.trim() };
const response = await api.get('/devices/all', { params });
setDevices(response.devices || response || []);
} else {
const response = await api.get('/devices/all');
setDevices(response.devices || response || []);
}
} catch (error) {
message.error('获取设备列表失败');
console.error('获取设备列表失败:', error);
} finally {
setDeviceSearching(false);
}
}, []);
const handleDeviceSearch = useCallback(
debounce(value => {
fetchDevices(value);
}, 300),
[fetchDevices]
);
useEffect(() => {
fetchPorts(1);
fetchDevices();
}, [fetchPorts, fetchDevices]);
const handleSearch = useCallback(() => {
setDeviceCardPage(1);
fetchPorts(1);
}, [fetchPorts]);
const handleReset = useCallback(() => {
setFilters({
deviceId: '',
});
setDeviceCardPage(1);
fetchPorts(1);
}, [fetchPorts]);
const handleAdd = () => {
setEditingPort(null);
form.resetFields();
setPortAddGuideModalVisible(true);
};
const handleGuideSelectType = type => {
setPortAddGuideModalVisible(false);
setGuidedDeviceType(type);
fetchDevices();
fetchRoomsAndRacks();
if (type === 'switch') {
setDeviceFilterType('switch');
} else if (type === 'server') {
setDeviceFilterType('server');
}
setSelectDeviceModalVisible(true);
};
const fetchRoomsAndRacks = async () => {
try {
const [roomsData, racksData] = await Promise.all([
roomAPI.list(),
api.get('/racks', { params: { pageSize: 1000 } }),
]);
setRoomList(roomsData.rooms || roomsData || []);
setRackList(racksData.racks || racksData || []);
} catch (error) {
console.error('获取机房机柜数据失败:', error);
}
};
const handleManageServerNics = async () => {
setServerNicListVisible(true);
setServerNicLoading(true);
try {
const [devicesResponse, portsResponse, nicsResponse, roomsData, racksData] =
await Promise.all([
api.get('/devices/all', { params: { type: 'server' } }),
api.get('/device-ports', { params: { pageSize: 10000 } }),
api.get('/network-cards'),
roomAPI.list(),
api.get('/racks', { params: { pageSize: 1000 } }),
]);
setRoomList(roomsData.rooms || roomsData || []);
setRackList(racksData.racks || racksData || []);
const servers = devicesResponse.devices || devicesResponse || [];
const ports = portsResponse.ports || portsResponse || [];
let allNics = [];
if (Array.isArray(nicsResponse)) {
allNics = nicsResponse;
} else if (nicsResponse && Array.isArray(nicsResponse.data)) {
allNics = nicsResponse.data;
} else if (nicsResponse && Array.isArray(nicsResponse.networkCards)) {
allNics = nicsResponse.networkCards;
}
console.log('[DEBUG] handleManageServerNics:', {
serversCount: servers.length,
portsCount: ports.length,
nicsCount: allNics.length,
sampleServer: servers[0],
sampleNic: allNics[0],
});
const serversWithPorts = new Set(ports.map(p => p.deviceId));
const serversWithNics = servers.filter(server => {
const serverNics = allNics.filter(nic => nic.deviceId === server.deviceId);
return serverNics.length > 0;
});
const serversNeedingAttention = serversWithNics
.filter(server => !serversWithPorts.has(server.deviceId))
.map(server => {
const serverNics = allNics.filter(nic => nic.deviceId === server.deviceId);
return { ...server, nics: serverNics, nicCount: serverNics.length };
});
console.log(
'[DEBUG] serversWithNics:',
serversWithNics.length,
'serversNeedingAttention:',
serversNeedingAttention.length
);
setServerNicList(serversNeedingAttention);
} catch (error) {
console.error('获取服务器列表失败:', error);
message.error('获取服务器列表失败: ' + (error.message || error));
} finally {
setServerNicLoading(false);
}
};
const handleSelectDeviceForPort = device => {
setSelectDeviceModalVisible(false);
if (!device) return;
const deviceType = getDeviceType(device);
if (deviceType === 'server') {
api
.get(`/network-cards/device/${device.deviceId}`)
.then(nicList => {
const validNicList = Array.isArray(nicList) ? nicList : [];
if (validNicList.length === 0) {
message.warning({
content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡',
icon: (
<ExclamationCircleOutlined style={{ color: designTokens.colors.warning.main }} />
),
duration: 3,
});
handleManageNetworkCards(device);
} else {
setNicList(validNicList);
setSelectedDeviceForPort(device);
form.resetFields();
form.setFieldsValue({ deviceId: device.deviceId });
setModalVisible(true);
}
})
.catch(() => {
message.warning({
content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡',
icon: <ExclamationCircleOutlined style={{ color: designTokens.colors.warning.main }} />,
duration: 3,
});
handleManageNetworkCards(device);
});
} else {
setSelectedDeviceForPort(device);
form.resetFields();
form.setFieldsValue({ deviceId: device.deviceId });
setModalVisible(true);
}
};
const handleAddPortForDevice = device => {
const deviceType = getDeviceType(device);
if (deviceType === 'server') {
api
.get(`/network-cards/device/${device.deviceId}`)
.then(nicList => {
const validNicList = Array.isArray(nicList) ? nicList : [];
if (validNicList.length === 0) {
message.warning({
content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡',
icon: (
<ExclamationCircleOutlined style={{ color: designTokens.colors.warning.main }} />
),
duration: 3,
});
handleManageNetworkCards(device);
} else {
setNicList(validNicList);
setSelectedDeviceForPort(device);
form.resetFields();
form.setFieldsValue({ deviceId: device.deviceId });
setModalVisible(true);
}
})
.catch(() => {
message.warning({
content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡',
icon: <ExclamationCircleOutlined style={{ color: designTokens.colors.warning.main }} />,
duration: 3,
});
handleManageNetworkCards(device);
});
} else {
setSelectedDeviceForPort(device);
form.resetFields();
form.setFieldsValue({ deviceId: device.deviceId });
setModalVisible(true);
}
};
const handleManageNetworkCards = device => {
setSelectedDeviceForNic(device);
setNetworkCardModalVisible(true);
};
const handleNicSuccess = () => {
message.success({
content: '操作成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setRefreshTrigger(prev => prev + 1);
fetchPorts(1);
};
const handleEdit = port => {
setEditingPort(port);
form.setFieldsValue({
portId: port.portId,
deviceId: port.deviceId,
portName: port.portName,
portType: port.portType,
portSpeed: port.portSpeed,
status: port.status,
vlanId: port.vlanId,
description: port.description,
});
setModalVisible(true);
};
const handleDelete = async portId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个端口吗?此操作不可恢复!',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await api.delete(`/device-ports/${portId}`);
message.success({
content: '删除成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
fetchPorts(1);
} catch (error) {
const errorMsg = error.response?.data?.error || '';
if (errorMsg.includes('关联的接线记录')) {
message.error('该端口存在关联的接线记录,请先删除关联的接线');
} else {
message.error('删除失败');
}
console.error('删除失败:', error);
}
},
});
};
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的端口');
return;
}
Modal.confirm({
title: '确认批量删除',
content: `确定要删除选中的 ${selectedRowKeys.length} 个端口吗?此操作不可恢复!`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const result = await api.post('/device-ports/batch-delete', {
portIds: selectedRowKeys,
});
message.success({
content: `成功删除 ${selectedRowKeys.length} 个端口`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setSelectedRowKeys([]);
fetchPorts(1);
} catch (error) {
const errorMsg = error.response?.data?.error || '';
if (errorMsg.includes('关联的接线记录')) {
message.error('部分端口存在关联的接线记录,请先删除关联的接线');
} else {
message.error('批量删除失败');
}
console.error('批量删除失败:', error);
}
},
});
};
/**
* 从端口名中提取前缀和末尾数字
* @param {string} portName - 端口名称
* @returns {{ prefix: string, num: number } | null}
*/
const extractPrefixAndNum = portName => {
if (!portName || typeof portName !== 'string') return null;
const match = portName.match(/^(.*?)(\d+)$/);
if (!match) return null;
return { prefix: match[1], num: parseInt(match[2], 10) };
};
/**
* 快捷模式解析端口范围
* @param {string} portName - 端口范围字符串
* @returns {{ isRange: boolean, ports: string[], portCount: number, error?: string } | null}
*/
const parsePortRangeQuick = portName => {
if (!portName || typeof portName !== 'string') return null;
const trimmed = portName.trim();
if (!trimmed.includes('-')) return null;
const lastDashIdx = trimmed.lastIndexOf('-');
const startPart = trimmed.substring(0, lastDashIdx).trim();
const endPart = trimmed.substring(lastDashIdx + 1).trim();
if (!startPart || !endPart) {
return { isRange: false, ports: [], portCount: 0, error: '格式不完整,- 前后都需要有内容' };
}
const startParsed = extractPrefixAndNum(startPart);
const endParsed = extractPrefixAndNum(endPart);
if (!startParsed) {
return { isRange: false, ports: [], portCount: 0, error: `起始端口名 "${startPart}" 末尾无数字` };
}
if (!endParsed) {
return { isRange: false, ports: [], portCount: 0, error: `结束端口名 "${endPart}" 末尾无数字` };
}
let prefix;
if (endParsed.prefix === '') {
prefix = startParsed.prefix;
} else {
if (startParsed.prefix !== endParsed.prefix) {
return { isRange: false, ports: [], portCount: 0, error: `前后端口名前缀不一致:"${startParsed.prefix}" vs "${endParsed.prefix}"` };
}
prefix = startParsed.prefix;
}
if (startParsed.num >= endParsed.num) {
return { isRange: false, ports: [], portCount: 0, error: `起始数字 ${startParsed.num} 必须小于结束数字 ${endParsed.num}` };
}
if (endParsed.num - startParsed.num > 1000) {
return { isRange: false, ports: [], portCount: 0, error: '单次最多创建1000个端口' };
}
const portCount = endParsed.num - startParsed.num + 1;
const ports = [];
for (let i = 0; i < portCount; i++) {
ports.push(`${prefix}${startParsed.num + i}`);
}
return { isRange: true, ports, portCount };
};
/**
* 范围模式解析(双输入框)
* @param {string} startPortName - 起始端口名
* @param {string} endPortName - 结束端口名
* @returns {{ isRange: boolean, ports: string[], portCount: number, error?: string }}
*/
const parsePortRangeMode = (startPortName, endPortName) => {
if (!startPortName || !endPortName) {
return { isRange: false, ports: [], portCount: 0, error: '请填写起始和结束端口名' };
}
const startParsed = extractPrefixAndNum(startPortName.trim());
const endParsed = extractPrefixAndNum(endPortName.trim());
if (!startParsed) {
return { isRange: false, ports: [], portCount: 0, error: `起始端口名 "${startPortName}" 末尾无数字` };
}
if (!endParsed) {
return { isRange: false, ports: [], portCount: 0, error: `结束端口名 "${endPortName}" 末尾无数字` };
}
if (startParsed.prefix !== endParsed.prefix) {
return { isRange: false, ports: [], portCount: 0, error: `前后端口名前缀不一致:"${startParsed.prefix || '(空)'}" vs "${endParsed.prefix || '(空)'}"` };
}
if (startParsed.num >= endParsed.num) {
return { isRange: false, ports: [], portCount: 0, error: `起始数字 ${startParsed.num} 必须小于结束数字 ${endParsed.num}` };
}
if (endParsed.num - startParsed.num > 1000) {
return { isRange: false, ports: [], portCount: 0, error: '单次最多创建1000个端口' };
}
const portCount = endParsed.num - startParsed.num + 1;
const ports = [];
for (let i = 0; i < portCount; i++) {
ports.push(`${startParsed.prefix}${startParsed.num + i}`);
}
return { isRange: true, ports, portCount };
};
/**
* 根据模式生成端口名列表
* @param {string} mode - 'range' 或 'quick'
* @param {object} params - { portName } 或 { startPortName, endPortName }
* @returns {{ ports: string[], error: string | null, parseResult: object | null }}
*/
const generatePortNames = (mode, params) => {
if (mode === 'range') {
const { startPortName, endPortName } = params;
if (!startPortName && !endPortName) return { ports: [], error: null, parseResult: null };
if (!startPortName || !endPortName) return { ports: [], error: null, parseResult: null };
const result = parsePortRangeMode(startPortName, endPortName);
if (result.isRange) return { ports: result.ports, error: null, parseResult: result };
return { ports: [], error: result.error, parseResult: result };
}
const { portName } = params;
if (!portName) return { ports: [], error: null, parseResult: null };
// 快捷模式(仅单端口,不支持范围)
return { ports: [portName.trim()], error: null, parseResult: null };
};
/** 更新端口预览 */
const updatePortPreview = (mode, params) => {
const result = generatePortNames(mode, params);
setParseError(result.error);
setParseResult(result.parseResult);
if (result.ports.length > 1) {
setPreviewPorts(result.ports.slice(0, 20));
setShowPreview(true);
} else {
setPreviewPorts([]);
setShowPreview(false);
}
};
/** 模式切换 */
const handleModeChange = newMode => {
setPortMode(newMode);
setParseError(null);
setParseResult(null);
setPreviewPorts([]);
setShowPreview(false);
form.setFieldsValue({ portName: undefined, startPortName: undefined, endPortName: undefined });
};
const generateUniquePortId = () => {
return `PORT-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingPort) {
await api.put(`/device-ports/${editingPort.portId}`, values);
message.success({
content: '更新成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
} else {
// 根据模式获取端口名列表
const genResult = portMode === 'range'
? generatePortNames('range', { startPortName: values.startPortName, endPortName: values.endPortName })
: generatePortNames('quick', { portName: values.portName });
if (genResult.error) {
message.error(genResult.error);
return;
}
const portNames = genResult.ports;
if (!portNames || portNames.length === 0) {
message.error('请输入端口名称');
return;
}
const targetDeviceId = selectedDeviceForPort
? selectedDeviceForPort.deviceId
: values.deviceId;
if (portNames.length > 1) {
const portsData = portNames.map((name, index) => ({
portId: generateUniquePortId(),
deviceId: targetDeviceId,
portName: name,
portType: values.portType,
status: 'free',
vlanId: values.vlanId,
description: values.description,
nicId: values.nicId || null,
}));
const result = await api.post('/device-ports/batch', { ports: portsData });
const success = result.success ?? 0;
const failed = result.failed ?? 0;
if (failed > 0) {
message.warning(`批量创建完成!成功 ${success} 个,失败 ${failed} 个`);
} else {
message.success({
content: `成功创建 ${success} 个端口`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
}
} else {
const result = await api.post('/device-ports', {
deviceId: targetDeviceId,
portName: portNames[0],
portType: values.portType,
status: 'free',
vlanId: values.vlanId,
description: values.description,
nicId: values.nicId || null,
});
message.success({
content: '创建成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
}
}
setModalVisible(false);
form.resetFields();
setSelectedDeviceForPort(null);
setParseError(null);
setParseResult(null);
setPreviewPorts([]);
setShowPreview(false);
fetchPorts(1);
} catch (error) {
message.error(error.response?.data?.error || editingPort ? '更新失败' : '创建失败');
console.error('提交失败:', error);
}
};
const handleImport = (deviceType = 'all') => {
setImportModalVisible(true);
setImportPreview([]);
setImportProgress({ current: 0, total: 0 });
};
const handleFileUpload = (file, onSuccess) => {
console.log('handleFileUpload 被调用', file.name, file.size);
const reader = new FileReader();
reader.onload = async e => {
try {
const data = e.target.result;
let parsedData = [];
let networkCardData = [];
if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
const workbook = XLSX.read(data, { type: 'binary' });
if (importDeviceType === 'server' && workbook.SheetNames.length >= 2) {
// 服务器设备:解析两张表格
const networkCardSheet = workbook.Sheets[workbook.SheetNames[0]];
const portSheet = workbook.Sheets[workbook.SheetNames[1]];
networkCardData = XLSX.utils.sheet_to_json(networkCardSheet);
parsedData = XLSX.utils.sheet_to_json(portSheet);
} else {
// 其他设备:只解析第一张表格
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;
}
// 保存网卡数据,供后续导入使用
if (networkCardData.length > 0) {
// 验证网卡数据
const validatedNicData = [];
const nicErrors = [];
for (let i = 0; i < networkCardData.length; i++) {
const row = networkCardData[i];
const rowNum = i + 2;
const errors = [];
if (!row['设备SN']) {
errors.push({
row: rowNum,
field: '设备SN',
value: row['设备SN'] || '(空)',
error: '缺少必填字段',
suggestion: '请填写设备SN,格式如:SNAA0001',
});
}
if (!row['网卡名称']) {
errors.push({
row: rowNum,
field: '网卡名称',
value: row['网卡名称'] || '(空)',
error: '缺少必填字段',
suggestion: '请填写网卡名称,格式如:网卡1、eth0',
});
}
if (row['插槽编号']) {
const validSlotValues = ['LOM', 'OCP', '1', '2', '3', '4'];
const slotValue = row['插槽编号'].toString().trim();
if (!validSlotValues.includes(slotValue) && !/^\d+$/.test(slotValue)) {
errors.push({
row: rowNum,
field: '插槽编号',
value: row['插槽编号'],
error: '插槽编号格式错误',
suggestion: '插槽编号必须为数字或预选项(LOM、OCP、1-4',
});
}
}
if (errors.length > 0) {
nicErrors.push(...errors.map(err => ({ ...err, originalRow: row })));
} else {
validatedNicData.push(row);
}
}
if (nicErrors.length > 0) {
message.warning({
content: `发现 ${nicErrors.length} 个网卡数据错误,请查看错误详情并修正后重新导入`,
duration: 5,
});
setImportErrors(nicErrors);
return;
}
sessionStorage.setItem('networkCardImportData', JSON.stringify(validatedNicData));
}
// 验证并处理数据
// 对于服务器设备,跳过网卡存在性验证,因为网卡还没有被导入
const validatedResult = await validateImportData(parsedData, importDeviceType === 'server');
console.log('文件解析结果:', {
总行数: parsedData.length,
有效数据: validatedResult.validData.length,
错误数据: validatedResult.errors.length,
第一行数据: parsedData[0],
网卡数据行数: networkCardData.length,
});
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, skipNicValidation = false) => {
const validData = [];
const allErrors = [];
for (let i = 0; i < data.length; i++) {
const row = data[i];
const result = await validatePortRow(row, i, skipNicValidation);
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 validatePortRow = async (row, index, skipNicValidation = false) => {
const rowNum = index + 2;
const errors = [];
if (!row['设备SN']) {
errors.push({
row: rowNum,
field: '设备SN',
value: row['设备SN'] || '(空)',
error: '缺少必填字段',
suggestion: '请填写设备SN,格式如:SNAA0001',
});
}
if (!row['端口名称']) {
errors.push({
row: rowNum,
field: '端口名称',
value: row['端口名称'] || '(空)',
error: '缺少必填字段',
suggestion: '请填写端口名称,格式如:eth0/1 或 GigabitEthernet0/0/1',
});
}
if (row['设备SN']) {
const device = devices.find(d => d.serialNumber === row['设备SN']);
if (!device) {
errors.push({
row: rowNum,
field: '设备SN',
value: row['设备SN'],
error: '设备不存在',
suggestion: `设备SN "${row['设备SN']}" 未在系统中注册,请先在设备管理中添加该设备`,
});
}
}
const validPortTypes = ['RJ45', 'SFP', 'SFP+', 'SFP28', 'QSFP', 'QSFP28'];
if (row['端口类型'] && !validPortTypes.includes(row['端口类型'])) {
errors.push({
row: rowNum,
field: '端口类型',
value: row['端口类型'],
error: '无效的端口类型',
suggestion: `端口类型 "${row['端口类型']}" 不支持。可选值:${validPortTypes.join('、')}`,
});
}
const validPortSpeeds = ['100M', '1G', '10G', '25G', '40G', '100G'];
if (row['端口速率'] && !validPortSpeeds.includes(row['端口速率'])) {
errors.push({
row: rowNum,
field: '端口速率',
value: row['端口速率'],
error: '无效的端口速率',
suggestion: `端口速率 "${row['端口速率']}" 不支持。可选值:${validPortSpeeds.join('、')}`,
});
}
const validStatuses = ['空闲', '占用', '故障'];
if (row['状态'] && !validStatuses.includes(row['状态'])) {
errors.push({
row: rowNum,
field: '状态',
value: row['状态'],
error: '无效的状态值',
suggestion: `状态 "${row['状态']}" 不支持。可选值:${validStatuses.join('、')}`,
});
}
if (row['VLAN ID']) {
const vlanPattern = /^\d+$/;
if (!vlanPattern.test(row['VLAN ID'])) {
errors.push({
row: rowNum,
field: 'VLAN ID',
value: row['VLAN ID'],
error: 'VLAN ID 格式错误',
suggestion: 'VLAN ID 必须为数字,如:100、200',
});
} else {
const vlanNum = parseInt(row['VLAN ID']);
if (vlanNum < 1 || vlanNum > 4094) {
errors.push({
row: rowNum,
field: 'VLAN ID',
value: row['VLAN ID'],
error: 'VLAN ID 范围超限',
suggestion: 'VLAN ID 必须介于 1-4094 之间',
});
}
}
}
if (!skipNicValidation && row['网卡名称'] && row['设备SN']) {
try {
const nicResponse = await api.get('/network-cards/find', {
params: { deviceSn: row['设备SN'], name: row['网卡名称'] },
});
row._nicId = nicResponse.nicId || null;
} catch (error) {
row._nicId = null;
}
}
if (row['设备SN']) {
const device = devices.find(d => d.serialNumber === row['设备SN']) || devices.find(d => d.sn === row['设备SN']);
if (device) {
const isServer = device.type && device.type.toLowerCase().includes('server');
if (isServer) {
if (!row['网卡名称'] && !row._nicId) {
errors.push({
row: rowNum,
field: '网卡名称',
value: row['网卡名称'] || '(空)',
error: '服务器端口必须关联网卡',
suggestion: `服务器 "${device.name}" 的端口必须关联到网卡,请先在网卡管理中添加网卡,或在导入模板中填写网卡名称`,
});
} else if (!skipNicValidation && row['网卡名称'] && !row._nicId) {
errors.push({
row: rowNum,
field: '网卡名称',
value: row['网卡名称'],
error: '网卡不存在',
suggestion: `服务器 "${device.name}" 的网卡"${row['网卡名称']}"不存在,请先在网卡管理中添加该网卡`,
});
}
}
}
}
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 {
// 检查是否有网卡数据需要导入
let networkCardImportResult = null;
const networkCardDataStr = sessionStorage.getItem('networkCardImportData');
if (networkCardDataStr) {
const networkCardData = JSON.parse(networkCardDataStr);
if (networkCardData.length > 0) {
// 先导入网卡数据
console.log('开始导入网卡数据:', networkCardData.length, '条');
// 生成网卡ID
const networkCardsData = networkCardData.map((card, index) => ({
nicId: `NIC-${Date.now()}-${index}`,
deviceSn: card['设备SN'],
name: card['网卡名称'],
slotNumber: card['插槽编号'] ? parseInt(card['插槽编号']) : null,
model: card['网卡型号'] || null,
manufacturer: card['制造商'] || null,
description: card['描述'] || null,
}));
const nicResponse = await api.post('/network-cards/batch', {
networkCards: networkCardsData,
skipExisting: true,
updateExisting: false,
});
networkCardImportResult = nicResponse;
console.log('网卡导入结果:', nicResponse);
// 重新获取设备列表,确保包含最新数据
await fetchDevices();
}
// 清除保存的网卡数据
sessionStorage.removeItem('networkCardImportData');
}
const statusMap = {
空闲: 'free',
占用: 'occupied',
故障: 'fault',
};
const portsData = importPreview.map((row, index) => ({
portId: generateUniquePortId(),
deviceSn: row['设备SN'],
nicId: row._nicId || null,
'网卡名称': row['网卡名称'],
portName: row['端口名称'],
portType: row['端口类型'],
portSpeed: row['端口速率'],
status: statusMap[row['状态']] || 'free',
vlanId: row['VLAN ID'],
description: row['描述'],
}));
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('/device-ports/batch', {
ports: portsData,
skipExisting,
updateExisting,
});
const { total, success, failed, skipped = 0, updated = 0, errors } = response;
clearInterval(progressInterval);
setImportProgress({ current: importPreview.length, total: importPreview.length });
let msgContent = '';
if (networkCardImportResult) {
const { success: nicSuccess, failed: nicFailed } = networkCardImportResult;
if (nicSuccess > 0) {
msgContent += `网卡导入成功 ${nicSuccess} 个,`;
}
if (nicFailed > 0) {
msgContent += `网卡导入失败 ${nicFailed} 个,`;
}
}
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 }} />,
});
}
fetchPorts(1);
setImportModalVisible(false);
setImportPreview([]);
} catch (error) {
clearInterval(progressInterval);
console.error('批量导入失败:', error);
message.error('批量导入失败,请检查数据格式');
} finally {
clearInterval(progressInterval);
setImporting(false);
// 确保清除保存的网卡数据
sessionStorage.removeItem('networkCardImportData');
}
};
const handleDownloadTemplate = () => {
let fileName = '端口导入模板.xlsx';
const workbook = XLSX.utils.book_new();
if (importDeviceType === 'server') {
// 网卡数据表格
const networkCardTemplateData = [
{
'设备SN': 'SN-20230001',
'网卡名称': '网卡1',
'插槽编号': 'LOM',
'网卡型号': 'Intel X710',
'制造商': 'Intel',
'描述': '板载网卡',
},
{
'设备SN': 'SN-20230001',
'网卡名称': '网卡2',
'插槽编号': 'OCP',
'网卡型号': 'Intel X710',
'制造商': 'Intel',
'描述': 'OCP网卡',
},
{
'设备SN': 'SN-20230001',
'网卡名称': '网卡3',
'插槽编号': '1',
'网卡型号': 'Intel X710',
'制造商': 'Intel',
'描述': 'PCIe插槽1',
},
];
// 端口数据表格
const portTemplateData = [
{
'设备SN': 'SN-20230001',
'网卡名称': '网卡1',
'端口名称': 'eth0/1',
'端口类型': 'RJ45',
'端口速率': '1G',
'状态': '空闲',
'VLAN ID': '100',
'描述': '服务器端口',
},
{
'设备SN': 'SN-20230001',
'网卡名称': '网卡2',
'端口名称': 'eth0/2',
'端口类型': 'RJ45',
'端口速率': '1G',
'状态': '空闲',
'VLAN ID': '200',
'描述': '服务器端口',
},
];
const networkCardWorksheet = XLSX.utils.json_to_sheet(networkCardTemplateData);
const portWorksheet = XLSX.utils.json_to_sheet(portTemplateData);
XLSX.utils.book_append_sheet(workbook, networkCardWorksheet, '网卡数据');
XLSX.utils.book_append_sheet(workbook, portWorksheet, '端口数据');
fileName = '服务器设备导入模板.xlsx';
} else if (importDeviceType === 'switch') {
const templateData = [
{
'设备SN': 'SW-20230001',
'端口名称': 'GigabitEthernet0/0/1',
'端口类型': 'RJ45',
'端口速率': '1G',
'状态': '空闲',
'VLAN ID': '100',
'描述': '交换机端口',
},
];
const worksheet = XLSX.utils.json_to_sheet(templateData);
XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据');
fileName = '交换机端口导入模板.xlsx';
} else {
const templateData = [
{
'设备SN': 'DEV-20230001',
'端口名称': 'eth0/1',
'网卡名称': '',
'端口类型': 'RJ45',
'端口速率': '1G',
'状态': '空闲',
'VLAN ID': '100',
'描述': '示例端口',
},
];
const worksheet = XLSX.utils.json_to_sheet(templateData);
XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据');
}
XLSX.writeFile(workbook, fileName);
};
const handleExport = async ({ scope, format }) => {
try {
let exportData = [];
let totalCount = 0;
if (scope === 'all') {
const params = {
pageSize: 50000,
};
const response = await api.get('/device-ports/export/all', { params });
exportData = response.ports || [];
totalCount = response.total || exportData.length;
} else if (scope === 'filtered') {
const params = {
deviceId: filters.deviceId || undefined,
pageSize: 50000,
};
const response = await api.get('/device-ports/export/all', { params });
exportData = response.ports || [];
totalCount = response.total || exportData.length;
} else {
exportData = ports.map(port => {
const device = devices.find(d => d.deviceId === port.deviceId);
const statusMap = { free: '空闲', occupied: '占用', fault: '故障' };
return {
端口ID: port.portId,
设备ID: port.deviceId,
设备名称: device?.name || '-',
设备类型: device?.type || '-',
机房: '-',
机架: '-',
网卡名称: port.nicId || '-',
端口名称: 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') : '-',
};
});
totalCount = exportData.length;
}
if (exportData.length === 0) {
message.warning('没有可导出的端口数据');
return;
}
if (format === 'csv') {
const csvContent = Papa.unparse(exportData);
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `端口导出_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} else {
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据');
const colWidths = [
{ wch: 20 },
{ wch: 15 },
{ wch: 20 },
{ wch: 12 },
{ wch: 10 },
{ wch: 12 },
{ wch: 15 },
{ wch: 15 },
{ wch: 10 },
{ wch: 8 },
{ wch: 10 },
{ wch: 10 },
{ wch: 25 },
{ wch: 20 },
];
worksheet['!cols'] = colWidths;
XLSX.writeFile(
workbook,
`端口导出_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.xlsx`
);
}
message.success({
content: `成功导出 ${totalCount} 个端口`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
} catch (error) {
console.error('导出失败:', error);
message.error('导出失败,请重试');
}
};
const getStatusTag = status => {
const statusMap = {
free: { color: 'success', text: '空闲', icon: <CheckCircleOutlined /> },
occupied: { color: 'processing', text: '占用', icon: <AppstoreOutlined /> },
fault: { color: 'error', text: '故障', icon: <ExclamationCircleOutlined /> },
空闲: { color: 'success', text: '空闲', icon: <CheckCircleOutlined /> },
占用: { color: 'processing', text: '占用', icon: <AppstoreOutlined /> },
故障: { color: 'error', text: '故障', icon: <ExclamationCircleOutlined /> },
};
const config = statusMap[status] || { color: 'default', text: status, icon: null };
return (
<Tag
color={config.color}
icon={config.icon}
style={{ borderRadius: '4px', padding: '2px 8px', fontSize: '12px' }}
>
{config.text}
</Tag>
);
};
const getPortTypeTag = type => {
const typeMap = {
RJ45: { color: 'blue', text: 'RJ45' },
SFP: { color: 'green', text: 'SFP' },
'SFP+': { color: 'cyan', text: 'SFP+' },
SFP28: { color: 'purple', text: 'SFP28' },
QSFP: { color: 'orange', text: 'QSFP' },
QSFP28: { color: 'red', text: 'QSFP28' },
};
const config = typeMap[type] || { color: 'default', text: type };
return (
<Tag
color={config.color}
style={{ borderRadius: '4px', padding: '2px 8px', fontSize: '12px' }}
>
{config.text}
</Tag>
);
};
const portColumns = useMemo(
() => [
{
title: '端口名称',
dataIndex: 'portName',
key: 'portName',
width: 120,
render: text => (
<span style={{ fontWeight: 500, color: designTokens.colors.neutral[800] }}>{text}</span>
),
},
{
title: '所属网卡',
dataIndex: 'networkCard',
key: 'networkCard',
width: 120,
render: (nic, record) => {
const isServer = record.device?.type?.toLowerCase()?.includes('server');
if (!isServer) return <Text type="secondary">-</Text>;
if (!nic)
return (
<Text type="secondary" style={{ color: '#ff4d4f' }}>
未关联
</Text>
);
return <Text style={{ color: '#667eea' }}>{nic.name}</Text>;
},
},
{
title: '端口类型',
dataIndex: 'portType',
key: 'portType',
width: 100,
render: type => getPortTypeTag(type),
},
{
title: '端口速率',
dataIndex: 'portSpeed',
key: 'portSpeed',
width: 100,
render: text => <Text type="secondary">{text}</Text>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: status => getStatusTag(status),
},
{
title: 'VLAN ID',
dataIndex: 'vlanId',
key: 'vlanId',
width: 100,
render: vlanId => vlanId || <Text type="secondary">-</Text>,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
render: text => text || <Text type="secondary">-</Text>,
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right',
render: (_, record) => (
<Space size="small">
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
<Tooltip title="删除">
<Popconfirm
title="确定要删除这个端口吗?"
onConfirm={() => handleDelete(record.portId)}
okText="确定"
cancelText="取消"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Tooltip>
</Space>
),
},
],
[]
);
// 获取设备图标
const getDeviceIcon = device => {
if (!device?.type) return <AppstoreOutlined />;
const type = device.type.toLowerCase();
if (type.includes('server')) return <CloudServerOutlined />;
if (type.includes('switch')) return <AppstoreOutlined />;
if (type.includes('router')) return <ApiOutlined />;
return <AppstoreOutlined />;
};
const getDeviceType = device => {
if (!device?.type) return 'unknown';
const type = device.type.toLowerCase();
if (type.includes('switch')) return 'switch';
if (type.includes('server')) return 'server';
return 'other';
};
const isSwitchDevice = device => getDeviceType(device) === 'switch';
const isServerDevice = device => getDeviceType(device) === 'server';
// 过滤后的设备列表(带分页)
const paginatedDevices = useMemo(() => {
const rackRoomMap = {};
rackList.forEach(rack => {
rackRoomMap[rack.rackId] = rack.roomId;
});
const filtered = devices.filter(d => {
const type = getDeviceType(d);
if (type !== 'switch' && type !== 'server') return false;
if (deviceFilterType !== 'all' && type !== deviceFilterType) return false;
if (selectedRackId && d.rackId !== selectedRackId) return false;
if (selectedRoomId && d.rackId && rackRoomMap[d.rackId] !== selectedRoomId) return false;
return true;
});
const pageSize = 100;
const end = devicePage * pageSize;
const hasMore = end < filtered.length;
hasMoreRef.current = hasMore;
return {
list: filtered.slice(0, end),
total: filtered.length,
hasMore,
};
}, [devices, deviceFilterType, devicePage, rackList, selectedRoomId, selectedRackId]);
useEffect(() => {
if (!loadMoreRef.current) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMoreRef.current && !isLoadingRef.current) {
isLoadingRef.current = true;
setDevicePage(p => p + 1);
setTimeout(() => {
isLoadingRef.current = false;
}, 200);
}
},
{ threshold: 0.1 }
);
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, []);
return (
<motion.div
variants={animations.container}
initial="hidden"
animate="visible"
style={{
padding: '24px',
background: designTokens.colors.neutral[50],
minHeight: '100vh',
}}
>
{/* 页面标题 */}
<motion.div variants={animations.item} style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: designTokens.borderRadius.md,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '20px',
}}
>
<ApiOutlined />
</div>
<div>
<Title level={4} style={{ margin: 0, color: designTokens.colors.neutral[800] }}>
端口管理
</Title>
<Text type="secondary" style={{ fontSize: '13px' }}>
管理设备的网络端口信息
</Text>
</div>
</div>
</motion.div>
{/* 主内容区 */}
<motion.div variants={animations.item}>
<Card
style={{
borderRadius: designTokens.borderRadius.lg,
boxShadow: designTokens.shadows.md,
border: 'none',
}}
bodyStyle={{ padding: '24px' }}
>
{/* 功能区:过滤 + 操作按钮 */}
<div style={{ marginBottom: '24px' }}>
<Card
style={{
background: designTokens.colors.neutral[50],
borderRadius: designTokens.borderRadius.md,
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
bodyStyle={{ padding: '16px' }}
>
<Row gutter={[16, 16]} align="middle">
<Col xs={24} sm={24} md={10} lg={8}>
<div
style={{
marginBottom: '6px',
fontSize: '13px',
color: designTokens.colors.neutral[600],
fontWeight: 500,
}}
>
设备筛选
</div>
<Select
placeholder="输入关键词搜索设备"
style={{ width: '100%' }}
value={filters.deviceId || undefined}
onChange={value => setFilters(prev => ({ ...prev, deviceId: value }))}
allowClear
showSearch
loading={deviceSearching}
filterOption={false}
onSearch={handleDeviceSearch}
onDropdownVisibleChange={open => {
if (open && devices.length === 0) {
fetchDevices();
}
}}
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
{device.name} ({device.deviceId})
</Option>
))}
</Select>
</Col>
<Col xs={24} sm={24} md={14} lg={16}>
<div
style={{
marginBottom: '6px',
fontSize: '13px',
color: designTokens.colors.neutral[600],
fontWeight: 500,
}}
>
操作
</div>
<Space size="small" wrap>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
size="middle"
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
}}
>
新增端口
</Button>
<Button
icon={<CloudServerOutlined />}
onClick={handleManageServerNics}
size="middle"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
管理网卡
</Button>
<Button
icon={<ImportOutlined />}
onClick={() => setBatchImportModalVisible(true)}
size="middle"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
批量导入
</Button>
<Button
icon={<ExportOutlined />}
onClick={() => setPortExportModalVisible(true)}
size="middle"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
导出
</Button>
<Tooltip title="刷新数据">
<Button
icon={<ReloadOutlined />}
onClick={fetchPorts}
loading={loading}
size="middle"
style={{ borderRadius: designTokens.borderRadius.sm }}
/>
</Tooltip>
</Space>
</Col>
</Row>
</Card>
</div>
{/* 数据展示区域 */}
{loading ? (
<div style={{ padding: '40px' }}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
) : groupedPorts.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div style={{ textAlign: 'center' }}>
<div
style={{
fontSize: '16px',
color: designTokens.colors.neutral[600],
marginBottom: '8px',
}}
>
暂无端口数据
</div>
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[400] }}>
点击下方按钮添加端口
</div>
</div>
}
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
}}
>
新增端口
</Button>
</Empty>
</motion.div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{groupedPorts.map((data, index) => {
const device = data.device;
const deviceId = device?.deviceId;
const devicePorts = data.ports || [];
const freeCount = devicePorts.filter(p => p.status === 'free').length;
const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length;
const faultCount = devicePorts.filter(p => p.status === 'fault').length;
const isExpanded = deviceId ? expandedKeys.includes(deviceId) : false;
return (
<Card
key={deviceId || index}
style={{
borderRadius: designTokens.borderRadius.lg,
border: `1px solid ${designTokens.colors.neutral[200]}`,
overflow: 'hidden',
}}
bodyStyle={{ padding: 0 }}
>
{/* 设备头部 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
background: isExpanded ? designTokens.colors.primary.light : '#fff',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onClick={() => {
if (isExpanded) {
setExpandedKeys(prev => prev.filter(key => key !== deviceId));
} else {
setExpandedKeys(prev => [...prev, deviceId]);
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: designTokens.borderRadius.md,
background: isServerDevice(device)
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: isSwitchDevice(device)
? 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'
: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '24px',
boxShadow: designTokens.shadows.md,
}}
>
{getDeviceIcon(device)}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
fontWeight: 600,
fontSize: '16px',
color: designTokens.colors.neutral[800],
}}
>
{device?.name || '未知设备'}
</span>
<Tag
color={
isServerDevice(device)
? 'blue'
: isSwitchDevice(device)
? 'green'
: 'default'
}
style={{ marginLeft: '4px' }}
>
{isServerDevice(device)
? '服务器'
: isSwitchDevice(device)
? '交换机'
: '设备'}
</Tag>
</div>
<div
style={{
fontSize: '13px',
color: designTokens.colors.neutral[500],
marginTop: '2px',
}}
>
{device?.deviceId || '-'} · {device?.model || device?.type || '设备'}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Space size="small">
<Tooltip title="空闲">
<Tag
color="success"
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<CheckCircleOutlined />}
>
{freeCount}
</Tag>
</Tooltip>
<Tooltip title="占用">
<Tag
color="processing"
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<AppstoreOutlined />}
>
{occupiedCount}
</Tag>
</Tooltip>
{faultCount > 0 && (
<Tooltip title="故障">
<Tag
color="error"
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<ExclamationCircleOutlined />}
>
{faultCount}
</Tag>
</Tooltip>
)}
<Tag
color="blue"
style={{ borderRadius: '4px', padding: '4px 12px', fontWeight: 500 }}
>
总计: {devicePorts.length}
</Tag>
</Space>
<Divider type="vertical" style={{ height: '24px', margin: '0 8px' }} />
<Space size="small">
<Tooltip
title={
isServerDevice(device)
? '添加端口'
: '添加端口(交换机端口无需关联网卡)'
}
>
<Button
type="text"
icon={<PlusOutlined />}
onClick={e => {
e.stopPropagation();
handleAddPortForDevice(device);
}}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
{isServerDevice(device) && (
<Tooltip title="网卡管理">
<Button
type="text"
icon={<CloudServerOutlined />}
onClick={e => {
e.stopPropagation();
handleManageNetworkCards(device);
}}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
)}
<Button
type="text"
size="small"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
style={{ color: designTokens.colors.neutral[600], minWidth: '70px' }}
>
{isExpanded ? '收起' : '展开'}
</Button>
</Space>
</div>
</div>
{/* 端口列表 */}
{isExpanded && (
<div
style={{
padding: '16px 20px',
borderTop: `1px solid ${designTokens.colors.neutral[200]}`,
}}
>
{devicePorts.length > 0 ? (
<div>
{selectedRowKeys.length > 0 && (
<div
style={{
marginBottom: '12px',
padding: '8px 12px',
background: designTokens.colors.error.bg,
borderRadius: designTokens.borderRadius.sm,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text type="secondary">
已选择 {selectedRowKeys.length} 个端口
</Text>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={handleBatchDelete}
>
批量删除
</Button>
</div>
)}
<Table
columns={portColumns}
dataSource={devicePorts}
rowKey="portId"
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
showTotal: total => `共 ${total} 个端口`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
size="middle"
scroll={{ x: 1000 }}
/>
</div>
) : (
<Empty description="暂无端口数据" style={{ padding: '24px 0' }} />
)}
</div>
)}
</Card>
);
})}
{deviceCardTotal > deviceCardPageSize && (
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 0' }}>
<Pagination
current={deviceCardPage}
pageSize={deviceCardPageSize}
total={deviceCardTotal}
onChange={page => {
setExpandedKeys([]);
fetchPorts(page);
}}
showSizeChanger={false}
showTotal={(total, range) =>
`第 ${range[0]}-${range[1]} 个,共 ${total} 个设备`
}
/>
</div>
)}
{ports.length > 0 && (
<div style={{ textAlign: 'center', padding: '16px', color: '#999' }}>
{deviceCardTotal} 个设备{portTotal} 个端口
</div>
)}
</div>
)}
</Card>
</motion.div>
{/* 新增/编辑端口弹窗 */}
<Modal
open={modalVisible}
closeIcon={<CloseButton />}
onCancel={() => {
setModalVisible(false);
form.resetFields();
setSelectedDeviceForPort(null);
setParseError(null);
setParseResult(null);
setPreviewPorts([]);
setShowPreview(false);
}}
footer={null}
width={860}
zIndex={1050}
style={{ borderRadius: '16px' }}
styles={{ body: { padding: 0 } }}
>
<div
style={{
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.primary.dark} 100%)`,
padding: '20px 24px',
borderRadius: '16px 16px 0 0',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: designTokens.borderRadius.md,
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
<ApiOutlined />
</div>
<span style={{ fontSize: '18px', fontWeight: 600, color: '#fff' }}>
{editingPort ? '编辑端口' : '新增端口'}
</span>
{selectedDeviceForPort && (
<Tag
style={{
background: 'rgba(255,255,255,0.2)',
border: 'none',
color: '#fff',
fontWeight: 500,
}}
>
{selectedDeviceForPort.name}
</Tag>
)}
</div>
</div>
<div style={{ padding: '24px', background: '#fff' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginBottom: '24px',
gap: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 20px',
borderRadius: '20px',
fontSize: '14px',
fontWeight: 500,
background: designTokens.colors.primary.bg,
color: designTokens.colors.primary.main,
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: designTokens.colors.primary.main,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 600,
}}
>
1
</div>
<span>基本信息</span>
</div>
<div
style={{
width: '40px',
height: '2px',
background: designTokens.colors.primary.main,
alignSelf: 'center',
}}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 20px',
borderRadius: '20px',
fontSize: '14px',
fontWeight: 500,
background: designTokens.colors.neutral[100],
color: designTokens.colors.neutral[500],
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'transparent',
color: designTokens.colors.neutral[500],
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 600,
border: `1px solid ${designTokens.colors.neutral[400]}`,
}}
>
2
</div>
<span>高级配置</span>
</div>
</div>
<Form form={form} layout="vertical">
{/* 格式说明 - 顶部 */}
<Alert
message="格式说明"
description={
<div style={{ fontSize: '12px', lineHeight: '1.8' }}>
{portMode === 'range' ? (
<>
<div>
<strong>范围模式</strong>分别输入起始和结束端口名系统自动生成端口序列
</div>
<div>
<strong>规则</strong>起始和结束端口名的前缀必须一致且起始数字需小于结束数字
</div>
<div>
<strong>示例</strong>起始 1/0/1结束 1/0/48 创建 1/0/1 1/0/48 48 个端口
</div>
</>
) : (
<>
<div>
<strong>单端口模式</strong>每次创建一个端口如需批量创建请切换至范围模式
</div>
<div>
<strong>示例</strong>eth0/1GigabitEthernet0/0/11/0/24
</div>
</>
)}
</div>
}
type="info"
showIcon
style={{
borderRadius: '8px',
background: designTokens.colors.info.bg,
border: `1px solid ${designTokens.colors.info.light}40`,
marginBottom: '16px',
}}
/>
{/* 端口标识 + 端口属性 左右排列 */}
<Row gutter={16} style={{ marginBottom: '16px' }}>
{/* 左侧:端口标识 */}
<Col span={12}>
<div
style={{
background: designTokens.colors.neutral[50],
borderRadius: '12px',
padding: '16px',
height: '100%',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<TagOutlined style={{ color: designTokens.colors.primary.main }} />
端口标识
{!editingPort && (
<div style={{ marginLeft: 'auto' }}>
<Segmented
size="small"
value={portMode}
onChange={handleModeChange}
options={[
{ label: '单端口', value: 'quick' },
{ label: '范围模式', value: 'range' },
]}
/>
</div>
)}
</div>
{selectedDeviceForPort ? (
<>
<Form.Item
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>设备</span>}
style={{ marginBottom: '12px' }}
>
<Input
value={selectedDeviceForPort.name}
disabled
addonAfter={
<span
style={{
color: isServerDevice(selectedDeviceForPort)
? designTokens.colors.warning.main
: designTokens.colors.success.main,
}}
>
{isServerDevice(selectedDeviceForPort)
? '服务器'
: isSwitchDevice(selectedDeviceForPort)
? '交换机'
: '设备'}
</span>
}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
{isServerDevice(selectedDeviceForPort) && (
<Form.Item
name="nicId"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>所属网卡</span>}
rules={[{ required: true, message: '请选择网卡' }]}
style={{ marginBottom: '12px' }}
>
<Select
placeholder="请选择网卡"
size="large"
style={{ borderRadius: '8px' }}
>
{nicList.map(nic => (
<Option key={nic.nicId} value={nic.nicId}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 0',
}}
>
<div>
<div style={{ fontWeight: 500 }}>{nic.name}</div>
{nic.speed && (
<div
style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
}}
>
{nic.speed}
</div>
)}
</div>
</div>
</Option>
))}
</Select>
</Form.Item>
)}
{editingPort ? (
<Form.Item
name="portName"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>端口名称</span>}
rules={[{ required: true, message: '请输入端口名称' }]}
style={{ marginBottom: '0' }}
>
<Input
placeholder="例如: eth0/1"
size="large"
prefix={<TagOutlined style={{ color: designTokens.colors.neutral[400] }} />}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
) : portMode === 'range' ? (
<>
<Form.Item
name="startPortName"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>起始端口名</span>}
rules={[{ required: true, message: '请输入起始端口名' }]}
style={{ marginBottom: '12px' }}
>
<Input
placeholder="例如: 1/0/1"
size="large"
prefix={<TagOutlined style={{ color: designTokens.colors.neutral[400] }} />}
style={{ borderRadius: '8px' }}
onChange={() => {
const startPortName = form.getFieldValue('startPortName');
const endPortName = form.getFieldValue('endPortName');
updatePortPreview('range', { startPortName, endPortName });
}}
/>
</Form.Item>
<Form.Item
name="endPortName"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>结束端口名</span>}
rules={[{ required: true, message: '请输入结束端口名' }]}
style={{ marginBottom: '0' }}
>
<Input
placeholder="例如: 1/0/48"
size="large"
prefix={<TagOutlined style={{ color: designTokens.colors.neutral[400] }} />}
style={{ borderRadius: '8px' }}
onChange={() => {
const startPortName = form.getFieldValue('startPortName');
const endPortName = form.getFieldValue('endPortName');
updatePortPreview('range', { startPortName, endPortName });
}}
/>
</Form.Item>
</>
) : (
<Form.Item
name="portName"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>端口名称</span>}
rules={[{ required: true, message: '请输入端口名称' }]}
style={{ marginBottom: '0' }}
>
<Input
placeholder="例如: eth0/1 或 1/0/24"
size="large"
prefix={<TagOutlined style={{ color: designTokens.colors.neutral[400] }} />}
suffix={
<Tooltip title="输入单个端口名称,批量创建请切换至范围模式">
<InfoCircleOutlined
style={{ color: designTokens.colors.neutral[400] }}
/>
</Tooltip>
}
style={{ borderRadius: '8px' }}
onChange={e => updatePortPreview('quick', { portName: e.target.value })}
/>
</Form.Item>
)}
{/* 解析失败提示 */}
{parseError && !editingPort && (
<Alert
message={parseError}
type="error"
showIcon
style={{ marginTop: '8px', borderRadius: '6px', fontSize: '12px' }}
/>
)}
{/* 端口预览 */}
{showPreview && parseResult && !editingPort && (
<div
style={{
background: `linear-gradient(135deg, ${designTokens.colors.info.bg} 0%, ${designTokens.colors.primary.bg} 100%)`,
borderRadius: '10px',
padding: '12px',
marginTop: '8px',
border: `1px solid ${designTokens.colors.primary.light}20`,
}}
>
<div
style={{
fontSize: '12px',
fontWeight: 600,
color: designTokens.colors.primary.dark,
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<ThunderboltOutlined />
将创建 {parseResult.portCount} 个端口
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{previewPorts.map((port, index) => (
<Tag
key={index}
style={{
background: '#fff',
border: `1px solid ${designTokens.colors.primary.light}`,
color: designTokens.colors.primary.main,
borderRadius: '4px',
fontSize: '11px',
fontWeight: 500,
padding: '2px 6px',
}}
>
{port}
</Tag>
))}
{parseResult.portCount > previewPorts.length && (
<Tag
style={{
background: designTokens.colors.neutral[100],
border: `1px solid ${designTokens.colors.neutral[300]}`,
color: designTokens.colors.neutral[600],
borderRadius: '4px',
fontSize: '11px',
padding: '2px 6px',
}}
>
... {parseResult.portCount}
</Tag>
)}
</div>
</div>
)}
</>
) : (
<Form.Item
name="deviceId"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>设备</span>}
rules={[{ required: true, message: '请选择设备' }]}
style={{ marginBottom: '0' }}
>
<Select
placeholder="输入关键词搜索设备"
showSearch
loading={deviceSearching}
filterOption={false}
onSearch={handleDeviceSearch}
onDropdownVisibleChange={open => {
if (open && devices.length === 0) {
fetchDevices();
}
}}
disabled={!!editingPort}
size="large"
style={{ borderRadius: '8px' }}
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
{device.name} ({device.deviceId})
</Option>
))}
</Select>
</Form.Item>
)}
</div>
</Col>
{/* 右侧:端口属性 */}
<Col span={12}>
<div
style={{
background: designTokens.colors.neutral[50],
borderRadius: '12px',
padding: '16px',
height: '100%',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<ThunderboltOutlined style={{ color: designTokens.colors.primary.main }} />
端口属性
</div>
<Form.Item
name="portType"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>端口类型</span>}
rules={[{ required: true, message: '请选择端口类型' }]}
initialValue="RJ45"
style={{ marginBottom: '12px' }}
>
<Select size="large" style={{ borderRadius: '8px' }}>
<Option value="RJ45">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '2px',
background: designTokens.colors.device.server,
}}
/>
RJ45
</div>
</Option>
<Option value="SFP">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '2px',
background: designTokens.colors.device.switch,
}}
/>
SFP
</div>
</Option>
<Option value="SFP+">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '2px',
background: designTokens.colors.purple.main,
}}
/>
SFP+
</div>
</Option>
<Option value="SFP28">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '2px',
background: designTokens.colors.info.main,
}}
/>
SFP28
</div>
</Option>
<Option value="QSFP">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '2px',
background: designTokens.colors.warning.main,
}}
/>
QSFP
</div>
</Option>
<Option value="QSFP28">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '2px',
background: designTokens.colors.secondary.main,
}}
/>
QSFP28
</div>
</Option>
</Select>
</Form.Item>
<Form.Item
name="portSpeed"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>端口速率</span>}
initialValue="1G"
style={{ marginBottom: '12px' }}
>
<Select size="large" style={{ borderRadius: '8px' }}>
<Option value="100M">100M</Option>
<Option value="1G">1G</Option>
<Option value="10G">10G</Option>
<Option value="25G">25G</Option>
<Option value="40G">40G</Option>
<Option value="100G">100G</Option>
</Select>
</Form.Item>
<Form.Item
name="vlanId"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>VLAN ID</span>}
style={{ marginBottom: '0' }}
>
<InputNumber
placeholder="1-4094"
min={1}
max={4094}
size="large"
style={{ width: '100%', borderRadius: '8px' }}
/>
</Form.Item>
{editingPort && (
<Form.Item
name="status"
label={<span style={{ fontSize: '13px', fontWeight: 500 }}>状态</span>}
rules={[{ required: true, message: '请选择状态' }]}
style={{ marginTop: '12px', marginBottom: '0' }}
>
<Select size="large" style={{ borderRadius: '8px' }}>
<Option value="free">
<Tag color="success">空闲</Tag>
</Option>
<Option value="occupied">
<Tag color="warning">占用</Tag>
</Option>
<Option value="fault">
<Tag color="error">故障</Tag>
</Option>
</Select>
</Form.Item>
)}
{!editingPort && (
<div
style={{
background: designTokens.colors.success.bg,
border: `1px solid ${designTokens.colors.success.light}`,
borderRadius: '8px',
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: '10px',
marginTop: '12px',
}}
>
<CheckCircleOutlined
style={{ color: designTokens.colors.success.main, fontSize: '16px' }}
/>
<div>
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.success.dark,
}}
>
新建端口默认状态为空闲
</div>
<div style={{ fontSize: '11px', color: designTokens.colors.success.main }}>
接线后状态将自动更新为占用
</div>
</div>
</div>
)}
</div>
</Col>
</Row>
{/* 描述信息 - 底部 */}
<div
style={{
background: designTokens.colors.neutral[50],
borderRadius: '12px',
padding: '16px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<FileTextOutlined style={{ color: designTokens.colors.primary.main }} />
描述信息
</div>
<Form.Item name="description" style={{ marginBottom: 0 }}>
<TextArea
rows={2}
placeholder="请输入描述信息(可选)"
style={{ borderRadius: '8px', resize: 'none' }}
/>
</Form.Item>
</div>
</Form>
</div>
<div
style={{
padding: '16px 24px',
background: designTokens.colors.neutral[50],
borderTop: `1px solid ${designTokens.colors.neutral[200]}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderRadius: '0 0 16px 16px',
}}
>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
{editingPort ? '编辑模式下仅修改单个端口' : '支持批量创建端口'}
</div>
<Space>
<Button
size="large"
onClick={() => {
setModalVisible(false);
form.resetFields();
setSelectedDeviceForPort(null);
setParseError(null);
setParseResult(null);
setPreviewPorts([]);
setShowPreview(false);
}}
style={{ borderRadius: '8px', minWidth: '80px' }}
>
取消
</Button>
<Button
type="primary"
size="large"
onClick={handleSubmit}
icon={<PlusOutlined />}
style={{
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.primary.dark} 100%)`,
border: 'none',
borderRadius: '8px',
boxShadow: `0 4px 12px ${designTokens.colors.primary.main}40`,
minWidth: '120px',
}}
>
{editingPort ? '保存' : '创建'}
</Button>
</Space>
</div>
</Modal>
{/* 批量导入弹窗 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: designTokens.borderRadius.md,
background: designTokens.colors.info.main,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
<ImportOutlined />
</div>
<span style={{ fontSize: '18px', fontWeight: 600 }}>
{importDeviceType === 'server' ? '服务器批量导入' : '交换机批量导入'}
</span>
</div>
}
open={importModalVisible}
closeIcon={<CloseButton />}
onCancel={() => {
setImportModalVisible(false);
setImportPreview([]);
setImportErrors([]);
setImportProgress({ current: 0, total: 0 });
}}
width={900}
footer={[
<Button
key="cancel"
onClick={() => setImportModalVisible(false)}
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 style={{ marginTop: '8px' }}>
<strong>操作步骤</strong>
</div>
<div style={{ paddingLeft: '12px', marginTop: '4px' }}>
<div>1. 点击下载模板获取{importDeviceType === 'server' ? '服务器批量导入' : '交换机批量导入'}模板</div>
<div>
2. 按模板格式填写数据
</div>
<div style={{ paddingLeft: '12px', marginTop: '4px' }}>
{importDeviceType === 'server' ? (
<>
<div>
- <strong>第一张表格网卡数据):</strong>填写服务器的网卡信息
</div>
<div>
- <strong>第二张表格端口数据):</strong>填写网卡对应的端口信息
</div>
</>
) : (
<div>
- <strong>端口数据</strong>填写交换机的端口信息
</div>
)}
</div>
<div>3. 点击上传区域选择文件或直接拖拽文件到上传区域</div>
<div>4. 系统自动校验数据可预览前10条数据及错误详情</div>
<div>5. 选择导入策略跳过/更新已存在),点击开始导入</div>
</div>
{importDeviceType === 'server' && (
<>
<div style={{ marginTop: '8px' }}>
<strong>网卡数据字段说明</strong>
</div>
<div style={{ paddingLeft: '12px', marginTop: '4px' }}>
<div>
<strong>设备SN</strong>必填):服务器的唯一序列号如SN-20230001
</div>
<div>
<strong>网卡名称</strong>必填):网卡的名称或标识如网卡1eth0
</div>
<div>
<strong>插槽编号</strong>选填):网卡所在的插槽位置可选择预选项LOMOCP1-4或填写其他数字
</div>
<div>
<strong>网卡型号</strong>选填):如Intel X710BCM57414
</div>
<div>
<strong>制造商</strong>选填):如IntelMellanox
</div>
<div>
<strong>描述</strong>选填):备注信息
</div>
</div>
</>
)}
<div style={{ marginTop: '8px' }}>
<strong>端口数据字段说明</strong>
</div>
<div style={{ paddingLeft: '12px', marginTop: '4px' }}>
<div>
<strong>设备SN</strong>必填):{importDeviceType === 'server' ? '服务器' : '交换机'}的唯一序列号{importDeviceType === 'server' ? 'SN-20230001' : 'SW-20230001'}
</div>
{importDeviceType === 'server' && (
<div>
<strong>网卡名称</strong>必填):端口所属的网卡名称必须与网卡数据中的网卡名称对应
</div>
)}
<div>
<strong>端口名称</strong>必填):端口的名称或标识{importDeviceType === 'server' ? 'eth0/1' : 'GigabitEthernet0/0/1'}
</div>
<div>
<strong>端口类型</strong>选填):如RJ45LCSCFCSFP+QSFP28等
</div>
<div>
<strong>端口速率</strong>选填):如1G10G25G100G40G等
</div>
<div>
<strong>状态</strong>选填):空闲/占用/故障默认为空闲
</div>
<div>
<strong>VLAN ID</strong>选填):端口所属的VLAN编号如100
</div>
<div>
<strong>描述</strong>选填):备注信息
</div>
</div>
<div style={{ marginTop: '8px', color: '#faad14' }}>
<strong>注意事项</strong>
</div>
<div style={{ paddingLeft: '12px', marginTop: '4px', color: '#faad14' }}>
{importDeviceType === 'server' && (
<div> 同一设备下网卡名称不可重复</div>
)}
<div> 同一设备下端口名称不可重复</div>
<div> 批量导入支持最多50000条记录</div>
<div> 状态字段请使用空闲/占用/故障勿使用其他值</div>
{importDeviceType === 'server' && (
<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">
<UploadIcon 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 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
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 }}
/>
</motion.div>
)}
{importPreview.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
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: '设备SN', dataIndex: '设备SN', key: 'deviceSn', width: 120 },
{ title: '端口名称', dataIndex: '端口名称', key: 'portName', width: 120 },
{
title: '端口类型',
dataIndex: '端口类型',
key: 'portType',
width: 100,
render: type => getPortTypeTag(type),
},
{ title: '端口速率', dataIndex: '端口速率', key: 'portSpeed', width: 100 },
{
title: '状态',
dataIndex: '状态',
key: 'status',
width: 100,
render: status => getStatusTag(status),
},
{
title: 'VLAN ID',
dataIndex: 'VLAN ID',
key: 'vlanId',
width: 100,
render: vlanId => vlanId || '-',
},
{ title: '描述', dataIndex: '描述', key: 'description', ellipsis: true },
]}
dataSource={importPreview.slice(0, 10)}
rowKey={(record, index) => `import-row-${index}`}
pagination={false}
size="small"
scroll={{ x: 800 }}
style={{ borderRadius: designTokens.borderRadius.md }}
/>
{importPreview.length > 10 && (
<div
style={{
textAlign: 'center',
marginTop: '12px',
color: designTokens.colors.neutral[500],
}}
>
仅显示前10条数据 {importPreview.length}
</div>
)}
</motion.div>
)}
{importing && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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>
</motion.div>
)}
</div>
</Modal>
{/* 设备选择弹窗 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '4px 0' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
}}
>
<ApiOutlined style={{ fontSize: '20px' }} />
</div>
<div>
<div style={{ fontSize: '18px', fontWeight: 600, color: '#1a1a2e', lineHeight: 1.3 }}>
选择设备
</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
为端口选择所属设备
</div>
</div>
</div>
}
open={selectDeviceModalVisible}
closeIcon={<CloseButton />}
onCancel={() => {
setSelectDeviceModalVisible(false);
setDeviceFilterType('all');
setDevicePage(1);
setSelectedRowKeys([]);
setGuidedDeviceType(null);
setSelectedRoomId(null);
setSelectedRackId(null);
}}
footer={null}
width={620}
style={{ top: 100 }}
bodyStyle={{ padding: '0 24px 24px' }}
>
{guidedDeviceType && (
<div
style={{
margin: '0 -24px 16px',
padding: '12px 24px',
background:
guidedDeviceType === 'switch'
? 'linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%)'
: 'linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%)',
borderBottom: `1px solid ${guidedDeviceType === 'switch' ? '#bbf7d0' : '#c7d2fe'}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{guidedDeviceType === 'switch' ? (
<ApiOutlined style={{ fontSize: '16px', color: '#059669' }} />
) : (
<CloudServerOutlined style={{ fontSize: '16px', color: '#6366f1' }} />
)}
<span
style={{
fontSize: '13px',
fontWeight: 500,
color: guidedDeviceType === 'switch' ? '#047857' : '#4f46e5',
}}
>
{guidedDeviceType === 'switch' ? '添加交换机端口' : '添加服务器端口'}
</span>
<span style={{ fontSize: '12px', color: '#666' }}>
{guidedDeviceType === 'switch'
? '(交换机端口可直接创建,无需关联网卡)'
: '(服务器端口需关联网卡)'}
</span>
</div>
</div>
)}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
<Input
placeholder="搜索设备名称、IP或位置..."
prefix={<SearchOutlined style={{ color: '#999' }} />}
allowClear
onChange={e => handleDeviceSearch(e.target.value)}
style={{
flex: 1,
borderRadius: '10px',
border: '1px solid #e8ecf4',
height: '40px',
fontSize: '14px',
}}
/>
<Select
placeholder="按机房筛选"
allowClear
value={selectedRoomId}
onChange={value => {
setSelectedRoomId(value);
setSelectedRackId(null);
}}
style={{ width: 140 }}
size="middle"
>
{roomList.map(room => (
<Select.Option key={room.roomId} value={room.roomId}>
{room.name}
</Select.Option>
))}
</Select>
<Select
placeholder="按机柜筛选"
allowClear
value={selectedRackId}
onChange={value => setSelectedRackId(value)}
style={{ width: 140 }}
size="middle"
disabled={!selectedRoomId}
>
{rackList
.filter(rack => !selectedRoomId || rack.roomId === selectedRoomId)
.map(rack => (
<Select.Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Select.Option>
))}
</Select>
</div>
{(() => {
const rackRoomMap = {};
rackList.forEach(rack => {
rackRoomMap[rack.rackId] = rack.roomId;
});
const allDevices = devices.filter(d => {
const type = getDeviceType(d);
if (type !== 'switch' && type !== 'server') return false;
if (selectedRackId && d.rackId !== selectedRackId) return false;
if (selectedRoomId && d.rackId && rackRoomMap[d.rackId] !== selectedRoomId)
return false;
return true;
});
const switchCount = allDevices.filter(d => getDeviceType(d) === 'switch').length;
const serverCount = allDevices.filter(d => getDeviceType(d) === 'server').length;
const showSwitchTab = guidedDeviceType === null || guidedDeviceType === 'switch';
const showServerTab = guidedDeviceType === null || guidedDeviceType === 'server';
return (
<div
style={{
display: 'flex',
gap: '8px',
marginBottom: '16px',
background: '#f5f7fa',
padding: '4px',
borderRadius: '12px',
}}
>
{guidedDeviceType === null && (
<button
onClick={() => {
setDeviceFilterType('all');
setDevicePage(1);
}}
style={{
flex: 1,
padding: '10px 16px',
border: 'none',
borderRadius: '8px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
background:
deviceFilterType === 'all'
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'transparent',
color: deviceFilterType === 'all' ? '#fff' : '#666',
boxShadow:
deviceFilterType === 'all' ? '0 2px 8px rgba(102, 126, 234, 0.3)' : 'none',
}}
>
全部 ({allDevices.length})
</button>
)}
{showSwitchTab && (
<button
onClick={() => {
setDeviceFilterType('switch');
setDevicePage(1);
}}
style={{
flex: 1,
padding: '10px 16px',
border: 'none',
borderRadius: '8px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
background:
deviceFilterType === 'switch'
? 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'
: 'transparent',
color: deviceFilterType === 'switch' ? '#fff' : '#666',
boxShadow:
deviceFilterType === 'switch' ? '0 2px 8px rgba(17, 153, 142, 0.3)' : 'none',
}}
>
交换机 ({switchCount})
</button>
)}
{showServerTab && (
<button
onClick={() => {
setDeviceFilterType('server');
setDevicePage(1);
}}
style={{
flex: 1,
padding: '10px 16px',
border: 'none',
borderRadius: '8px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
background:
deviceFilterType === 'server'
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'transparent',
color: deviceFilterType === 'server' ? '#fff' : '#666',
boxShadow:
deviceFilterType === 'server' ? '0 2px 8px rgba(102, 126, 234, 0.3)' : 'none',
}}
>
服务器 ({serverCount})
</button>
)}
</div>
);
})()}
<div
style={{ maxHeight: '480px', overflowY: 'auto', margin: '0 -24px', padding: '0 16px' }}
>
{deviceSearching ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin size="large" />
<div style={{ marginTop: '16px', color: '#999', fontSize: '13px' }}>
加载设备中...
</div>
</div>
) : paginatedDevices.list.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
}}
>
<SearchOutlined style={{ fontSize: '32px', color: '#ccc' }} />
</div>
<div style={{ fontSize: '15px', color: '#666', marginBottom: '4px' }}>未找到设备</div>
<div style={{ fontSize: '13px', color: '#999' }}>
{deviceFilterType === 'all'
? '暂无可添加端口的设备'
: deviceFilterType === 'switch'
? '暂无可添加端口的交换机'
: '暂无可添加端口的服务器'}
</div>
</div>
) : (
<>
<div
style={{
fontSize: '12px',
color: '#999',
marginBottom: '12px',
paddingLeft: '4px',
}}
>
{paginatedDevices.total} 个设备
</div>
{paginatedDevices.list.map(device => (
<div
key={device.deviceId}
onClick={() => handleSelectDeviceForPort(device)}
style={{
padding: '14px 16px',
marginBottom: '10px',
borderRadius: '12px',
border: '1px solid #e8ecf4',
cursor: 'pointer',
transition: 'all 0.2s',
background: '#fff',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.background = '#f8f9ff';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.15)';
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = '#e8ecf4';
e.currentTarget.style.background = '#fff';
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: isServerDevice(device)
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '22px',
boxShadow: isServerDevice(device)
? '0 4px 12px rgba(102, 126, 234, 0.3)'
: '0 4px 12px rgba(17, 153, 142, 0.3)',
flexShrink: 0,
}}
>
{getDeviceIcon(device)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: '15px',
color: '#1a1a2e',
marginBottom: '4px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{device.name || '未命名设备'}
</div>
<div
style={{
fontSize: '12px',
color: '#999',
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
}}
>
{device.Rack?.Room?.name && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span>📍</span>
{device.Rack.Room.name}
</span>
)}
{device.Rack?.name && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span>🗄️</span>
{device.Rack.name}
</span>
)}
{device.ipAddress && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span>🌐</span>
{device.ipAddress}
</span>
)}
</div>
</div>
<div
style={{
padding: '4px 10px',
borderRadius: '6px',
fontSize: '11px',
fontWeight: 500,
background: isServerDevice(device)
? 'rgba(102, 126, 234, 0.1)'
: 'rgba(17, 153, 142, 0.1)',
color: isServerDevice(device) ? '#667eea' : '#11998e',
flexShrink: 0,
}}
>
{isServerDevice(device) ? '服务器' : '交换机'}
</div>
</div>
</div>
))}
{paginatedDevices.hasMore && (
<div
ref={loadMoreRef}
style={{
textAlign: 'center',
padding: '20px 0',
cursor: 'pointer',
}}
onClick={() => setDevicePage(p => p + 1)}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '10px 20px',
borderRadius: '20px',
background: '#f5f7fa',
color: '#666',
fontSize: '13px',
transition: 'all 0.2s',
}}
onMouseEnter={e => {
e.currentTarget.style.background = '#667eea';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={e => {
e.currentTarget.style.background = '#f5f7fa';
e.currentTarget.style.color = '#666';
}}
>
<span>点击加载更多</span>
<span style={{ fontSize: '11px' }}>
({Math.min(devicePage * 100, paginatedDevices.total)} /{' '}
{paginatedDevices.total})
</span>
</div>
</div>
)}
{!paginatedDevices.hasMore && paginatedDevices.total > 0 && (
<div
style={{
textAlign: 'center',
padding: '20px 0',
color: '#999',
fontSize: '13px',
}}
>
<div style={{ marginBottom: '4px', color: '#667eea', fontWeight: 500 }}>
已加载全部
</div>
<div> {paginatedDevices.total} 个设备</div>
</div>
)}
</>
)}
</div>
</Modal>
{/* 网卡管理模态框 */}
<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',
}}
>
<CloudServerOutlined />
</div>
<span style={{ fontSize: '18px', fontWeight: 600 }}>
网卡管理 - {selectedDeviceForNic?.name}
</span>
</div>
}
open={networkCardModalVisible}
closeIcon={<CloseButton />}
onCancel={() => {
setNetworkCardModalVisible(false);
setSelectedDeviceForNic(null);
}}
footer={null}
width={800}
destroyOnClose
>
{selectedDeviceForNic && (
<NetworkCardPanel
deviceId={selectedDeviceForNic.deviceId}
deviceName={selectedDeviceForNic.name}
onRefresh={fetchPorts}
refreshTrigger={refreshTrigger}
/>
)}
</Modal>
{/* 创建网卡模态框 */}
<NetworkCardCreateModal
device={selectedDeviceForNic}
visible={portCreateModalVisible}
onClose={() => {
setPortCreateModalVisible(false);
setSelectedDeviceForNic(null);
}}
onSuccess={handleNicSuccess}
/>
{/* 服务器网卡列表弹窗 */}
<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',
}}
>
<CloudServerOutlined />
</div>
<span style={{ fontSize: '18px', fontWeight: 600 }}>服务器网卡管理</span>
</div>
}
open={serverNicListVisible}
closeIcon={<CloseButton />}
onCancel={() => {
setServerNicListVisible(false);
setServerNicList([]);
setServerNicSearchText('');
setServerNicRoomFilter('all');
setServerNicRackFilter('all');
setServerNicCardPage(1);
}}
footer={null}
width={900}
destroyOnClose
>
{serverNicLoading ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin size="large" />
<div style={{ marginTop: '16px', color: '#999' }}>加载中...</div>
</div>
) : (
<>
<div
style={{
display: 'flex',
gap: '12px',
marginBottom: '16px',
flexWrap: 'wrap',
}}
>
<Input
placeholder="搜索服务器名称或ID..."
prefix={<SearchOutlined style={{ color: designTokens.colors.primary.main }} />}
value={serverNicSearchText}
onChange={e => {
setServerNicSearchText(e.target.value);
setServerNicCardPage(1);
}}
allowClear
style={{
width: '260px',
borderRadius: designTokens.borderRadius.md,
}}
/>
<Select
value={serverNicRoomFilter}
onChange={value => {
setServerNicRoomFilter(value);
setServerNicRackFilter('all');
setServerNicCardPage(1);
}}
style={{ width: '140px' }}
placeholder="选择机房"
>
<Option value="all">全部机房</Option>
{roomList.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
))}
</Select>
<Select
value={serverNicRackFilter}
onChange={value => {
setServerNicRackFilter(value);
setServerNicCardPage(1);
}}
style={{ width: '140px' }}
placeholder="选择机柜"
disabled={serverNicRoomFilter === 'all'}
>
<Option value="all">全部机柜</Option>
{rackList
.filter(
rack => serverNicRoomFilter === 'all' || rack.roomId === serverNicRoomFilter
)
.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Option>
))}
</Select>
<div
style={{
marginLeft: 'auto',
color: designTokens.colors.text.secondary,
fontSize: '13px',
alignSelf: 'center',
}}
>
{serverNicList.length} 台设备
</div>
</div>
{serverNicList.length === 0 ? (
<Empty description="暂无服务器数据" />
) : (
<>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '16px',
maxHeight: '520px',
overflowY: 'auto',
padding: '4px',
}}
>
{serverNicList
.filter(server => {
const searchMatch =
!serverNicSearchText ||
server.name?.toLowerCase().includes(serverNicSearchText.toLowerCase()) ||
server.deviceId?.toLowerCase().includes(serverNicSearchText.toLowerCase());
const roomMatch =
serverNicRoomFilter === 'all' || server.roomId === serverNicRoomFilter;
const rackMatch =
serverNicRackFilter === 'all' || server.rackId === serverNicRackFilter;
return searchMatch && roomMatch && rackMatch;
})
.slice(
(serverNicCardPage - 1) * serverNicCardPageSize,
serverNicCardPage * serverNicCardPageSize
)
.map(server => (
<ServerNicCard
key={server.deviceId}
server={server}
onManage={() => {
setSelectedDeviceForNic(server);
setServerNicListVisible(false);
setNetworkCardModalVisible(true);
}}
/>
))}
</div>
{serverNicList.filter(server => {
const searchMatch =
!serverNicSearchText ||
server.name?.toLowerCase().includes(serverNicSearchText.toLowerCase()) ||
server.deviceId?.toLowerCase().includes(serverNicSearchText.toLowerCase());
const roomMatch =
serverNicRoomFilter === 'all' || server.roomId === serverNicRoomFilter;
const rackMatch =
serverNicRackFilter === 'all' || server.rackId === serverNicRackFilter;
return searchMatch && roomMatch && rackMatch;
}).length > serverNicCardPageSize && (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '16px',
paddingTop: '12px',
borderTop: `1px solid ${designTokens.colors.border.light}`,
}}
>
<Pagination
current={serverNicCardPage}
pageSize={serverNicCardPageSize}
total={
serverNicList.filter(server => {
const searchMatch =
!serverNicSearchText ||
server.name
?.toLowerCase()
.includes(serverNicSearchText.toLowerCase()) ||
server.deviceId
?.toLowerCase()
.includes(serverNicSearchText.toLowerCase());
const typeMatch =
serverNicTypeFilter === 'all' || server.type === serverNicTypeFilter;
return searchMatch && typeMatch;
}).length
}
onChange={page => setServerNicCardPage(page)}
showSizeChanger={false}
showQuickJumper
size="small"
/>
</div>
)}
</>
)}
</>
)}
</Modal>
{/* 批量导入选择弹窗 */}
<BatchImportModal
visible={batchImportModalVisible}
onClose={() => setBatchImportModalVisible(false)}
onImportNetworkCard={() => {
setImportDeviceType('server');
handleImport();
}}
onImportPort={() => {
setImportDeviceType('switch');
handleImport();
}}
/>
{/* 网卡批量导入弹窗 */}
<NetworkCardImportModal
visible={networkCardImportModalVisible}
onClose={() => setNetworkCardImportModalVisible(false)}
onSuccess={async () => {
message.success({
content: '网卡导入成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setRefreshTrigger(prev => prev + 1);
// 重新获取设备列表,确保包含最新数据
await fetchDevices();
// 服务器设备导入网卡后自动进入端口导入
if (importDeviceType === 'server') {
setTimeout(() => {
setNetworkCardImportModalVisible(false);
handleImport();
}, 1000);
}
}}
/>
{/* 端口新增引导弹窗 */}
<PortAddGuideModal
visible={portAddGuideModalVisible}
onClose={() => setPortAddGuideModalVisible(false)}
onSelectType={handleGuideSelectType}
/>
{/* 端口导出弹窗 */}
<PortExportModal
visible={portExportModalVisible}
filters={filters}
totalCount={portTotal}
currentPageCount={ports.length}
selectedCount={selectedRowKeys.length}
onExport={handleExport}
onCancel={() => setPortExportModalVisible(false)}
/>
</motion.div>
);
}
export default PortManagement;