From d9c46245df87934101f94336c692b5e6aca7da0c Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Thu, 26 Mar 2026 14:35:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E5=86=B2=E7=AA=81=E6=A3=80=E6=9F=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(api): 在设备API中添加checkPosition接口用于检查U位冲突 feat(frontend): 在设备表单和空闲设备管理中实现U位冲突检查 refactor(backend): 重构操作日志功能,添加设备描述生成和元数据构建工具 fix(backend): 修复批量导入机柜时的ID验证规则 feat(backend): 为机柜导入添加创建和跳过机柜的详细返回信息 fix(backend): 修复设备删除时未检查关联接线的问题 feat(backend): 添加机柜导入模板生成脚本 perf(frontend): 优化机柜管理页面的导入结果展示 fix(frontend): 修复设备端口删除时的关联接线检查 --- backend/routes/devicePorts.js | 188 +++- backend/routes/devices.js | 217 ++++- backend/routes/idleDevices.js | 86 +- backend/routes/networkCards.js | 23 + backend/routes/racks.js | 20 +- .../scripts/generate-rack-import-template.js | 113 +++ backend/utils/operationLogger.js | 70 +- frontend/src/api/index.js | 1 + .../src/components/DeviceDetailDrawer.jsx | 583 ++++++++---- .../src/components/device/DeviceFormModal.jsx | 135 ++- frontend/src/pages/IdleDeviceManagement.jsx | 85 +- frontend/src/pages/PortManagement.jsx | 895 ++++++++++++++++-- frontend/src/pages/Rack3DVisualization.jsx | 7 - frontend/src/pages/RackManagement.jsx | 113 ++- 14 files changed, 2152 insertions(+), 384 deletions(-) create mode 100644 backend/scripts/generate-rack-import-template.js diff --git a/backend/routes/devicePorts.js b/backend/routes/devicePorts.js index d8f6ddd..d3ff830 100644 --- a/backend/routes/devicePorts.js +++ b/backend/routes/devicePorts.js @@ -4,6 +4,7 @@ const { Op } = require('sequelize'); const DevicePort = require('../models/DevicePort'); const Device = require('../models/Device'); const NetworkCard = require('../models/NetworkCard'); +const Cable = require('../models/Cable'); DevicePort.belongsTo(Device, { foreignKey: 'deviceId', as: 'device' }); Device.hasMany(DevicePort, { foreignKey: 'deviceId', as: 'ports' }); @@ -39,6 +40,11 @@ router.get('/', async (req, res) => { model: Device, as: 'device', attributes: ['deviceId', 'name', 'type', 'rackId'] + }, + { + model: NetworkCard, + as: 'networkCard', + attributes: ['nicId', 'name'] } ], offset, @@ -69,6 +75,11 @@ router.get('/device/:deviceId', async (req, res) => { model: Device, as: 'device', attributes: ['deviceId', 'name', 'type', 'rackId'] + }, + { + model: NetworkCard, + as: 'networkCard', + attributes: ['nicId', 'name'] } ], order: [['portName', 'ASC']] @@ -130,59 +141,89 @@ router.post('/', async (req, res) => { router.post('/batch', async (req, res) => { try { - const { ports } = req.body; - + const { ports, skipExisting = false, updateExisting = false } = req.body; + if (!ports || !Array.isArray(ports) || ports.length === 0) { return res.status(400).json({ error: '请提供有效的端口数据' }); } - + const results = { total: ports.length, success: 0, failed: 0, + skipped: 0, + updated: 0, errors: [] }; - - for (let i = 0; i < ports.length; i++) { - const portData = ports[i]; - - try { - if (!portData.portId || !portData.deviceId || !portData.portName) { - throw new Error('缺少必填字段'); + + const transaction = await DevicePort.sequelize.transaction(); + + try { + for (let i = 0; i < ports.length; i++) { + const portData = ports[i]; + + try { + if (!portData.portId || !portData.deviceId || !portData.portName) { + throw new Error('缺少必填字段'); + } + + const existingPort = await DevicePort.findOne({ + where: { deviceId: portData.deviceId, portName: portData.portName }, + transaction + }); + + if (existingPort) { + if (skipExisting) { + results.skipped++; + continue; + } + if (updateExisting) { + await DevicePort.update({ + portType: portData.portType || existingPort.portType, + portSpeed: portData.portSpeed || existingPort.portSpeed, + status: portData.status || existingPort.status, + vlanId: portData.vlanId !== undefined ? portData.vlanId : existingPort.vlanId, + description: portData.description !== undefined ? portData.description : existingPort.description + }, { + where: { portId: existingPort.portId }, + transaction + }); + results.updated++; + results.success++; + continue; + } + throw new Error('该设备的端口名称已存在'); + } + + await DevicePort.create({ + portId: portData.portId, + deviceId: portData.deviceId, + nicId: portData.nicId || null, + portName: portData.portName, + portType: portData.portType || 'RJ45', + portSpeed: portData.portSpeed || '1G', + status: portData.status || 'free', + vlanId: portData.vlanId, + description: portData.description + }, { transaction }); + + results.success++; + } catch (error) { + results.failed++; + results.errors.push({ + index: i + 1, + portId: portData.portId, + error: error.message + }); } - - const existingPort = await DevicePort.findOne({ - where: { deviceId: portData.deviceId, portName: portData.portName } - }); - - if (existingPort) { - throw new Error('该设备的端口名称已存在'); - } - - await DevicePort.create({ - portId: portData.portId, - deviceId: portData.deviceId, - nicId: portData.nicId || null, - portName: portData.portName, - portType: portData.portType || 'RJ45', - portSpeed: portData.portSpeed || '1G', - status: portData.status || 'free', - vlanId: portData.vlanId, - description: portData.description - }); - - results.success++; - } catch (error) { - results.failed++; - results.errors.push({ - index: i + 1, - portId: portData.portId, - error: error.message - }); } + + await transaction.commit(); + res.json(results); + } catch (error) { + await transaction.rollback(); + throw error; } - - res.json(results); } catch (error) { console.error('批量创建端口失败:', error); res.status(500).json({ error: error.message }); @@ -217,15 +258,38 @@ router.put('/:portId', async (req, res) => { router.delete('/:portId', async (req, res) => { try { - const deleted = await DevicePort.destroy({ + const port = await DevicePort.findByPk(req.params.portId); + if (!port) { + return res.status(404).json({ error: '端口不存在' }); + } + + const relatedCables = await Cable.findAll({ + where: { + [Op.or]: [ + { sourceDeviceId: port.deviceId, sourcePort: port.portName }, + { targetDeviceId: port.deviceId, targetPort: port.portName } + ] + } + }); + + if (relatedCables.length > 0) { + return res.status(400).json({ + error: '该端口存在关联的接线记录,请先删除关联的接线', + relatedCables: relatedCables.map(c => ({ + cableId: c.cableId, + sourceDeviceId: c.sourceDeviceId, + sourcePort: c.sourcePort, + targetDeviceId: c.targetDeviceId, + targetPort: c.targetPort + })) + }); + } + + await DevicePort.destroy({ where: { portId: req.params.portId } }); - - if (deleted) { - res.status(204).json(); - } else { - res.status(404).json({ error: '端口不存在' }); - } + + res.status(204).json(); } catch (error) { console.error('删除端口失败:', error); res.status(500).json({ error: error.message }); @@ -235,15 +299,37 @@ router.delete('/:portId', async (req, res) => { router.delete('/batch', async (req, res) => { try { const { portIds } = req.body; - + if (!portIds || !Array.isArray(portIds) || portIds.length === 0) { return res.status(400).json({ error: '请提供有效的端口ID列表' }); } - + const deletedCount = await DevicePort.destroy({ where: { portId: { [Op.in]: portIds } } }); - + + res.json({ + message: `批量删除成功,已删除 ${deletedCount} 个端口`, + deletedCount + }); + } catch (error) { + console.error('批量删除端口失败:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.post('/batch-delete', async (req, res) => { + try { + const { portIds } = req.body; + + if (!portIds || !Array.isArray(portIds) || portIds.length === 0) { + return res.status(400).json({ error: '请提供有效的端口ID列表' }); + } + + const deletedCount = await DevicePort.destroy({ + where: { portId: { [Op.in]: portIds } } + }); + res.json({ message: `批量删除成功,已删除 ${deletedCount} 个端口`, deletedCount diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 6cc42be..5fa2161 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -16,7 +16,7 @@ const DevicePort = require('../models/DevicePort'); const Cable = require('../models/Cable'); const NetworkCard = require('../models/NetworkCard'); const InventoryRecord = require('../models/InventoryRecord'); -const { logDeviceOperation } = require('../utils/operationLogger'); +const { logDeviceOperation, generateDeviceDescription, buildDeviceMetadata } = require('../utils/operationLogger'); const { validateBody, validateQuery } = require('../middleware/validation'); const { createDeviceSchema, @@ -710,12 +710,15 @@ router.post('/', validateBody(createDeviceSchema), async (req, res) => { `功耗: ${device.powerConsumption}W` ].join(';'); - await logDeviceOperation('create', `创建设备【${device.name}】`, { + await logDeviceOperation('create', generateDeviceDescription('创建设备', { + ...device.toJSON(), + rackName: rack?.name + }), { targetId: device.deviceId, targetName: device.name, afterState: device.toJSON(), req, - metadata: { deviceType: device.type, rackName: rack?.name, powerConsumption: device.powerConsumption } + metadata: buildDeviceMetadata({ ...device.toJSON(), rackName: rack?.name }) }); res.status(201).json(device); @@ -1482,26 +1485,39 @@ router.put('/batch-status', async (req, res) => { }; const beforeDevices = await Device.findAll({ - where: { deviceId: { [Op.in]: deviceIds } } + where: { deviceId: { [Op.in]: deviceIds } }, + include: [{ model: Rack, attributes: ['name'] }] }); - const deviceNames = beforeDevices.map(d => d.name); + const deviceDetails = beforeDevices.map(d => { + const data = d.toJSON(); + return { + deviceId: d.deviceId, + name: d.name, + type: d.type, + model: d.model, + serialNumber: d.serialNumber, + ipAddress: d.ipAddress, + rackName: data.Rack?.name || null, + position: d.position, + status: d.status + }; + }); - // 更新设备状态 - const [affectedCount] = await Device.update( - { status }, - { where: { deviceId: { [Op.in]: deviceIds } } } - ); + const deviceNames = deviceDetails.map(d => d.name); + const deviceSummary = deviceDetails.map(d => + `${d.name}(编号:${d.deviceId}${d.rackName ? `,机柜:${d.rackName}` : ''})` + ).join('、'); - const statusChangeDesc = `批量变更${affectedCount}台设备状态:${deviceNames.join('、')} → ${statusText[status]}`; + const statusChangeDesc = `批量变更${affectedCount}台设备状态:${deviceSummary} → ${statusText[status]}`; await logDeviceOperation('status_change', statusChangeDesc, { targetId: deviceIds.join(','), targetName: `${affectedCount}台设备`, - beforeState: beforeDevices.map(d => ({ deviceId: d.deviceId, name: d.name, status: d.status })), - afterState: beforeDevices.map(d => ({ deviceId: d.deviceId, name: d.name, status })), + beforeState: deviceDetails.map(d => ({ deviceId: d.deviceId, name: d.name, status: d.status })), + afterState: deviceDetails.map(d => ({ deviceId: d.deviceId, name: d.name, status })), req, - metadata: { status, statusText: statusText[status], count: affectedCount, deviceNames } + metadata: { status, statusText: statusText[status], count: affectedCount, devices: deviceDetails } }); res.json({ @@ -1534,16 +1550,32 @@ router.put('/batch-move', async (req, res) => { const devicesToMove = await Device.findAll({ where: { deviceId: { [Op.in]: deviceIds } }, - attributes: ['deviceId', 'name', 'rackId', 'position', 'height'] + attributes: ['deviceId', 'name', 'type', 'model', 'serialNumber', 'ipAddress', 'rackId', 'position', 'height', 'powerConsumption'] }); - const beforeMoveState = devicesToMove.map(d => ({ + const deviceDetails = devicesToMove.map(d => d.toJSON()); + + const beforeMoveState = deviceDetails.map(d => ({ deviceId: d.deviceId, name: d.name, + type: d.type, rackId: d.rackId, - position: d.position + position: d.position, + powerConsumption: d.powerConsumption })); + const deviceSummary = deviceDetails.map(d => + `${d.name}(编号:${d.deviceId}${d.type ? `,类型:${d.type}` : ''}${d.ipAddress ? `,IP:${d.ipAddress}` : ''})` + ).join('、'); + + const sourceRackPowerChanges = new Map(); + devicesToMove.forEach(device => { + if (device.rackId) { + const currentChange = sourceRackPowerChanges.get(device.rackId) || 0; + sourceRackPowerChanges.set(device.rackId, currentChange - (device.powerConsumption || 0)); + } + }); + const deviceHeightMap = new Map(devicesToMove.map(d => [d.deviceId, d.height || 1])); if (startPosition) { @@ -1601,6 +1633,8 @@ router.put('/batch-move', async (req, res) => { } let movedCount = 0; + const targetRackPowerChange = { rackId: targetRackId, change: 0 }; + for (let i = 0; i < deviceIds.length; i++) { const deviceId = deviceIds[i]; const position = startPosition ? startPosition + i : undefined; @@ -1616,13 +1650,36 @@ router.put('/batch-move', async (req, res) => { if (updated) { movedCount++; + const device = devicesToMove.find(d => d.deviceId === deviceId); + if (device) { + targetRackPowerChange.change += device.powerConsumption || 0; + } } } - const deviceNames = devicesToMove.map(d => d.name); + for (const [rackId, powerChange] of sourceRackPowerChanges) { + if (rackId !== targetRackId) { + await Rack.update( + { currentPower: sequelize.literal(`currentPower + ${powerChange}`) }, + { where: { rackId } } + ); + } + } + + if (sourceRackPowerChanges.has(targetRackId)) { + targetRackPowerChange.change += sourceRackPowerChanges.get(targetRackId); + } + + if (targetRackPowerChange.change !== 0) { + await Rack.update( + { currentPower: sequelize.literal(`currentPower + ${targetRackPowerChange.change}`) }, + { where: { rackId: targetRackId } } + ); + } + const moveDesc = startPosition - ? `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceNames.join('、')} → U${startPosition}起` - : `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceNames.join('、')}`; + ? `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceSummary} → U${startPosition}起` + : `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceSummary}`; await logDeviceOperation('move', moveDesc, { targetId: deviceIds.join(','), @@ -1630,7 +1687,7 @@ router.put('/batch-move', async (req, res) => { beforeState: beforeMoveState, afterState: { targetRackId, targetRackName: targetRack.name, startPosition }, req, - metadata: { count: movedCount, targetRackId, targetRackName: targetRack.name, startPosition, deviceNames } + metadata: { count: movedCount, targetRackId, targetRackName: targetRack.name, startPosition, devices: deviceDetails } }); res.json({ @@ -1817,6 +1874,29 @@ router.get('/enhanced-export', async (req, res) => { } }); +// 检查U位是否可用 +router.get('/check-position/:rackId', async (req, res) => { + try { + const { rackId } = req.params; + const { position, height, excludeDeviceId } = req.query; + + if (!position) { + return res.status(400).json({ error: '请提供位置参数' }); + } + + const result = await checkPositionAvailable( + rackId, + parseInt(position), + parseInt(height) || 1, + excludeDeviceId || null + ); + + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // 获取单个设备 router.get('/:deviceId', async (req, res) => { try { @@ -1875,13 +1955,17 @@ router.put('/:deviceId/to-idle', async (req, res) => { await t.commit(); - await logDeviceOperation('to_idle', `设备【${device.name}】转入空闲设备`, { + const deviceData = { + ...device.toJSON(), + rackName: device.rack?.name + }; + await logDeviceOperation('to_idle', generateDeviceDescription('转入空闲设备', deviceData), { targetId: device.deviceId, targetName: device.name, beforeState: { ...device.toJSON(), isIdle: false }, afterState: { ...device.toJSON(), isIdle: true }, req, - metadata: { idleReason, type: 'device_to_idle' } + metadata: buildDeviceMetadata(deviceData, { idleReason, type: 'device_to_idle' }) }); res.json({ @@ -1944,17 +2028,58 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => { const beforeState = oldDevice.toJSON(); const changedFields = {}; + const newRackId = req.body.rackId !== undefined ? req.body.rackId : oldDevice.rackId; + const newPosition = req.body.position !== undefined ? req.body.position : oldDevice.position; + const newHeight = req.body.height !== undefined ? req.body.height : oldDevice.height; + + if ((req.body.rackId !== undefined || req.body.position !== undefined || req.body.height !== undefined) + && newRackId && newPosition) { + const positionCheck = await checkPositionAvailable( + newRackId, + newPosition, + newHeight, + req.params.deviceId + ); + if (!positionCheck.available) { + return res.status(400).json({ error: positionCheck.reason }); + } + } + const [updated] = await Device.update(req.body, { where: { deviceId: req.params.deviceId } }); if (updated) { - const rack = await Rack.findByPk(oldDevice.rackId); - if (rack) { - const powerDiff = req.body.powerConsumption - oldDevice.powerConsumption; - await rack.update({ - currentPower: rack.currentPower + powerDiff - }); + const oldRackId = oldDevice.rackId; + const newRackId = req.body.rackId; + const oldPower = oldDevice.powerConsumption || 0; + const newPower = req.body.powerConsumption !== undefined ? req.body.powerConsumption : oldPower; + + if (oldRackId === newRackId) { + const rack = await Rack.findByPk(oldRackId); + if (rack) { + const powerDiff = newPower - oldPower; + await rack.update({ + currentPower: rack.currentPower + powerDiff + }); + } + } else { + if (oldRackId) { + const oldRack = await Rack.findByPk(oldRackId); + if (oldRack) { + await oldRack.update({ + currentPower: Math.max(0, oldRack.currentPower - oldPower) + }); + } + } + if (newRackId) { + const newRack = await Rack.findByPk(newRackId); + if (newRack) { + await newRack.update({ + currentPower: newRack.currentPower + newPower + }); + } + } } const updatedDevice = await Device.findByPk(req.params.deviceId, { @@ -1975,6 +2100,13 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => { } } + const deviceData = { + ...updatedDevice.toJSON(), + rackName: updatedDevice.Rack?.name, + roomName: updatedDevice.Rack?.Room?.name + }; + delete deviceData.Rack; + const changeDetails = Object.entries(changedFields).map(([field, values]) => { const fieldNames = { name: '名称', deviceId: '设备编号', type: '类型', model: '型号', @@ -1986,17 +2118,17 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => { return `${displayName}: ${values.from ?? '空'} → ${values.to ?? '空'}`; }).join(';'); - const operationDesc = changeDetails - ? `更新设备【${updatedDevice.name}】:${changeDetails}` - : `更新设备【${updatedDevice.name}】`; + const operationDesc = generateDeviceDescription('更新设备', deviceData, { + includePosition: false + }) + (changeDetails ? `,变更内容:${changeDetails}` : ''); await logDeviceOperation('update', operationDesc, { targetId: updatedDevice.deviceId, targetName: updatedDevice.name, beforeState, - afterState, + afterState: deviceData, req, - metadata: { changedFields } + metadata: buildDeviceMetadata(deviceData, { changedFields }) }); res.json(updatedDevice); @@ -2024,8 +2156,6 @@ router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res transaction: t }); - const deviceNames = devices.map(d => d.name).join(', '); - // 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡) await DevicePort.destroy({ where: { deviceId: { [Op.in]: deviceIds } }, @@ -2081,12 +2211,17 @@ router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res await t.commit(); - await logDeviceOperation('batch_delete', `批量删除${deletedCount}台设备:${deviceNames}`, { + const deviceDetails = devices.map(d => d.toJSON()); + const deviceSummary = deviceDetails.map(d => + `${d.name}(编号:${d.deviceId}${d.type ? `,类型:${d.type}` : ''}${d.serialNumber ? `,序列号:${d.serialNumber}` : ''})` + ).join('、'); + + await logDeviceOperation('batch_delete', `批量删除${deletedCount}台设备:${deviceSummary}`, { targetId: deviceIds.join(','), targetName: `${deletedCount}台设备`, - beforeState: devices.map(d => d.toJSON()), + beforeState: deviceDetails, req, - metadata: { count: deletedCount, deviceNames } + metadata: { count: deletedCount, devices: deviceDetails } }); res.json({ @@ -2260,12 +2395,12 @@ router.delete('/:deviceId', async (req, res) => { console.log(`已删除 ${deletedCables} 条相关接线`); } - await logDeviceOperation('delete', `删除设备【${deviceName}】(编号:${deviceId},类型:${device.type},关联删除:${deletedCables}条接线、${deletedPorts}个端口、${deletedNetworkCards}张网卡)`, { + await logDeviceOperation('delete', `删除设备【${deviceName}】(编号:${deviceId},类型:${device.type},型号:${device.model || '无'},序列号:${device.serialNumber || '无'},IP:${device.ipAddress || '无'}),关联删除:${deletedCables}条接线、${deletedPorts}个端口、${deletedNetworkCards}张网卡`, { targetId: deviceId, targetName: deviceName, beforeState, req, - metadata: { deletedCables, deletedPorts, deletedNetworkCards, deviceType: device.type } + metadata: buildDeviceMetadata(device.toJSON(), { deletedCables, deletedPorts, deletedNetworkCards }) }); res.status(200).json({ diff --git a/backend/routes/idleDevices.js b/backend/routes/idleDevices.js index d4e397c..0d40f09 100644 --- a/backend/routes/idleDevices.js +++ b/backend/routes/idleDevices.js @@ -4,7 +4,7 @@ const { Op } = require('sequelize'); const Device = require('../models/Device'); const Rack = require('../models/Rack'); const Room = require('../models/Room'); -const { logDeviceOperation } = require('../utils/operationLogger'); +const { logDeviceOperation, generateDeviceDescription, buildDeviceMetadata } = require('../utils/operationLogger'); async function generateIdleDeviceId() { const devices = await Device.findAll({ @@ -148,12 +148,19 @@ router.post('/', async (req, res) => { description: description || '' }); - await logDeviceOperation('create', `新增空闲设备【${device.name || deviceId}】`, { + await logDeviceOperation('create', generateDeviceDescription('新增空闲设备', { + deviceId: device.deviceId, + name: device.name || deviceId, + type: device.type, + model: device.model, + serialNumber: device.serialNumber, + ipAddress: device.ipAddress + }, { includeRack: false }), { targetId: device.deviceId, - targetName: device.name, + targetName: device.name || deviceId, afterState: device.toJSON(), req, - metadata: { sourceType: device.sourceType, type: 'idle_device_create' } + metadata: buildDeviceMetadata(device.toJSON(), { sourceType: device.sourceType, type: 'idle_device_create' }) }); res.status(201).json(device); @@ -189,13 +196,14 @@ router.post('/from-device/:deviceId', async (req, res) => { await t.commit(); - await logDeviceOperation('to_idle', `设备【${device.name}】转入空闲设备`, { + const deviceData = device.toJSON(); + await logDeviceOperation('to_idle', generateDeviceDescription('设备转入空闲设备', deviceData), { targetId: device.deviceId, targetName: device.name, - beforeState: { ...device.toJSON(), isIdle: false }, - afterState: { ...device.toJSON(), isIdle: true }, + beforeState: { ...deviceData, isIdle: false }, + afterState: { ...deviceData, isIdle: true }, req, - metadata: { idleReason, type: 'device_to_idle' } + metadata: buildDeviceMetadata(deviceData, { idleReason, type: 'device_to_idle' }) }); res.json({ @@ -244,11 +252,16 @@ router.post('/batch-from-devices', async (req, res) => { await t.commit(); - await logDeviceOperation('batch_to_idle', `批量将 ${notIdleDevices.length} 台设备转入空闲设备`, { + const deviceDetails = notIdleDevices.map(d => d.toJSON()); + const deviceSummary = deviceDetails.map(d => + `${d.name}(编号:${d.deviceId}${d.type ? `,类型:${d.type}` : ''}${d.serialNumber ? `,序列号:${d.serialNumber}` : ''})` + ).join('、'); + + await logDeviceOperation('batch_to_idle', `批量将 ${notIdleDevices.length} 台设备转入空闲设备:${deviceSummary}`, { targetId: deviceIds.join(','), targetName: `${notIdleDevices.length}台设备`, req, - metadata: { idleReason, type: 'batch_device_to_idle' } + metadata: { idleReason, type: 'batch_device_to_idle', devices: deviceDetails } }); res.json({ @@ -391,11 +404,16 @@ router.put('/batch-restore', async (req, res) => { const failedCount = results.filter(r => r.status === 'failed').length; const skippedCount = results.filter(r => r.status === 'skipped').length; - await logDeviceOperation('batch_restore', `批量上架 ${successCount} 台空闲设备`, { + const successDevices = idleDevices.filter(d => results.some(r => r.deviceId === d.deviceId && r.status === 'success')); + const deviceSummary = successDevices.map(d => + `${d.name}(编号:${d.deviceId}${d.type ? `,类型:${d.type}` : ''}${d.ipAddress ? `,IP:${d.ipAddress}` : ''})` + ).join('、'); + + await logDeviceOperation('batch_restore', `批量上架 ${successCount} 台空闲设备:${deviceSummary}`, { targetId: deviceIds.join(','), targetName: `${successCount}台设备`, req, - metadata: { results, type: 'batch_idle_device_restore' } + metadata: { results, type: 'batch_idle_device_restore', devices: successDevices.map(d => d.toJSON()) } }); res.json({ @@ -479,13 +497,19 @@ router.put('/:deviceId/shelve', async (req, res) => { ] }); - await logDeviceOperation('shelve', `空闲设备【${device.name}】上架到机柜【${targetRack.name}】U${position}`, { + const deviceData = { + ...updatedDevice.toJSON(), + rackName: targetRack.name, + roomName: updatedDevice.Rack?.Room?.name + }; + + await logDeviceOperation('shelve', generateDeviceDescription('空闲设备上架', deviceData, { includePosition: false }) + `到机柜【${targetRack.name}】U${position}`, { targetId: device.deviceId, targetName: device.name, beforeState: { ...beforeState, isIdle: true }, - afterState: updatedDevice.toJSON(), + afterState: deviceData, req, - metadata: { rackId, position, type: 'idle_device_shelve' } + metadata: buildDeviceMetadata(deviceData, { rackId, position, type: 'idle_device_shelve' }) }); res.json({ @@ -542,13 +566,13 @@ router.put('/:deviceId', async (req, res) => { await device.save(); - await logDeviceOperation('update', `更新空闲设备【${device.name}】`, { + await logDeviceOperation('update', generateDeviceDescription('更新空闲设备', device.toJSON(), { includeRack: false }), { targetId: device.deviceId, targetName: device.name, beforeState, afterState: device.toJSON(), req, - metadata: { type: 'idle_device_update' } + metadata: buildDeviceMetadata(device.toJSON(), { type: 'idle_device_update' }) }); res.json(device); @@ -613,13 +637,19 @@ router.put('/:deviceId/restore', async (req, res) => { ] }); - await logDeviceOperation('restore', `空闲设备【${device.name}】恢复到机柜【${targetRack.name}】U${targetPosition}`, { + const deviceData = { + ...updatedDevice.toJSON(), + rackName: targetRack.name, + roomName: updatedDevice.Rack?.Room?.name + }; + + await logDeviceOperation('restore', generateDeviceDescription('空闲设备恢复', deviceData, { includePosition: false }) + `到机柜【${targetRack.name}】U${targetPosition}`, { targetId: device.deviceId, targetName: device.name, beforeState: { ...device.toJSON(), isIdle: true }, - afterState: updatedDevice.toJSON(), + afterState: deviceData, req, - metadata: { targetRackId, targetPosition, type: 'idle_device_restore' } + metadata: buildDeviceMetadata(deviceData, { targetRackId, targetPosition, type: 'idle_device_restore' }) }); res.json({ @@ -761,11 +791,16 @@ router.put('/batch-restore', async (req, res) => { const failedCount = results.filter(r => r.status === 'failed').length; const skippedCount = results.filter(r => r.status === 'skipped').length; - await logDeviceOperation('batch_restore', `批量上架 ${successCount} 台空闲设备`, { + const successDevices = idleDevices.filter(d => results.some(r => r.deviceId === d.deviceId && r.status === 'success')); + const deviceSummary = successDevices.map(d => + `${d.name}(编号:${d.deviceId}${d.type ? `,类型:${d.type}` : ''}${d.ipAddress ? `,IP:${d.ipAddress}` : ''})` + ).join('、'); + + await logDeviceOperation('batch_restore', `批量上架 ${successCount} 台空闲设备:${deviceSummary}`, { targetId: deviceIds.join(','), targetName: `${successCount}台设备`, req, - metadata: { results, type: 'batch_idle_device_restore' } + metadata: { results, type: 'batch_idle_device_restore', devices: successDevices.map(d => d.toJSON()) } }); res.json({ @@ -802,12 +837,15 @@ router.delete('/:deviceId', async (req, res) => { await t.commit(); - await logDeviceOperation('delete', `删除空闲设备【${device.name || device.deviceId}】`, { + await logDeviceOperation('delete', generateDeviceDescription('删除空闲设备', { + ...device.toJSON(), + name: device.name || device.deviceId + }, { includeRack: false }), { targetId: device.deviceId, targetName: device.name, beforeState, req, - metadata: { type: 'idle_device_delete' } + metadata: buildDeviceMetadata(device.toJSON(), { type: 'idle_device_delete' }) }); res.json({ message: '空闲设备删除成功' }); diff --git a/backend/routes/networkCards.js b/backend/routes/networkCards.js index c7c36e8..add8c33 100644 --- a/backend/routes/networkCards.js +++ b/backend/routes/networkCards.js @@ -161,6 +161,29 @@ router.get('/:nicId/ports', async (req, res) => { } }); +router.get('/find', async (req, res) => { + try { + const { deviceId, name } = req.query; + + if (!deviceId || !name) { + return res.status(400).json({ error: '缺少设备ID或网卡名称' }); + } + + const networkCard = await NetworkCard.findOne({ + where: { deviceId, name } + }); + + if (!networkCard) { + return res.json({ nicId: null }); + } + + res.json(networkCard); + } catch (error) { + console.error('查找网卡失败:', error); + res.status(500).json({ error: error.message }); + } +}); + router.post('/', async (req, res) => { try { const { nicId, deviceId, name, description, slotNumber, model, manufacturer, status } = req.body; diff --git a/backend/routes/racks.js b/backend/routes/racks.js index 17fd601..1e2a21b 100644 --- a/backend/routes/racks.js +++ b/backend/routes/racks.js @@ -572,9 +572,9 @@ router.post('/import', async (req, res) => { // 验证数据 processedData.forEach((item) => { const errors = []; - - if (!/^RACK\d+$/.test(item.rackId)) { - errors.push('机柜ID格式应为RACK+数字,如RACK001'); + + if (!/^[a-zA-Z0-9_-]+$/.test(item.rackId)) { + errors.push('机柜ID只能包含字母、数字、下划线和横线'); } if (!item.name || String(item.name).trim() === '') { errors.push('机柜名称不能为空'); @@ -582,7 +582,8 @@ router.post('/import', async (req, res) => { if (!item.roomName || String(item.roomName).trim() === '') { errors.push('所属机房名称不能为空'); } else if (!validRoomNames.has(String(item.roomName).trim())) { - errors.push(`所属机房名称不存在: ${item.roomName}`); + const availableRooms = Array.from(validRoomNames).join('、'); + errors.push(`所属机房"${item.roomName}"不存在,可用机房: ${availableRooms}`); } if (typeof item.height !== 'number' || item.height <= 0) { errors.push('高度必须是大于0的数字'); @@ -644,12 +645,17 @@ router.post('/import', async (req, res) => { // 提交事务 await t.commit(); - res.status(200).json({ - success: true, + const createdRacks = newData.map(item => ({ rackId: item.rackId, name: item.name })); + const skippedRacks = existingRacks.map(rack => ({ rackId: rack.rackId, name: rack.name })); + + res.status(200).json({ + success: true, message: '机柜导入完成', imported: createdCount, duplicates: duplicateCount, - total: jsonData.length + total: jsonData.length, + createdRacks, + skippedRacks }); } finally { // 删除临时文件 diff --git a/backend/scripts/generate-rack-import-template.js b/backend/scripts/generate-rack-import-template.js new file mode 100644 index 0000000..bc7c86f --- /dev/null +++ b/backend/scripts/generate-rack-import-template.js @@ -0,0 +1,113 @@ +const XLSX = require('xlsx'); +const path = require('path'); + +const templateData = [ + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC2-SERVER-01', + '所属机房名称': 'IDC2', + '高度(U)': 42, + '最大功率(W)': 5000, + '状态': 'active' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC2-SERVER-02', + '所属机房名称': 'IDC2', + '高度(U)': 42, + '最大功率(W)': 5000, + '状态': 'active' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC2-SERVER-03', + '所属机房名称': 'IDC2', + '高度(U)': 42, + '最大功率(W)': 5000, + '状态': 'active' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC4-NETWORK-01', + '所属机房名称': 'IDC4', + '高度(U)': 48, + '最大功率(W)': 8000, + '状态': 'active' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC4-NETWORK-02', + '所属机房名称': 'IDC4', + '高度(U)': 48, + '最大功率(W)': 8000, + '状态': 'maintenance' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC5-STORAGE-01', + '所属机房名称': 'IDC5', + '高度(U)': 42, + '最大功率(W)': 10000, + '状态': 'active' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC5-STORAGE-02', + '所属机房名称': 'IDC5', + '高度(U)': 42, + '最大功率(W)': 10000, + '状态': 'inactive' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': 'IDC7-SERVER-01', + '所属机房名称': 'IDC7', + '高度(U)': 36, + '最大功率(W)': 6000, + '状态': 'active' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': '古荡-机柜-01', + '所属机房名称': '古荡机房1-1', + '高度(U)': 42, + '最大功率(W)': 5000, + '状态': 'active' + }, + { + '机柜ID(留空自动生成)': '', + '机柜名称': '古荡-机柜-02', + '所属机房名称': '古荡机房1-1', + '高度(U)': 42, + '最大功率(W)': 5000, + '状态': 'active' + } +]; + +const wb = XLSX.utils.book_new(); + +const ws = XLSX.utils.json_to_sheet(templateData); + +ws['!cols'] = [ + { wch: 20 }, + { wch: 20 }, + { wch: 15 }, + { wch: 10 }, + { wch: 15 }, + { wch: 15 } +]; + +XLSX.utils.book_append_sheet(wb, ws, '机柜导入模板'); + +const outputPath = path.join(__dirname, '测试机柜导入.xlsx'); +XLSX.writeFile(wb, outputPath); + +console.log(`Excel文件已生成: ${outputPath}`); +console.log(`共 ${templateData.length} 条测试数据`); +console.log('\n字段说明:'); +console.log('- 机柜ID(留空自动生成): 留空则系统自动生成唯一ID'); +console.log('- 机柜名称: 必填,机柜的唯一标识名称'); +console.log('- 所属机房名称: 必填,系统现有机房: IDC2, IDC4, IDC5, IDC7, 古荡机房1-1, 三墩机房1-1, 地市机房, IDCT'); +console.log('- 高度(U): 必填,机柜的标准高度(1-50U)'); +console.log('- 最大功率(W): 必填,机柜的最大承载功率'); +console.log('- 状态: active(在用)/maintenance(维护中)/inactive(停用)'); \ No newline at end of file diff --git a/backend/utils/operationLogger.js b/backend/utils/operationLogger.js index a7420fe..216fe96 100644 --- a/backend/utils/operationLogger.js +++ b/backend/utils/operationLogger.js @@ -32,6 +32,72 @@ const getClientInfo = (req) => { return { ipAddress, userAgent }; }; +const DEVICE_TYPE_MAP = { + server: '服务器', + switch: '交换机', + router: '路由器', + storage: '存储设备', + firewall: '防火墙', + loadbalancer: '负载均衡器', + other: '其他设备' +}; + +const generateDeviceDescription = (operation, device, options = {}) => { + const { + includeRack = true, + includePosition = true, + includeSerial = true, + includeIp = true, + includeModel = true + } = options; + + const deviceType = DEVICE_TYPE_MAP[device.type] || device.type || '设备'; + const parts = [`${deviceType}【${device.name}】`]; + + if (device.deviceId) { + parts.push(`编号:${device.deviceId}`); + } + + if (includeModel && device.model) { + parts.push(`型号:${device.model}`); + } + + if (includeSerial && device.serialNumber) { + parts.push(`序列号:${device.serialNumber}`); + } + + if (includeIp && device.ipAddress) { + parts.push(`IP:${device.ipAddress}`); + } + + if (includeRack && device.rackName) { + if (includePosition && device.position !== undefined) { + parts.push(`位置:机柜【${device.rackName}】U${device.position}`); + } else { + parts.push(`机柜【${device.rackName}】`); + } + } + + return `${operation}${parts.join(',')}`; +}; + +const buildDeviceMetadata = (device, extra = {}) => { + return { + deviceId: device.deviceId || null, + deviceName: device.name || null, + deviceType: device.type || null, + deviceModel: device.model || null, + serialNumber: device.serialNumber || null, + ipAddress: device.ipAddress || null, + rackId: device.rackId || null, + rackName: device.rackName || null, + position: device.position !== undefined ? device.position : null, + roomId: device.roomId || null, + roomName: device.roomName || null, + ...extra + }; +}; + async function logOperation({ module, operationType, @@ -119,5 +185,7 @@ module.exports = { logOperation, logDeviceOperation, logUserOperation, - logRoleOperation + logRoleOperation, + generateDeviceDescription, + buildDeviceMetadata }; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 80c704a..bb5d5ce 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -126,6 +126,7 @@ export const deviceAPI = { update: (deviceId, data) => api.put(`/devices/${deviceId}`, data), delete: deviceId => api.delete(`/devices/${deviceId}`), getTickets: (deviceId, params) => api.get(`/devices/${deviceId}/tickets`, { params }), + checkPosition: (rackId, params) => api.get(`/devices/check-position/${rackId}`, { params }), }; export const ticketAPI = { diff --git a/frontend/src/components/DeviceDetailDrawer.jsx b/frontend/src/components/DeviceDetailDrawer.jsx index d7ae24f..d3fb03c 100644 --- a/frontend/src/components/DeviceDetailDrawer.jsx +++ b/frontend/src/components/DeviceDetailDrawer.jsx @@ -9,62 +9,89 @@ import { Card, Tooltip, Button, - Popconfirm, Table, Badge, Row, Col, Divider, + Collapse, + Spin, + Pagination, } from 'antd'; import { ApiOutlined, CloudServerOutlined, - EnvironmentOutlined, EditOutlined, - PlusCircleOutlined, - DeleteOutlined, FileTextOutlined, - ToolOutlined, - EyeOutlined, DesktopOutlined, - FieldTimeOutlined, - InfoCircleOutlined, LinkOutlined, + FolderOutlined, + LeftOutlined, + RightOutlined, } from '@ant-design/icons'; -import NetworkCardPanel from './NetworkCardPanel'; import { deviceAPI } from '../api'; import { designTokens } from '../config/theme'; import dayjs from 'dayjs'; +import api from '../api'; const { Text, Title } = Typography; +const { Panel } = Collapse; + +const PAGE_SIZE = 3; function DeviceDetailDrawer({ device, visible, onClose, cables, - onRefreshCables, onEdit, - onAddNic, - onAddPort, - onAddCable, - onDeleteCable, tooltipFields, refreshTrigger, - onViewTicket, - onCreateTicket, }) { const [activeTab, setActiveTab] = useState('ports'); + const [tickets, setTickets] = useState([]); const [ticketsLoading, setTicketsLoading] = useState(false); const [ticketsPagination, setTicketsPagination] = useState({ current: 1, - pageSize: 5, + pageSize: PAGE_SIZE, total: 0, }); - // 获取设备关联的工单列表 - const fetchDeviceTickets = useCallback(async (page = 1, pageSize = 5) => { + const [networkCards, setNetworkCards] = useState([]); + const [networkCardsLoading, setNetworkCardsLoading] = useState(false); + const [expandedCards, setExpandedCards] = useState([]); + const [portsPage, setPortsPage] = useState(1); + + const [cablesPage, setCablesPage] = useState(1); + + const fetchNetworkCards = useCallback(async () => { + if (!device?.deviceId) return; + setNetworkCardsLoading(true); + try { + const response = await api.get(`/network-cards/device/${device.deviceId}/with-ports`); + const cardsData = response.data || response || []; + setNetworkCards(cardsData); + const initialExpanded = cardsData + .filter(card => card.ports && card.ports.length > 0) + .map(card => card.nicId); + setExpandedCards(initialExpanded); + setPortsPage(1); + } catch (error) { + console.error('获取网卡数据失败:', error); + setNetworkCards([]); + } finally { + setNetworkCardsLoading(false); + } + }, [device?.deviceId]); + + useEffect(() => { + if (visible && device?.deviceId) { + fetchNetworkCards(); + } + }, [visible, device?.deviceId, fetchNetworkCards, refreshTrigger]); + + const fetchDeviceTickets = useCallback(async (page = 1, pageSize = PAGE_SIZE) => { if (!device?.deviceId) return; setTicketsLoading(true); try { @@ -75,7 +102,7 @@ function DeviceDetailDrawer({ setTickets(response.data || []); setTicketsPagination({ current: response.page || 1, - pageSize: response.pageSize || 5, + pageSize: response.pageSize || PAGE_SIZE, total: response.total || 0, }); } catch (error) { @@ -85,14 +112,12 @@ function DeviceDetailDrawer({ } }, [device?.deviceId]); - // 当设备变化或标签页切换到工单时,加载工单数据 useEffect(() => { if (visible && device?.deviceId && activeTab === 'tickets') { - fetchDeviceTickets(1, 5); + fetchDeviceTickets(1, PAGE_SIZE); } }, [visible, device?.deviceId, activeTab, fetchDeviceTickets]); - // 工单表格列定义 const ticketColumns = useMemo(() => [ { title: '工单编号', @@ -149,22 +174,7 @@ function DeviceDetailDrawer({ return dayjs(date).format('YYYY-MM-DD HH:mm'); }, }, - { - title: '操作', - key: 'action', - width: 80, - render: (_, record) => ( - - ), - }, - ], [onViewTicket]); + ], []); const deviceCables = useMemo(() => { if (!device || !cables) return []; @@ -173,6 +183,18 @@ function DeviceDetailDrawer({ ); }, [device, cables]); + const paginatedCables = useMemo(() => { + const start = (cablesPage - 1) * PAGE_SIZE; + const end = start + PAGE_SIZE; + return deviceCables.slice(start, end); + }, [deviceCables, cablesPage]); + + const cablesTotalPages = Math.ceil(deviceCables.length / PAGE_SIZE); + + useEffect(() => { + setCablesPage(1); + }, [deviceCables.length]); + const getStatusTag = useCallback(status => { const config = { running: { color: 'success', text: '运行中' }, @@ -182,11 +204,26 @@ function DeviceDetailDrawer({ fault: { color: 'error', text: '故障' }, offline: { color: 'default', text: '离线' }, maintenance: { color: 'processing', text: '维护中' }, + free: { color: 'success', text: '空闲' }, + occupied: { color: 'processing', text: '占用' }, }; const { color, text } = config[status] || { color: 'default', text: status }; return {text}; }, []); + const getPortTypeTag = useCallback(type => { + const config = { + 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 { color, text } = config[type] || { color: 'default', text: type }; + return {text}; + }, []); + const getDeviceTypeName = useCallback(type => { const typeMap = { server: '服务器', @@ -200,9 +237,331 @@ function DeviceDetailDrawer({ return typeMap[type?.toLowerCase()] || type || '未知设备'; }, []); + const renderPortTable = (ports) => { + const columns = [ + { + title: '端口名称', + dataIndex: 'portName', + key: 'portName', + width: 120, + render: text => {text}, + }, + { + title: '类型', + dataIndex: 'portType', + key: 'portType', + width: 80, + render: type => getPortTypeTag(type), + }, + { + title: '速率', + dataIndex: 'portSpeed', + key: 'portSpeed', + width: 70, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 70, + render: status => getStatusTag(status), + }, + { + title: 'VLAN', + dataIndex: 'vlanId', + key: 'vlanId', + width: 60, + render: vlanId => vlanId || '-', + }, + ]; + + return ( + + ); + }; + + const renderCardHeader = card => { + const stats = card.stats || { free: 0, occupied: 0, fault: 0, total: 0 }; + + return ( +
+
+
+ {card.isUngrouped ? : } +
+
+
+ {card.name} + {card.slotNumber && ( + 插槽 {card.slotNumber} + )} +
+
+ {card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')} +
+
+
+ + + 空闲 + + 占用 + + 故障 + +
+ ); + }; + + const paginatedNetworkCards = useMemo(() => { + const start = (portsPage - 1) * PAGE_SIZE; + const end = start + PAGE_SIZE; + return networkCards.slice(start, end); + }, [networkCards, portsPage]); + + const portsTotalPages = Math.ceil(networkCards.length / PAGE_SIZE); + + const renderPortsTab = () => { + if (networkCardsLoading) { + return ( +
+ +
+ ); + } + + const totalStats = networkCards.reduce( + (acc, card) => { + const stats = card.stats || {}; + acc.total += stats.total || 0; + acc.free += stats.free || 0; + acc.occupied += stats.occupied || 0; + acc.fault += stats.fault || 0; + return acc; + }, + { total: 0, free: 0, occupied: 0, fault: 0 } + ); + + if (networkCards.length === 0) { + return ; + } + + return ( +
+
+ + !c.isUngrouped).length} + style={{ backgroundColor: designTokens.colors.primary.main }} + /> + 个网卡 + + 个端口 + +
+ + + paginatedNetworkCards.some(card => card.nicId === id) + )} + onChange={keys => setExpandedCards(keys)} + expandIconPosition="end" + style={{ background: 'transparent' }} + > + {paginatedNetworkCards.map(card => ( + + {card.ports && card.ports.length > 0 ? ( + renderPortTable(card.ports) + ) : ( +
+ 该{card.isUngrouped ? '分组' : '网卡'}暂无端口 +
+ )} +
+ ))} +
+ + {portsTotalPages > 1 && ( +
+
+ )} +
+ ); + }; + + const renderCablesTab = () => { + if (deviceCables.length === 0) { + return ; + } + + return ( +
+ + {paginatedCables.map(cable => ( + + +
+
源设备
+
+ {cable.sourceDevice?.name || '-'} + + {cable.sourcePort} + +
+ + +
目标设备
+
+ {cable.targetDevice?.name || '-'} + + {cable.targetPort} + +
+ + + + + {cable.status === 'normal' + ? '正常' + : cable.status === 'fault' + ? '故障' + : '未连接'} + + + {cable.cableType === 'ethernet' + ? '网线' + : cable.cableType === 'fiber' + ? '光纤' + : '铜缆'} + + {cable.cableLength && {cable.cableLength}m} + + + {cable.description && ( + +
+ {cable.description} +
+ + )} + + + ))} + + + {cablesTotalPages > 1 && ( +
+
+ )} + + ); + }; + if (!device) return null; - // 解析自定义字段 const customFields = device.customFields || {}; const standardFields = ['deviceId', 'name', 'type', 'model', 'serialNumber', 'status', 'ipAddress', 'position', 'height', 'powerConsumption', 'purchaseDate', 'warrantyExpiry', 'description']; const customFieldEntries = Object.entries(customFields).filter(([key]) => !standardFields.includes(key)); @@ -213,17 +572,10 @@ function DeviceDetailDrawer({ label: ( - 端口与网卡 + 端口与网卡 ({networkCards.length}) ), - children: ( - - ), + children: renderPortsTab(), }, { key: 'cables', @@ -233,88 +585,7 @@ function DeviceDetailDrawer({ 接线 ({deviceCables.length}) ), - children: ( -
- {deviceCables.length === 0 ? ( - - ) : ( - - {deviceCables.map(cable => ( - onDeleteCable?.(cable.cableId)} - okText="确定" - cancelText="取消" - > -
-
源设备
-
- {cable.sourceDevice?.name || '-'} - - {cable.sourcePort} - -
- - -
目标设备
-
- {cable.targetDevice?.name || '-'} - - {cable.targetPort} - -
- - - - - {cable.status === 'normal' - ? '正常' - : cable.status === 'fault' - ? '故障' - : '未连接'} - - - {cable.cableType === 'ethernet' - ? '网线' - : cable.cableType === 'fiber' - ? '光纤' - : '铜缆'} - - {cable.cableLength && {cable.cableLength}m} - - - {cable.description && ( - -
- {cable.description} -
- - )} - - - ))} - - )} - - ), + children: renderCablesTab(), }, { key: 'tickets', @@ -326,25 +597,21 @@ function DeviceDetailDrawer({ ), children: (
-
- -
fetchDeviceTickets(page, pageSize), + showSizeChanger: false, + size: 'small', }} size="small" + locale={{ emptyText: }} /> ), @@ -364,32 +631,14 @@ function DeviceDetailDrawer({ open={visible} onClose={onClose} extra={ - - - - - - - - - - - - - - + + + } styles={{ body: { padding: '0', overflow: 'auto' } }} > - {/* 设备头部信息 */}
@@ -408,9 +657,7 @@ function DeviceDetailDrawer({ - {/* 设备信息内容 */}
- {/* 基本信息卡片 */}
@@ -440,7 +687,6 @@ function DeviceDetailDrawer({ - {/* 维保信息卡片 */} @@ -458,14 +704,12 @@ function DeviceDetailDrawer({ - {/* 描述信息 */} {device.description && (
{device.description}
)} - {/* 自定义字段卡片 */} {customFieldEntries.length > 0 && ( @@ -484,7 +728,6 @@ function DeviceDetailDrawer({ - {/* 标签页 */} diff --git a/frontend/src/components/device/DeviceFormModal.jsx b/frontend/src/components/device/DeviceFormModal.jsx index f420346..aff8273 100644 --- a/frontend/src/components/device/DeviceFormModal.jsx +++ b/frontend/src/components/device/DeviceFormModal.jsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; -import { Modal, Form, Input, Select, InputNumber, DatePicker, Switch, Row, Col, Button, Space } from 'antd'; -import { PlusOutlined, EditOutlined, DatabaseOutlined } from '@ant-design/icons'; +import { Modal, Form, Input, Select, InputNumber, DatePicker, Switch, Row, Col, Button, Space, Alert } from 'antd'; +import { PlusOutlined, EditOutlined, DatabaseOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { designTokens } from '../../config/theme'; import { getFormInitialValues, prepareDeviceFormData } from '../../utils/deviceUtils.jsx'; +import { deviceAPI } from '../../api'; const { Option } = Select; @@ -31,6 +32,9 @@ const DeviceFormModal = ({ }) => { const [form] = Form.useForm(); const [selectedRoomId, setSelectedRoomId] = useState(null); + const [selectedRackId, setSelectedRackId] = useState(null); + const [positionConflict, setPositionConflict] = useState(null); + const [checkingPosition, setCheckingPosition] = useState(false); useEffect(() => { if (visible) { @@ -47,22 +51,91 @@ const DeviceFormModal = ({ const rack = racks.find((r) => r.rackId === editingDevice.rackId); if (rack) { setSelectedRoomId(rack.roomId); + setSelectedRackId(editingDevice.rackId); } } + if (editingDevice.position) { + checkPositionConflict(editingDevice.rackId, editingDevice.position, editingDevice.height, editingDevice.deviceId); + } } else { form.resetFields(); setSelectedRoomId(null); + setSelectedRackId(null); + setPositionConflict(null); } } }, [visible, editingDevice, racks, form]); + const checkPositionConflict = async (rackId, position, height, deviceId = null) => { + if (!rackId || !position) { + setPositionConflict(null); + return; + } + + setCheckingPosition(true); + try { + const params = { + position, + height: height || 1, + }; + if (deviceId) { + params.excludeDeviceId = deviceId; + } + const result = await deviceAPI.checkPosition(rackId, params); + if (!result.available) { + setPositionConflict(result.reason); + } else { + setPositionConflict(null); + } + } catch (error) { + console.error('检查U位冲突失败:', error); + setPositionConflict(null); + } finally { + setCheckingPosition(false); + } + }; + + const handleRackChange = (value) => { + setSelectedRackId(value); + const position = form.getFieldValue('position'); + const height = form.getFieldValue('height'); + if (position) { + checkPositionConflict(value, position, height, editingDevice?.deviceId); + } else { + setPositionConflict(null); + } + }; + + const handlePositionChange = (value) => { + const height = form.getFieldValue('height'); + if (selectedRackId && value) { + checkPositionConflict(selectedRackId, value, height, editingDevice?.deviceId); + } else { + setPositionConflict(null); + } + }; + + const handleHeightChange = (value) => { + const position = form.getFieldValue('position'); + if (selectedRackId && position) { + checkPositionConflict(selectedRackId, position, value, editingDevice?.deviceId); + } else { + setPositionConflict(null); + } + }; + const handleSubmit = (values) => { + if (positionConflict) { + return; + } const deviceData = prepareDeviceFormData(values, !!editingDevice); onSubmit(deviceData); }; const handleRoomChange = (value) => { setSelectedRoomId(value); + setSelectedRackId(null); + setPositionConflict(null); form.setFieldValue('rackId', undefined); }; @@ -123,7 +196,7 @@ const DeviceFormModal = ({ }; const filteredFields = deviceFields.filter( - (field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId' + (field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId' && field.fieldName !== 'position' && field.fieldName !== 'height' ); const formItems = []; @@ -219,6 +292,7 @@ const DeviceFormModal = ({ disabled={!selectedRoomId} showSearch optionFilterProp="children" + onChange={handleRackChange} > {(selectedRoomId ? racks.filter((rack) => rack.roomId === selectedRoomId) : []).map( (rack) => ( @@ -231,6 +305,61 @@ const DeviceFormModal = ({ + + + + 安装位置 (U位) + * + + } + rules={[{ required: true, message: '请输入U位' }]} + style={{ marginBottom: '0' }} + > + + + + + + 设备高度 (U) + * + + } + rules={[{ required: true, message: '请输入设备高度' }]} + initialValue={1} + style={{ marginBottom: '0' }} + > + + + + + {positionConflict && ( +
+ } + /> +
+ )} diff --git a/frontend/src/pages/IdleDeviceManagement.jsx b/frontend/src/pages/IdleDeviceManagement.jsx index d511fdb..977d600 100644 --- a/frontend/src/pages/IdleDeviceManagement.jsx +++ b/frontend/src/pages/IdleDeviceManagement.jsx @@ -29,8 +29,10 @@ import { InboxOutlined, ClockCircleOutlined, UploadOutlined, + ExclamationCircleOutlined, } from '@ant-design/icons'; import axios from 'axios'; +import { deviceAPI } from '../api'; const { Title, Text, Paragraph } = Typography; const { Option } = Select; @@ -52,6 +54,8 @@ const IdleDeviceManagement = () => { const [rooms, setRooms] = useState([]); const [selectedRoomId, setSelectedRoomId] = useState(null); const [selectedShelveRoomId, setSelectedShelveRoomId] = useState(null); + const [shelvePositionConflict, setShelvePositionConflict] = useState(null); + const [shelveSelectedRackId, setShelveSelectedRackId] = useState(null); const fetchIdleDevices = useCallback(async () => { setLoading(true); @@ -144,6 +148,8 @@ const IdleDeviceManagement = () => { const handleShelve = (record) => { setShelvingDevice(record); + setShelvePositionConflict(null); + setShelveSelectedRackId(null); let roomId = null; if (record.rackId) { const rack = racks.find(r => r.rackId === record.rackId); @@ -164,12 +170,69 @@ const IdleDeviceManagement = () => { position: record.position, description: record.description, }); + if (record.rackId) { + setShelveSelectedRackId(record.rackId); + } setIsShelveModalVisible(true); }; + const checkShelvePositionConflict = async (rackId, position, height) => { + if (!rackId || !position) { + setShelvePositionConflict(null); + return; + } + + try { + const result = await deviceAPI.checkPosition(rackId, { position, height: height || 1 }); + if (!result.available) { + setShelvePositionConflict(result.reason); + } else { + setShelvePositionConflict(null); + } + } catch (error) { + console.error('检查U位冲突失败:', error); + setShelvePositionConflict(null); + } + }; + + const handleShelveRackChange = (value) => { + setShelveSelectedRackId(value); + const position = shelveForm.getFieldValue('position'); + const height = shelveForm.getFieldValue('height'); + if (position) { + checkShelvePositionConflict(value, position, height); + } else { + setShelvePositionConflict(null); + } + }; + + const handleShelvePositionChange = (e) => { + const value = e.target.value ? parseInt(e.target.value) : null; + const height = shelveForm.getFieldValue('height'); + if (shelveSelectedRackId && value) { + checkShelvePositionConflict(shelveSelectedRackId, value, height); + } else { + setShelvePositionConflict(null); + } + }; + + const handleShelveHeightChange = (e) => { + const value = e.target.value ? parseInt(e.target.value) : null; + const position = shelveForm.getFieldValue('position'); + if (shelveSelectedRackId && position) { + checkShelvePositionConflict(shelveSelectedRackId, position, value); + } else { + setShelvePositionConflict(null); + } + }; + const handleShelveSubmit = async () => { try { const values = await shelveForm.validateFields(); + if (shelvePositionConflict) { + message.error('存在U位冲突,请重新选择上架位置'); + return; + } const submitData = { name: values.name, type: values.type, @@ -866,6 +929,7 @@ const IdleDeviceManagement = () => { placeholder={selectedShelveRoomId ? "请选择机柜" : "请先选择机房"} disabled={!selectedShelveRoomId} style={{ borderRadius: '8px' }} + onChange={handleShelveRackChange} > {racks .filter((rack) => rack.roomId === selectedShelveRoomId) @@ -879,10 +943,29 @@ const IdleDeviceManagement = () => { - + + {shelvePositionConflict && ( +
+
+ +
+
U位冲突
+
{shelvePositionConflict}
+
+
+
+ )}
{ try { @@ -165,12 +178,14 @@ function PortManagement() { const fetchDevices = useCallback(async (keyword = '') => { try { setDeviceSearching(true); - const params = { pageSize: 50 }; if (keyword && keyword.trim()) { - params.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 || []); } - const response = await api.get('/devices', { params }); - setDevices(response.devices || response || []); } catch (error) { message.error('获取设备列表失败'); console.error('获取设备列表失败:', error); @@ -256,16 +271,87 @@ function PortManagement() { const handleAdd = () => { setEditingPort(null); form.resetFields(); - setModalVisible(true); + fetchDevices(); + setSelectDeviceModalVisible(true); + }; + + 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 : (nicList.data || []); + if (validNicList.length === 0) { + message.warning({ + content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡', + icon: , + duration: 3, + }); + handleManageNetworkCards(device); + } else { + setNicList(validNicList); + setSelectedDeviceForPort(device); + form.resetFields(); + form.setFieldsValue({ deviceId: device.deviceId }); + setModalVisible(true); + } + }) + .catch(() => { + message.warning({ + content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡', + icon: , + duration: 3, + }); + handleManageNetworkCards(device); + }); + } else { + setSelectedDeviceForPort(device); + form.resetFields(); + form.setFieldsValue({ deviceId: device.deviceId }); + setModalVisible(true); + } }; const handleAddPortForDevice = device => { - setEditingPort(null); - form.resetFields(); - form.setFieldsValue({ - deviceId: device.deviceId, - }); - setModalVisible(true); + const deviceType = getDeviceType(device); + + if (deviceType === 'server') { + api.get(`/network-cards/device/${device.deviceId}`) + .then(response => { + const nicList = response.data || []; + if (nicList.length === 0) { + message.warning({ + content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡', + icon: , + duration: 3, + }); + handleManageNetworkCards(device); + } else { + setNicList(nicList); + setSelectedDeviceForPort(device); + form.resetFields(); + form.setFieldsValue({ deviceId: device.deviceId }); + setModalVisible(true); + } + }) + .catch(() => { + message.warning({ + content: '该服务器尚未添加网卡,请先在网卡管理中添加网卡', + icon: , + duration: 3, + }); + handleManageNetworkCards(device); + }); + } else { + setSelectedDeviceForPort(device); + form.resetFields(); + form.setFieldsValue({ deviceId: device.deviceId }); + setModalVisible(true); + } }; const handleManageNetworkCards = device => { @@ -313,13 +399,54 @@ function PortManagement() { }); fetchPorts(); } catch (error) { - message.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: , + }); + setSelectedRowKeys([]); + fetchPorts(); + } catch (error) { + const errorMsg = error.response?.data?.error || ''; + if (errorMsg.includes('关联的接线记录')) { + message.error('部分端口存在关联的接线记录,请先删除关联的接线'); + } else { + message.error('批量删除失败'); + } + console.error('批量删除失败:', error); + } + }, + }); + }; + const parsePortRange = portName => { const rangeMatch = portName.match(/^(.*?)\/(\d+)-\1\/(\d+)$/); if (rangeMatch) { @@ -334,6 +461,10 @@ function PortManagement() { return [portName]; }; + const generateUniquePortId = () => { + return `PORT-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + }; + const handleSubmit = async () => { try { const values = await form.validateFields(); @@ -346,21 +477,24 @@ function PortManagement() { }); } else { const portNames = parsePortRange(values.portName); + const targetDeviceId = selectedDeviceForPort ? selectedDeviceForPort.deviceId : values.deviceId; if (portNames.length > 1) { const portsData = portNames.map((name, index) => ({ - portId: `PORT-${Date.now()}-${index}`, - deviceId: values.deviceId, + portId: generateUniquePortId(), + deviceId: targetDeviceId, portName: name, portType: values.portType, portSpeed: values.portSpeed, status: values.status, vlanId: values.vlanId, description: values.description, + nicId: values.nicId || null, })); - const response = await api.post('/device-ports/batch', { ports: portsData }); - const { success, failed } = response.data; + 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} 个`); @@ -371,7 +505,16 @@ function PortManagement() { }); } } else { - await api.post('/device-ports', values); + const result = await api.post('/device-ports', { + deviceId: targetDeviceId, + portName: portNames[0], + portType: values.portType, + portSpeed: values.portSpeed, + status: values.status, + vlanId: values.vlanId, + description: values.description, + nicId: values.nicId || null, + }); message.success({ content: '创建成功', icon: , @@ -381,9 +524,10 @@ function PortManagement() { setModalVisible(false); form.resetFields(); + setSelectedDeviceForPort(null); fetchPorts(); } catch (error) { - message.error(editingPort ? '更新失败' : '创建失败'); + message.error(error.response?.data?.error || editingPort ? '更新失败' : '创建失败'); console.error('提交失败:', error); } }; @@ -480,6 +624,19 @@ function PortManagement() { return { valid: false, error: `第 ${index + 1} 行:无效的状态` }; } + let nicId = null; + if (row['网卡名称']) { + try { + const nicResponse = await api.get('/network-cards/find', { + params: { deviceId: row['设备ID'], name: row['网卡名称'] } + }); + nicId = nicResponse.nicId || null; + } catch (error) { + nicId = null; + } + } + + row._nicId = nicId; return { valid: true }; }; @@ -492,6 +649,9 @@ function PortManagement() { setImporting(true); setImportProgress({ current: 0, total: importPreview.length }); + let progressInterval; + let currentProgress = 0; + try { const statusMap = { 空闲: 'free', @@ -500,8 +660,9 @@ function PortManagement() { }; const portsData = importPreview.map((row, index) => ({ - portId: `PORT-${Date.now()}-${index}`, + portId: generateUniquePortId(), deviceId: row['设备ID'], + nicId: row._nicId || null, portName: row['端口名称'], portType: row['端口类型'], portSpeed: row['端口速率'], @@ -510,17 +671,44 @@ function PortManagement() { description: row['描述'], })); - const response = await api.post('/device-ports/batch', { ports: portsData }); - const { total, success, failed, errors } = response; + progressInterval = setInterval(() => { + currentProgress = Math.min(currentProgress + Math.random() * 15, 85); + setImportProgress(prev => ({ + ...prev, + current: Math.floor((currentProgress / 100) * importPreview.length) + })); + }, 200); - setImportProgress({ current: total, total: total }); + 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 (updated > 0) { + msgContent += `更新 ${updated} 个,`; + } + if (skipped > 0) { + msgContent += `跳过 ${skipped} 个,`; + } + if (success > 0) { + msgContent += `新增 ${success - updated} 个,`; + } if (failed > 0) { + msgContent += `失败 ${failed} 个`; console.error('导入错误:', errors); - message.warning(`导入完成!成功 ${success} 条,失败 ${failed} 条`); + } + + if (failed > 0 && success === 0 && skipped === 0 && updated === 0) { + message.error(`导入失败!${msgContent}`); } else { message.success({ - content: `导入完成!成功 ${success} 条`, + content: `导入完成!${msgContent}`, icon: , }); } @@ -529,9 +717,11 @@ function PortManagement() { setImportModalVisible(false); setImportPreview([]); } catch (error) { + clearInterval(progressInterval); console.error('批量导入失败:', error); message.error('批量导入失败,请检查数据格式'); } finally { + clearInterval(progressInterval); setImporting(false); } }; @@ -541,6 +731,7 @@ function PortManagement() { { 设备ID: 'DEV001', 端口名称: 'eth0/1', + 网卡名称: '', 端口类型: 'RJ45', 端口速率: '1G', 状态: '空闲', @@ -555,6 +746,57 @@ function PortManagement() { XLSX.writeFile(workbook, '端口导入模板.xlsx'); }; + const handleExport = () => { + if (ports.length === 0) { + message.warning('没有可导出的端口数据'); + return; + } + + const statusMap = { + free: '空闲', + occupied: '占用', + fault: '故障', + }; + + const exportData = ports.map(port => { + const device = devices.find(d => d.deviceId === port.deviceId); + return { + '设备ID': port.deviceId, + '设备名称': device?.name || '-', + '端口名称': port.portName, + '端口类型': port.portType, + '端口速率': port.portSpeed, + '状态': statusMap[port.status] || port.status, + 'VLAN ID': port.vlanId || '-', + '描述': port.description || '-', + }; + }); + + const worksheet = XLSX.utils.json_to_sheet(exportData); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据'); + + const colWidths = [ + { wch: 15 }, + { wch: 20 }, + { wch: 15 }, + { wch: 10 }, + { wch: 10 }, + { wch: 8 }, + { wch: 10 }, + { wch: 25 }, + ]; + worksheet['!cols'] = colWidths; + + const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + XLSX.writeFile(workbook, `端口导出_${timestamp}.xlsx`); + + message.success({ + content: `成功导出 ${ports.length} 个端口`, + icon: , + }); + }; + const getStatusTag = status => { const statusMap = { free: { color: 'success', text: '空闲', icon: }, @@ -601,6 +843,18 @@ function PortManagement() { width: 120, render: text => {text}, }, + { + title: '所属网卡', + dataIndex: 'networkCard', + key: 'networkCard', + width: 120, + render: (nic, record) => { + const isServer = record.device?.type?.toLowerCase()?.includes('server'); + if (!isServer) return -; + if (!nic) return 未关联; + return {nic.name}; + }, + }, { title: '端口类型', dataIndex: 'portType', @@ -677,6 +931,55 @@ function PortManagement() { return ; }; + 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 filtered = devices.filter(d => { + const type = getDeviceType(d); + if (type !== 'switch' && type !== 'server') return false; + if (deviceFilterType !== 'all' && type !== deviceFilterType) 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]); + + 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 ( } onClick={handleAdd} size="large" - style={{ - background: designTokens.colors.primary.gradient, + style={{ + background: designTokens.colors.primary.gradient, border: 'none', borderRadius: designTokens.borderRadius.sm, boxShadow: designTokens.shadows.md, @@ -886,9 +1189,10 @@ function PortManagement() { > 批量导入 -
-
- {device?.name || '未知设备'} +
+ + {device?.name || '未知设备'} + + + {isServerDevice(device) ? '服务器' : isSwitchDevice(device) ? '交换机' : '设备'} +
{device?.deviceId || '-'} · {device?.model || device?.type || '设备'} @@ -1073,7 +1386,7 @@ function PortManagement() { - +
`共 ${total} 个端口`, - pageSizeOptions: ['10', '20', '50', '100'], - }} - size="middle" - scroll={{ x: 1000 }} - /> +
+ {selectedRowKeys.length > 0 && ( +
+ 已选择 {selectedRowKeys.length} 个端口 + +
+ )} +
`共 ${total} 个端口`, + pageSizeOptions: ['10', '20', '50', '100'], + }} + size="middle" + scroll={{ x: 1000 }} + /> + ) : ( )} @@ -1168,6 +1495,7 @@ function PortManagement() { onCancel={() => { setModalVisible(false); form.resetFields(); + setSelectedDeviceForPort(null); }} width={600} okText="确定" @@ -1181,30 +1509,62 @@ function PortManagement() { }} >
- - + {isServerDevice(selectedDeviceForPort) ? '服务器' : isSwitchDevice(selectedDeviceForPort) ? '交换机' : '设备'} + + } + /> + + {isServerDevice(selectedDeviceForPort) && ( + + + + )} + + ) : ( + - {devices.map(device => ( - - ))} - - + + + )} index} + rowKey={(record, index) => `import-row-${index}`} pagination={false} size="small" scroll={{ x: 800 }} @@ -1443,6 +1803,401 @@ function PortManagement() { + {/* 设备选择弹窗 */} + +
+ +
+
+
+ 选择设备 +
+
+ 为端口选择所属设备 +
+
+ + } + open={selectDeviceModalVisible} + closeIcon={} + onCancel={() => { + setSelectDeviceModalVisible(false); + setDeviceFilterType('all'); + setDevicePage(1); + setSelectedRowKeys([]); + }} + footer={null} + width={620} + style={{ top: 100 }} + bodyStyle={{ padding: '0 24px 24px' }} + > +
+
+
+
+ +
+
+
交换机
+
可直接创建端口
+
+
+
+
+ +
+
+
服务器
+
需先添加网卡
+
+
+
+
+ +
+ } + allowClear + onChange={e => handleDeviceSearch(e.target.value)} + style={{ + borderRadius: '10px', + border: '1px solid #e8ecf4', + height: '44px', + fontSize: '14px', + }} + /> +
+ + {(() => { + const allDevices = devices.filter(d => { + const type = getDeviceType(d); + return type === 'switch' || type === 'server'; + }); + const switchCount = allDevices.filter(d => getDeviceType(d) === 'switch').length; + const serverCount = allDevices.filter(d => getDeviceType(d) === 'server').length; + + return ( +
+ + + +
+ ); + })()} + +
+ {deviceSearching ? ( +
+ +
加载设备中...
+
+ ) : paginatedDevices.list.length === 0 ? ( +
+
+ +
+
未找到设备
+
+ {deviceFilterType === 'all' + ? '暂无可添加端口的设备' + : deviceFilterType === 'switch' + ? '暂无可添加端口的交换机' + : '暂无可添加端口的服务器'} +
+
+ ) : ( + <> +
+ 共 {paginatedDevices.total} 个设备 +
+ {paginatedDevices.list.map(device => ( +
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'; + }} + > +
+
+ {getDeviceIcon(device)} +
+
+
+ {device.name || '未命名设备'} +
+
+ {device.Rack?.Room?.name && ( + + 📍 + {device.Rack.Room.name} + + )} + {device.Rack?.name && ( + + 🗄️ + {device.Rack.name} + + )} + {device.ipAddress && ( + + 🌐 + {device.ipAddress} + + )} +
+
+
+ {isServerDevice(device) ? '服务器' : '交换机'} +
+
+
+ ))} + + {paginatedDevices.hasMore && ( +
setDevicePage(p => p + 1)} + > +
{ + e.currentTarget.style.background = '#667eea'; + e.currentTarget.style.color = '#fff'; + }} + onMouseLeave={e => { + e.currentTarget.style.background = '#f5f7fa'; + e.currentTarget.style.color = '#666'; + }} + > + 点击加载更多 + ({Math.min(devicePage * 100, paginatedDevices.total)} / {paginatedDevices.total}) +
+
+ )} + + {!paginatedDevices.hasMore && paginatedDevices.total > 0 && ( +
+
+ 已加载全部 +
+
共 {paginatedDevices.total} 个设备
+
+ )} + + )} +
+
+ {/* 网卡管理模态框 */} { device={selectedDevice} onClose={() => setSelectedDevice(null)} onEdit={handleEditDevice} - onAddNic={handleAddNic} - onAddPort={handleAddPort} - onAddCable={handleAddCable} tooltipFields={tooltipFields} cables={deviceCables} - onRefreshCables={() => - selectedDevice && fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id) - } - onDeleteCable={handleDeleteCable} refreshTrigger={refreshTrigger} /> diff --git a/frontend/src/pages/RackManagement.jsx b/frontend/src/pages/RackManagement.jsx index 1f39068..b61e893 100644 --- a/frontend/src/pages/RackManagement.jsx +++ b/frontend/src/pages/RackManagement.jsx @@ -417,7 +417,8 @@ function RackManagement() { message.success('机柜删除成功'); fetchRacks(); } catch (error) { - message.error('机柜删除失败'); + const errorMsg = error.response?.data?.error || '机柜删除失败'; + message.error(errorMsg); console.error('机柜删除失败:', error); } }, @@ -438,8 +439,16 @@ function RackManagement() { cancelText: '取消', onOk: async () => { try { - await Promise.all(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`))); - message.success(`成功删除 ${selectedRackIds.length} 个机柜`); + const results = await Promise.allSettled(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`))); + const succeeded = results.filter(r => r.status === 'fulfilled').length; + const failed = results.filter(r => r.status === 'rejected'); + if (succeeded > 0) { + message.success(`成功删除 ${succeeded} 个机柜`); + } + if (failed.length > 0) { + const firstError = failed[0].reason.response?.data?.error || '部分机柜删除失败'; + message.error(`${firstError}(${failed.length} 个失败)`); + } setSelectedRackIds([]); fetchRacks(); } catch (error) { @@ -519,13 +528,33 @@ function RackManagement() { setImportProgress(100); setImportPhase('导入完成'); - setImportResult(response.data); + + const resData = response.data; + const importResult = { + total: resData.total || 0, + successCount: resData.imported || 0, + duplicates: resData.duplicates || 0, + failedCount: 0, + errors: [], + createdRacks: resData.createdRacks || [], + skippedRacks: resData.skippedRacks || [] + }; + + if (resData.details && Array.isArray(resData.details)) { + importResult.failedCount = resData.details.length; + importResult.errors = resData.details.map(item => ({ + row: item.row, + error: item.errors.join(';') + })); + } + + setImportResult(importResult); setIsImporting(false); - if (response.data.success) { + if (resData.success) { message.success('机柜导入成功'); } else { - message.warning(response.data.message || '部分记录导入失败'); + message.warning(resData.message || '部分记录导入失败'); } fetchRacks(); @@ -533,8 +562,24 @@ function RackManagement() { } catch (error) { setIsImporting(false); setImportProgress(0); - message.error('机柜导入失败'); - console.error('机柜导入失败:', error); + + const errorData = error.response?.data; + if (errorData?.details && Array.isArray(errorData.details)) { + const importResult = { + total: errorData.total || 0, + successCount: 0, + failedCount: errorData.details.length, + errors: errorData.details.map(item => ({ + row: item.row, + error: item.errors.join(';') + })) + }; + setImportResult(importResult); + setImportPhase('导入失败'); + } else { + message.error(errorData?.error || errorData?.message || '机柜导入失败'); + console.error('机柜导入失败:', error); + } return false; } }, @@ -1380,10 +1425,60 @@ function RackManagement() { ✗ 导入失败:{importResult.failedCount}

)} + {importResult.duplicates > 0 && ( +

+ ⚠ 跳过(已存在):{importResult.duplicates} +

+ )} + + {importResult.createdRacks && importResult.createdRacks.length > 0 && ( +
+

+ ✓ 本次新增机柜({importResult.createdRacks.length}): +

+
+ {importResult.createdRacks.map((rack, idx) => ( +
+ • {rack.rackId} - {rack.name} +
+ ))} +
+
+ )} + + {importResult.skippedRacks && importResult.skippedRacks.length > 0 && ( +
+

+ ⚠ 已跳过机柜({importResult.skippedRacks.length}): +

+
+ {importResult.skippedRacks.map((rack, idx) => ( +
+ • {rack.rackId} - {rack.name} +
+ ))} +
+
+ )} + {importResult.errors && importResult.errors.length > 0 && (
-

错误详情:

+

错误详情:

{importResult.errors.slice(0, 5).map((err, idx) => (