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:
@@ -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,7 +141,7 @@ 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: '请提供有效的端口数据' });
|
||||
@@ -140,9 +151,14 @@ router.post('/batch', async (req, res) => {
|
||||
total: ports.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
updated: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
const transaction = await DevicePort.sequelize.transaction();
|
||||
|
||||
try {
|
||||
for (let i = 0; i < ports.length; i++) {
|
||||
const portData = ports[i];
|
||||
|
||||
@@ -152,10 +168,30 @@ router.post('/batch', async (req, res) => {
|
||||
}
|
||||
|
||||
const existingPort = await DevicePort.findOne({
|
||||
where: { deviceId: portData.deviceId, portName: portData.portName }
|
||||
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('该设备的端口名称已存在');
|
||||
}
|
||||
|
||||
@@ -169,7 +205,7 @@ router.post('/batch', async (req, res) => {
|
||||
status: portData.status || 'free',
|
||||
vlanId: portData.vlanId,
|
||||
description: portData.description
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
@@ -182,7 +218,12 @@ router.post('/batch', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
} 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: '端口不存在' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -254,6 +318,28 @@ router.delete('/batch', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量删除端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:portId', async (req, res) => {
|
||||
try {
|
||||
const port = await DevicePort.findByPk(req.params.portId, {
|
||||
|
||||
+172
-37
@@ -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,18 +2028,59 @@ 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);
|
||||
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 = req.body.powerConsumption - oldDevice.powerConsumption;
|
||||
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, {
|
||||
include: [
|
||||
@@ -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({
|
||||
|
||||
@@ -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: '空闲设备删除成功' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-4
@@ -573,8 +573,8 @@ 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();
|
||||
|
||||
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(停用)');
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,60 +237,239 @@ function DeviceDetailDrawer({
|
||||
return typeMap[type?.toLowerCase()] || type || '未知设备';
|
||||
}, []);
|
||||
|
||||
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));
|
||||
|
||||
const tabItems = [
|
||||
const renderPortTable = (ports) => {
|
||||
const columns = [
|
||||
{
|
||||
key: 'ports',
|
||||
label: (
|
||||
<span>
|
||||
<ApiOutlined />
|
||||
端口与网卡
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<NetworkCardPanel
|
||||
deviceId={device?.deviceId}
|
||||
deviceName={device?.name}
|
||||
onRefresh={onRefreshCables}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
),
|
||||
title: '端口名称',
|
||||
dataIndex: 'portName',
|
||||
key: 'portName',
|
||||
width: 120,
|
||||
render: text => <span style={{ fontWeight: 500 }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
key: 'cables',
|
||||
label: (
|
||||
<span>
|
||||
<LinkOutlined />
|
||||
接线 ({deviceCables.length})
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="cable-panel">
|
||||
{deviceCables.length === 0 ? (
|
||||
<Empty description="该设备暂无接线" />
|
||||
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%' }}>
|
||||
{deviceCables.map(cable => (
|
||||
{paginatedCables.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}>
|
||||
@@ -312,9 +528,64 @@ function DeviceDetailDrawer({
|
||||
</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));
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'ports',
|
||||
label: (
|
||||
<span>
|
||||
<ApiOutlined />
|
||||
端口与网卡 ({networkCards.length})
|
||||
</span>
|
||||
),
|
||||
children: renderPortsTab(),
|
||||
},
|
||||
{
|
||||
key: 'cables',
|
||||
label: (
|
||||
<span>
|
||||
<LinkOutlined />
|
||||
接线 ({deviceCables.length})
|
||||
</span>
|
||||
),
|
||||
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>
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('机柜导入失败');
|
||||
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user