feat: 添加设备位置冲突检查功能并优化操作日志

feat(api): 在设备API中添加checkPosition接口用于检查U位冲突
feat(frontend): 在设备表单和空闲设备管理中实现U位冲突检查
refactor(backend): 重构操作日志功能,添加设备描述生成和元数据构建工具
fix(backend): 修复批量导入机柜时的ID验证规则
feat(backend): 为机柜导入添加创建和跳过机柜的详细返回信息
fix(backend): 修复设备删除时未检查关联接线的问题
feat(backend): 添加机柜导入模板生成脚本
perf(frontend): 优化机柜管理页面的导入结果展示
fix(frontend): 修复设备端口删除时的关联接线检查
This commit is contained in:
zhang1106
2026-03-26 14:35:08 +08:00
parent 0b7732e427
commit d9c46245df
14 changed files with 2152 additions and 384 deletions
+137 -51
View File
@@ -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
+176 -41
View File
@@ -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({
+62 -24
View File
@@ -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: '空闲设备删除成功' });
+23
View File
@@ -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;
+13 -7
View File
@@ -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 {
// 删除临时文件
@@ -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(停用)');
+69 -1
View File
@@ -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
};
+1
View File
@@ -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 = {
+413 -170
View File
@@ -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) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => onViewTicket?.(record)}
>
查看
</Button>
),
},
], [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 <Tag color={color}>{text}</Tag>;
}, []);
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 <Tag color={color}>{text}</Tag>;
}, []);
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 => <span style={{ fontWeight: 500 }}>{text}</span>,
},
{
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 (
<Table
columns={columns}
dataSource={ports}
rowKey="portId"
pagination={false}
size="small"
scroll={{ x: 400 }}
/>
);
};
const renderCardHeader = card => {
const stats = card.stats || { free: 0, occupied: 0, fault: 0, total: 0 };
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
background: card.isUngrouped
? 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
{card.isUngrouped ? <FolderOutlined /> : <CloudServerOutlined />}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>
{card.name}
{card.slotNumber && (
<span style={{ color: '#94a3b8', marginLeft: 8 }}>插槽 {card.slotNumber}</span>
)}
</div>
<div style={{ fontSize: '12px', color: '#64748b' }}>
{card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')}
</div>
</div>
</div>
<Space size={12}>
<Badge count={stats.free} style={{ backgroundColor: designTokens.colors.success }} />
<span style={{ fontSize: '12px', color: '#64748b' }}>空闲</span>
<Badge count={stats.occupied} style={{ backgroundColor: '#1677ff' }} />
<span style={{ fontSize: '12px', color: '#64748b' }}>占用</span>
<Badge count={stats.fault} style={{ backgroundColor: designTokens.colors.error }} />
<span style={{ fontSize: '12px', color: '#64748b' }}>故障</span>
</Space>
</div>
);
};
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 (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" tip="加载网卡数据中..." />
</div>
);
}
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 <Empty description="该设备暂无网卡和端口" />;
}
return (
<div className="network-card-panel">
<div
style={{
display: 'flex',
gap: '24px',
marginBottom: '16px',
padding: '12px 16px',
background: '#f8fafc',
borderRadius: '8px',
}}
>
<Space size={16}>
<Badge
count={networkCards.filter(c => !c.isUngrouped).length}
style={{ backgroundColor: designTokens.colors.primary.main }}
/>
<span style={{ color: '#64748b', fontSize: '13px' }}>个网卡</span>
<Badge count={totalStats.total} style={{ backgroundColor: '#667eea' }} />
<span style={{ color: '#64748b', fontSize: '13px' }}>个端口</span>
</Space>
</div>
<Collapse
activeKey={expandedCards.filter(id =>
paginatedNetworkCards.some(card => card.nicId === id)
)}
onChange={keys => setExpandedCards(keys)}
expandIconPosition="end"
style={{ background: 'transparent' }}
>
{paginatedNetworkCards.map(card => (
<Panel
key={card.nicId}
header={renderCardHeader(card)}
style={{
background: '#fff',
borderRadius: '8px',
marginBottom: '8px',
border: '1px solid #e2e8f0',
}}
>
{card.ports && card.ports.length > 0 ? (
renderPortTable(card.ports)
) : (
<div style={{ padding: '24px', textAlign: 'center', color: '#94a3b8' }}>
{card.isUngrouped ? '分组' : '网卡'}暂无端口
</div>
)}
</Panel>
))}
</Collapse>
{portsTotalPages > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 16,
gap: 12,
}}
>
<Button
icon={<LeftOutlined />}
disabled={portsPage === 1}
onClick={() => setPortsPage(prev => prev - 1)}
size="small"
/>
<span style={{ color: '#64748b', fontSize: '13px' }}>
{portsPage} / {portsTotalPages}
</span>
<Button
icon={<RightOutlined />}
disabled={portsPage === portsTotalPages}
onClick={() => setPortsPage(prev => prev + 1)}
size="small"
/>
</div>
)}
</div>
);
};
const renderCablesTab = () => {
if (deviceCables.length === 0) {
return <Empty description="该设备暂无接线" />;
}
return (
<div className="cable-panel">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{paginatedCables.map(cable => (
<Card
key={cable.cableId}
size="small"
style={{ borderRadius: '8px' }}
>
<Row gutter={[16, 8]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>源设备</div>
<div style={{ fontWeight: 500 }}>
{cable.sourceDevice?.name || '-'}
<Tag color="blue" style={{ marginLeft: '8px' }}>
{cable.sourcePort}
</Tag>
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>目标设备</div>
<div style={{ fontWeight: 500 }}>
{cable.targetDevice?.name || '-'}
<Tag color="green" style={{ marginLeft: '8px' }}>
{cable.targetPort}
</Tag>
</div>
</Col>
<Col span={24}>
<Space wrap style={{ marginTop: 8 }}>
<Tag
color={
cable.status === 'normal'
? 'success'
: cable.status === 'fault'
? 'error'
: 'default'
}
>
{cable.status === 'normal'
? '正常'
: cable.status === 'fault'
? '故障'
: '未连接'}
</Tag>
<Tag color="purple">
{cable.cableType === 'ethernet'
? '网线'
: cable.cableType === 'fiber'
? '光纤'
: '铜缆'}
</Tag>
{cable.cableLength && <Tag color="orange">{cable.cableLength}m</Tag>}
</Space>
</Col>
{cable.description && (
<Col span={24}>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
{cable.description}
</div>
</Col>
)}
</Row>
</Card>
))}
</Space>
{cablesTotalPages > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 16,
gap: 12,
}}
>
<Button
icon={<LeftOutlined />}
disabled={cablesPage === 1}
onClick={() => setCablesPage(prev => prev - 1)}
size="small"
/>
<span style={{ color: '#64748b', fontSize: '13px' }}>
{cablesPage} / {cablesTotalPages}
</span>
<Button
icon={<RightOutlined />}
disabled={cablesPage === cablesTotalPages}
onClick={() => setCablesPage(prev => prev + 1)}
size="small"
/>
</div>
)}
</div>
);
};
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: (
<span>
<ApiOutlined />
端口与网卡
端口与网卡 ({networkCards.length})
</span>
),
children: (
<NetworkCardPanel
deviceId={device?.deviceId}
deviceName={device?.name}
onRefresh={onRefreshCables}
refreshTrigger={refreshTrigger}
/>
),
children: renderPortsTab(),
},
{
key: 'cables',
@@ -233,88 +585,7 @@ function DeviceDetailDrawer({
接线 ({deviceCables.length})
</span>
),
children: (
<div className="cable-panel">
{deviceCables.length === 0 ? (
<Empty description="该设备暂无接线" />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{deviceCables.map(cable => (
<Card
key={cable.cableId}
size="small"
style={{ borderRadius: '8px' }}
extra={
<Popconfirm
title="确定要删除这条接线吗?"
onConfirm={() => onDeleteCable?.(cable.cableId)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
}
>
<Row gutter={[16, 8]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>源设备</div>
<div style={{ fontWeight: 500 }}>
{cable.sourceDevice?.name || '-'}
<Tag color="blue" style={{ marginLeft: '8px' }}>
{cable.sourcePort}
</Tag>
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>目标设备</div>
<div style={{ fontWeight: 500 }}>
{cable.targetDevice?.name || '-'}
<Tag color="green" style={{ marginLeft: '8px' }}>
{cable.targetPort}
</Tag>
</div>
</Col>
<Col span={24}>
<Space wrap style={{ marginTop: 8 }}>
<Tag
color={
cable.status === 'normal'
? 'success'
: cable.status === 'fault'
? 'error'
: 'default'
}
>
{cable.status === 'normal'
? '正常'
: cable.status === 'fault'
? '故障'
: '未连接'}
</Tag>
<Tag color="purple">
{cable.cableType === 'ethernet'
? '网线'
: cable.cableType === 'fiber'
? '光纤'
: '铜缆'}
</Tag>
{cable.cableLength && <Tag color="orange">{cable.cableLength}m</Tag>}
</Space>
</Col>
{cable.description && (
<Col span={24}>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
{cable.description}
</div>
</Col>
)}
</Row>
</Card>
))}
</Space>
)}
</div>
),
children: renderCablesTab(),
},
{
key: 'tickets',
@@ -326,25 +597,21 @@ function DeviceDetailDrawer({
),
children: (
<div className="tickets-panel">
<div style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<ToolOutlined />}
onClick={() => onCreateTicket?.(device)}
>
创建工单
</Button>
</div>
<Table
columns={ticketColumns}
dataSource={tickets}
rowKey="ticketId"
loading={ticketsLoading}
pagination={{
...ticketsPagination,
current: ticketsPagination.current,
pageSize: ticketsPagination.pageSize,
total: ticketsPagination.total,
onChange: (page, pageSize) => fetchDeviceTickets(page, pageSize),
showSizeChanger: false,
size: 'small',
}}
size="small"
locale={{ emptyText: <Empty description="该设备暂无工单记录" /> }}
/>
</div>
),
@@ -364,32 +631,14 @@ function DeviceDetailDrawer({
open={visible}
onClose={onClose}
extra={
<Space>
<Tooltip title="编辑设备信息">
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)}>
编辑
</Button>
</Tooltip>
<Tooltip title="添加网卡">
<Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>
加网卡
</Button>
</Tooltip>
<Tooltip title="添加端口">
<Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>
加端口
</Button>
</Tooltip>
<Tooltip title="添加接线">
<Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>
加接线
</Button>
</Tooltip>
</Space>
<Tooltip title="编辑设备信息">
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)}>
编辑
</Button>
</Tooltip>
}
styles={{ body: { padding: '0', overflow: 'auto' } }}
>
{/* 设备头部信息 */}
<div style={{ padding: '20px 24px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: '#fff' }}>
<Row gutter={[16, 16]} align="middle">
<Col>
@@ -408,9 +657,7 @@ function DeviceDetailDrawer({
</Row>
</div>
{/* 设备信息内容 */}
<div style={{ padding: '20px 24px' }}>
{/* 基本信息卡片 */}
<Card title="基本信息" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[24, 16]}>
<Col span={8}>
@@ -440,7 +687,6 @@ function DeviceDetailDrawer({
</Row>
</Card>
{/* 维保信息卡片 */}
<Card title="维保信息" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[24, 16]}>
<Col span={12}>
@@ -458,14 +704,12 @@ function DeviceDetailDrawer({
</Row>
</Card>
{/* 描述信息 */}
{device.description && (
<Card title="描述" size="small" style={{ marginBottom: 16 }}>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{device.description}</div>
</Card>
)}
{/* 自定义字段卡片 */}
{customFieldEntries.length > 0 && (
<Card title="自定义字段" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[24, 16]}>
@@ -484,7 +728,6 @@ function DeviceDetailDrawer({
<Divider style={{ margin: '24px 0' }} />
{/* 标签页 */}
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</div>
</Drawer>
@@ -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 = ({
</Form.Item>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: '16px' }}>
<Col span={12}>
<Form.Item
name="position"
label={
<span>
安装位置 (U位)
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入U位' }]}
style={{ marginBottom: '0' }}
>
<InputNumber
placeholder="如: 1"
min={1}
max={42}
style={{ width: '100%', borderRadius: '8px' }}
onChange={handlePositionChange}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="height"
label={
<span>
设备高度 (U)
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入设备高度' }]}
initialValue={1}
style={{ marginBottom: '0' }}
>
<InputNumber
placeholder="如: 2"
min={1}
max={10}
style={{ width: '100%', borderRadius: '8px' }}
onChange={handleHeightChange}
/>
</Form.Item>
</Col>
</Row>
{positionConflict && (
<div style={{ marginTop: '12px' }}>
<Alert
message={positionConflict}
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
/>
</div>
)}
</div>
</Col>
</React.Fragment>
+84 -1
View File
@@ -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 = () => {
</Col>
<Col span={8}>
<Form.Item name="position" label="U位">
<Input type="number" placeholder="请输入U位" min={1} style={{ borderRadius: '8px' }} />
<Input type="number" placeholder="请输入U位" min={1} style={{ borderRadius: '8px' }} onChange={handleShelvePositionChange} />
</Form.Item>
</Col>
</Row>
{shelvePositionConflict && (
<div style={{ marginTop: '12px' }}>
<div style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
}}>
<ExclamationCircleOutlined style={{ color: '#ef4444', fontSize: '18px', marginTop: '2px' }} />
<div>
<div style={{ color: '#dc2626', fontWeight: 600, marginBottom: '4px' }}>U位冲突</div>
<div style={{ color: '#991b1b', fontSize: '13px' }}>{shelvePositionConflict}</div>
</div>
</div>
</div>
)}
</div>
<div style={{
File diff suppressed because it is too large Load Diff
@@ -1093,15 +1093,8 @@ const Rack3DVisualization = () => {
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}
/>
</Content>
+104 -9
View File
@@ -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}
</p>
)}
{importResult.duplicates > 0 && (
<p style={{ margin: '8px 0', color: '#faad14' }}>
跳过已存在{importResult.duplicates}
</p>
)}
</div>
{importResult.createdRacks && importResult.createdRacks.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#52c41a' }}>
本次新增机柜{importResult.createdRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '8px',
padding: '12px'
}}>
{importResult.createdRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
</div>
))}
</div>
</div>
)}
{importResult.skippedRacks && importResult.skippedRacks.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#faad14' }}>
已跳过机柜{importResult.skippedRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: '8px',
padding: '12px'
}}>
{importResult.skippedRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
</div>
))}
</div>
</div>
)}
{importResult.errors && importResult.errors.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px' }}>错误详情</p>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#ff4d4f' }}>错误详情</p>
{importResult.errors.slice(0, 5).map((err, idx) => (
<div
key={idx}