import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, Collapse, Empty, Spin, Upload, Progress, Checkbox, Statistic, Row, Col, Badge, Divider, Typography, Alert, Skeleton, } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon, AppstoreOutlined, SwapOutlined, CheckCircleOutlined, ExclamationCircleOutlined, DisconnectOutlined, LinkOutlined, FilterOutlined, ClearOutlined, MoreOutlined, } from '@ant-design/icons'; import axios from 'axios'; import * as XLSX from 'xlsx'; import Papa from 'papaparse'; import { motion, AnimatePresence } from 'framer-motion'; import { designTokens } from '../config/theme'; 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], }, }, }, card: { hidden: { opacity: 0, scale: 0.95 }, visible: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94], }, }, hover: { scale: 1.01, boxShadow: designTokens.shadows.lg, transition: { duration: 0.2 }, }, }, table: { hidden: { opacity: 0, y: 10 }, visible: { opacity: 1, y: 0, transition: { duration: 0.25 }, }, }, }; function CableManagement() { const [cables, setCables] = useState([]); const [devices, setDevices] = useState([]); const [deviceSearching, setDeviceSearching] = useState(false); const [switchDevices, setSwitchDevices] = useState([]); const [groupedCables, setGroupedCables] = useState({}); const [devicePorts, setDevicePorts] = useState({}); const [loading, setLoading] = useState(false); const [filters, setFilters] = useState({ switchDeviceId: '', status: 'all', cableType: 'all', searchText: '', }); const [modalVisible, setModalVisible] = useState(false); const [editingCable, setEditingCable] = useState(null); const [form] = Form.useForm(); const [importModalVisible, setImportModalVisible] = useState(false); const [importFileList, setImportFileList] = useState([]); const [importPreview, setImportPreview] = 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 [conflictModalVisible, setConflictModalVisible] = useState(false); const [conflictInfo, setConflictInfo] = useState(null); const [pendingSubmitValues, setPendingSubmitValues] = useState(null); const [expandedKeys, setExpandedKeys] = useState([]); const fetchCables = useCallback(async () => { try { setLoading(true); const params = {}; if (filters.switchDeviceId) params.sourceDeviceId = filters.switchDeviceId; if (filters.status !== 'all') params.status = filters.status; if (filters.cableType !== 'all') params.cableType = filters.cableType; const response = await axios.get('/api/cables', { params }); const cablesData = response.data.cables || []; // 搜索过滤 let filteredCables = cablesData; if (filters.searchText) { const searchLower = filters.searchText.toLowerCase(); filteredCables = cablesData.filter(cable => cable.sourceDevice?.name?.toLowerCase().includes(searchLower) || cable.targetDevice?.name?.toLowerCase().includes(searchLower) || cable.sourcePort?.toLowerCase().includes(searchLower) || cable.targetPort?.toLowerCase().includes(searchLower) || cable.cableId?.toLowerCase().includes(searchLower) ); } setCables(filteredCables); const grouped = {}; filteredCables.forEach(cable => { const switchId = cable.sourceDeviceId; if (!grouped[switchId]) { grouped[switchId] = { switch: cable.sourceDevice, cables: [], }; } grouped[switchId].cables.push(cable); }); setGroupedCables(grouped); // 自动展开前5个 const switchIds = Object.keys(grouped); setExpandedKeys(switchIds.slice(0, 5)); // 自动为每个交换机加载端口数据 for (const switchId of switchIds) { if (!devicePorts[switchId]) { try { const portsResponse = await axios.get(`/api/device-ports/device/${switchId}`); setDevicePorts(prev => ({ ...prev, [switchId]: portsResponse.data || [] })); } catch (error) { console.error(`获取交换机 ${switchId} 端口失败:`, error); } } } } catch (error) { message.error('获取接线列表失败'); console.error('获取接线列表失败:', error); } finally { setLoading(false); } }, [filters, devicePorts]); const fetchDevices = useCallback(async (keyword = '') => { try { setDeviceSearching(true); const params = { pageSize: 50 }; if (keyword && keyword.trim()) { params.keyword = keyword.trim(); } const response = await axios.get('/api/devices', { params }); const allDevices = response.data.devices || []; const switches = allDevices.filter(device => device.type === 'switch'); setDevices(allDevices); setSwitchDevices(switches); } catch (error) { message.error('获取设备列表失败'); console.error('获取设备列表失败:', error); } finally { setDeviceSearching(false); } }, []); const handleDeviceSearch = useCallback( debounce(value => { fetchDevices(value); }, 300), [fetchDevices] ); const fetchDevicePorts = useCallback(async deviceId => { if (!deviceId) { setDevicePorts(prev => ({ ...prev, [deviceId]: [] })); return; } try { const response = await axios.get(`/api/device-ports/device/${deviceId}`); setDevicePorts(prev => ({ ...prev, [deviceId]: response.data || [] })); } catch (error) { console.error('获取设备端口失败:', error); setDevicePorts(prev => ({ ...prev, [deviceId]: [] })); } }, []); useEffect(() => { fetchCables(); fetchDevices(); }, [fetchCables, fetchDevices]); const handleSearch = () => { fetchCables(); }; const handleReset = () => { setFilters({ switchDeviceId: '', status: 'all', cableType: 'all', searchText: '', }); }; const handleAdd = () => { setEditingCable(null); form.resetFields(); setModalVisible(true); }; const handleEdit = cable => { setEditingCable(cable); form.setFieldsValue({ sourceDeviceId: cable.sourceDeviceId, sourcePort: cable.sourcePort, targetDeviceId: cable.targetDeviceId, targetPort: cable.targetPort, cableType: cable.cableType, cableLength: cable.cableLength, status: cable.status, description: cable.description, }); setModalVisible(true); }; const handleDelete = async cableId => { try { await axios.delete(`/api/cables/${cableId}`); message.success({ content: '删除成功', icon: , }); fetchCables(); } catch (error) { message.error('删除失败'); console.error('删除失败:', error); } }; const handleDeleteSwitch = async switchId => { try { await axios.delete(`/api/devices/${switchId}`); message.success({ content: '删除设备成功', icon: , }); fetchDevices(); fetchCables(); } catch (error) { message.error('删除设备失败'); console.error('删除设备失败:', error); } }; const handleSubmit = async () => { try { const values = await form.validateFields(); // 如果是编辑模式,直接提交 if (editingCable) { await axios.put(`/api/cables/${editingCable.cableId}`, values); message.success({ content: '更新成功', icon: , }); setModalVisible(false); form.resetFields(); fetchCables(); return; } // 创建模式:先检查冲突 try { const checkResponse = await axios.post('/api/cables/check-conflict', { sourceDeviceId: values.sourceDeviceId, sourcePort: values.sourcePort, targetDeviceId: values.targetDeviceId, targetPort: values.targetPort, }); if (checkResponse.data.hasConflict) { setConflictInfo(checkResponse.data.conflicts); setPendingSubmitValues(values); setConflictModalVisible(true); return; } // 无冲突,直接创建 await axios.post('/api/cables', values); message.success({ content: '创建成功', icon: , }); setModalVisible(false); form.resetFields(); fetchCables(); } catch (error) { if (error.response?.status === 409) { // 冲突错误 setConflictInfo([ { type: 'unknown', existingCable: error.response.data.existingCable, }, ]); setPendingSubmitValues(values); setConflictModalVisible(true); } else { throw error; } } } catch (error) { message.error(editingCable ? '更新失败' : '创建失败'); console.error('提交失败:', error); } }; const handleForceSubmit = async () => { try { if (!pendingSubmitValues) return; await axios.post('/api/cables', { ...pendingSubmitValues, force: true, }); message.success({ content: '接线已强制接管并创建成功', icon: , }); setConflictModalVisible(false); setModalVisible(false); form.resetFields(); setPendingSubmitValues(null); setConflictInfo(null); fetchCables(); } catch (error) { message.error('强制接管失败'); console.error('强制接管失败:', error); } }; const handleImport = () => { setImportModalVisible(true); setImportPreview([]); setImportProgress({ current: 0, total: 0 }); }; const handleFileUpload = info => { const { file } = info; setImportFileList([file]); 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')) { Papa.parse(data, { header: true, skipEmptyLines: true, complete: results => { parsedData = results.data; }, }); } else { message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件'); return; } const validatedData = await validateImportData(parsedData); setImportPreview(validatedData); setImportProgress({ current: 0, total: validatedData.length }); } catch (error) { message.error('文件解析失败'); console.error('文件解析失败:', error); } }; reader.readAsBinaryString(file); }; const validateImportData = async data => { const validatedData = []; const errors = []; for (let i = 0; i < data.length; i++) { const row = data[i]; const error = await validateCableRow(row, i); if (error) { errors.push(error); } else { validatedData.push(row); } } if (errors.length > 0) { message.warning(`发现 ${errors.length} 条数据错误,已跳过`); } return validatedData; }; const validateCableRow = async (row, index) => { const errors = []; if (!row['源设备ID'] || !row['源设备端口']) { return { valid: false, error: `第 ${index + 1} 行:缺少必填字段(源设备ID或源设备端口)` }; } const sourceDevice = devices.find(d => d.deviceId === row['源设备ID']); if (!sourceDevice) { return { valid: false, error: `第 ${index + 1} 行:源设备不存在` }; } const targetDevice = devices.find(d => d.deviceId === row['目标设备ID']); if (!targetDevice) { return { valid: false, error: `第 ${index + 1} 行:目标设备不存在` }; } const validCableTypes = ['网线', '光纤', '铜缆']; if (!validCableTypes.includes(row['线缆类型'])) { return { valid: false, error: `第 ${index + 1} 行:无效的线缆类型` }; } const validStatuses = ['正常', '故障', '未连接']; if (!validStatuses.includes(row['状态'])) { return { valid: false, error: `第 ${index + 1} 行:无效的状态` }; } if (errors.length > 0) { return { valid: false, error: errors.join('; ') }; } return { valid: true }; }; const handleBatchImport = async () => { if (importPreview.length === 0) { message.warning('请先选择要导入的数据'); return; } setImporting(true); setImportProgress({ current: 0, total: importPreview.length }); try { const cableTypeMap = { 网线: 'ethernet', 光纤: 'fiber', 铜缆: 'copper', }; const statusMap = { 正常: 'normal', 故障: 'fault', 未连接: 'disconnected', }; const cablesData = importPreview.map((row, index) => ({ cableId: `CABLE-${Date.now()}-${index}`, sourceDeviceId: row['源设备ID'], sourcePort: row['源设备端口'], targetDeviceId: row['目标设备ID'], targetPort: row['目标设备端口'], cableType: cableTypeMap[row['线缆类型']] || 'ethernet', cableLength: row['线缆长度(米)'], status: statusMap[row['状态']] || 'normal', description: row['描述'], })); const response = await axios.post('/api/cables/batch', { cables: cablesData }); const { total, success, failed, errors } = response.data; setImportProgress({ current: total, total: total }); if (failed > 0) { console.error('导入错误:', errors); message.warning(`导入完成!成功 ${success} 条,失败 ${failed} 条`); } else { message.success({ content: `导入完成!成功 ${success} 条`, icon: , }); } fetchCables(); setImportModalVisible(false); setImportPreview([]); } catch (error) { console.error('批量导入失败:', error); message.error('批量导入失败,请检查数据格式'); } finally { setImporting(false); } }; const handleDownloadTemplate = () => { const templateData = [ { 源设备ID: 'DEV001', 源设备端口: 'eth0/1', 目标设备ID: 'DEV002', 目标设备端口: 'eth0', 线缆类型: '网线', '线缆长度(米)': '5', 状态: '正常', 描述: '示例接线', }, ]; 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 getStatusTag = status => { const statusMap = { normal: { color: 'success', text: '正常', icon: , bg: designTokens.colors.success.bg, }, fault: { color: 'error', text: '故障', icon: , bg: designTokens.colors.error.bg, }, disconnected: { color: 'default', text: '未连接', icon: , bg: designTokens.colors.neutral[100], }, }; const config = statusMap[status] || { color: 'default', text: status, icon: null, bg: designTokens.colors.neutral[100] }; return ( {config.text} ); }; const getCableTypeTag = type => { const typeMap = { ethernet: { color: 'blue', text: '网线', icon: '🔌' }, fiber: { color: 'cyan', text: '光纤', icon: '🔷' }, copper: { color: 'orange', text: '铜缆', icon: '⚡' }, }; const config = typeMap[type] || { color: 'default', text: type, icon: '' }; return ( {config.text} ); }; const getPortConnectionStatus = (portName, switchData) => { const cable = switchData.cables.find(c => c.sourcePort === portName); if (!cable) { return { status: 'disconnected', text: '未连接', color: 'default' }; } return { status: cable.status, text: cable.status === 'normal' ? '已连接' : cable.status === 'fault' ? '故障' : '未连接', color: cable.status === 'normal' ? 'success' : cable.status === 'fault' ? 'error' : 'default', }; }; const portColumns = useMemo(() => [ { title: '端口名称', dataIndex: 'portName', key: 'portName', width: 120, render: text => {text}, }, { title: '端口类型', dataIndex: 'portType', key: 'portType', width: 100, render: 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 {config.text}; }, }, { title: '端口速率', dataIndex: 'portSpeed', key: 'portSpeed', width: 100, render: text => {text}, }, { title: '连接状态', dataIndex: 'connectionStatus', key: 'connectionStatus', width: 100, render: (_, record) => { const status = getPortConnectionStatus(record.portName, record.switchData); return ( ); }, }, { title: '目标设备', dataIndex: 'targetDevice', key: 'targetDevice', width: 200, render: (_, record) => { const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); if (!cable) return -; return (
{cable.targetDevice?.name || '-'}
{cable.targetPort}
); }, }, { title: '线缆类型', dataIndex: 'cableType', key: 'cableType', width: 100, render: (_, record) => { const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); if (!cable) return -; return getCableTypeTag(cable.cableType); }, }, { title: '长度', dataIndex: 'cableLength', key: 'cableLength', width: 80, render: (_, record) => { const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); if (!cable) return -; return cable.cableLength ? {cable.cableLength}m : -; }, }, { title: '操作', key: 'action', width: 120, fixed: 'right', render: (_, record) => { const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); return ( {cable && ( <> ) : ( {Object.entries(groupedCables).map(([switchId, switchData], index) => { const switchDevice = switchData.switch; const switchPorts = devicePorts[switchId] || []; const connectedCount = switchData.cables.filter(c => c.status === 'normal').length; const disconnectedCount = switchData.cables.filter(c => c.status === 'disconnected').length; const faultCount = switchData.cables.filter(c => c.status === 'fault').length; return (
{switchDevice?.name || '未知设备'}
{switchDevice?.deviceId || '-'} · {switchDevice?.model || '交换机'}
} > {connectedCount} } > {disconnectedCount} {faultCount > 0 && ( } > {faultCount} )} 总计: {switchData.cables.length} } extra={ e.stopPropagation()}>