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) => (
- }
- onClick={() => onViewTicket?.(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