feat(floorplan): 新增机房平面图功能模块
refactor(models): 为Room和Rack模型添加布局相关字段 feat(routes): 实现机房平面图相关API接口 feat(components): 添加平面图编辑器、画布渲染及交互组件 feat(hooks): 实现平面图数据管理和状态管理 feat(pages): 新增机房平面图页面入口 style(components): 优化平面图组件样式和交互体验
This commit is contained in:
+13
-1
@@ -19,7 +19,7 @@ const Rack = sequelize.define(
|
||||
height: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 45, // 标准机柜高度(U数)
|
||||
defaultValue: 45,
|
||||
},
|
||||
maxPower: {
|
||||
type: DataTypes.FLOAT,
|
||||
@@ -41,6 +41,18 @@ const Rack = sequelize.define(
|
||||
key: 'roomId',
|
||||
},
|
||||
},
|
||||
rowPos: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: null,
|
||||
},
|
||||
colPos: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: null,
|
||||
},
|
||||
facing: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'front',
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'racks',
|
||||
|
||||
@@ -65,6 +65,18 @@ const Room = sequelize.define(
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
gridRows: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 10,
|
||||
},
|
||||
gridCols: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 10,
|
||||
},
|
||||
layoutConfig: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'rooms',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const Room = require('./Room');
|
||||
const Rack = require('./Rack');
|
||||
const Device = require('./Device');
|
||||
const DeviceField = require('./DeviceField');
|
||||
const DevicePort = require('./DevicePort');
|
||||
const NetworkCard = require('./NetworkCard');
|
||||
const Cable = require('./Cable');
|
||||
const Ticket = require('./Ticket');
|
||||
const InventoryRecord = require('./InventoryRecord');
|
||||
const Warehouse = require('./Warehouse');
|
||||
|
||||
// 建立Rack与Device的关联关系(Device模型中已有反向关联定义)
|
||||
Rack.hasMany(Device, { foreignKey: 'rackId', as: 'Devices' });
|
||||
|
||||
module.exports = {
|
||||
Room,
|
||||
Rack,
|
||||
Device,
|
||||
DeviceField,
|
||||
DevicePort,
|
||||
NetworkCard,
|
||||
Cable,
|
||||
Ticket,
|
||||
InventoryRecord,
|
||||
Warehouse,
|
||||
};
|
||||
@@ -8,15 +8,7 @@ const path = require('path');
|
||||
const csv = require('csv-parser');
|
||||
const { createObjectCsvWriter } = require('csv-writer');
|
||||
const iconv = require('iconv-lite');
|
||||
const Device = require('../models/Device');
|
||||
const Rack = require('../models/Rack');
|
||||
const Room = require('../models/Room');
|
||||
const DeviceField = require('../models/DeviceField');
|
||||
const Ticket = require('../models/Ticket');
|
||||
const DevicePort = require('../models/DevicePort');
|
||||
const Cable = require('../models/Cable');
|
||||
const NetworkCard = require('../models/NetworkCard');
|
||||
const InventoryRecord = require('../models/InventoryRecord');
|
||||
const { Room, Rack, Device, DeviceField, Ticket, DevicePort, Cable, NetworkCard, InventoryRecord } = require('../models');
|
||||
const {
|
||||
logDeviceOperation,
|
||||
generateDeviceDescription,
|
||||
@@ -32,11 +24,6 @@ const {
|
||||
queryDeviceSchema,
|
||||
} = require('../validation/deviceSchema');
|
||||
|
||||
Device.belongsTo(Rack, { foreignKey: 'rackId' });
|
||||
Rack.hasMany(Device, { foreignKey: 'rackId' });
|
||||
Rack.belongsTo(Room, { foreignKey: 'roomId' });
|
||||
Room.hasMany(Rack, { foreignKey: 'roomId' });
|
||||
|
||||
const PREVIEW_COUNT = 20;
|
||||
|
||||
async function checkPositionAvailable(
|
||||
@@ -577,11 +564,13 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
|
||||
// 使用 sequelize.literal 构建原始SQL条件
|
||||
if (dbDialect === 'mysql') {
|
||||
return sequelize.literal(
|
||||
`JSON_EXTRACT(customFields, '$."${safeFieldName}"') LIKE '%${escapedKeyword}%' ESCAPE '\\\\'`
|
||||
`JSON_UNQUOTE(JSON_EXTRACT(customFields, '$."${safeFieldName}"')) LIKE '%${escapedKeyword}%' ESCAPE '\\\\'`
|
||||
);
|
||||
} else {
|
||||
// SQLite: json_extract 返回 JSON 格式值,需要转换为文本进行比较
|
||||
// 使用 ->> 操作符或 CAST 获取纯文本值
|
||||
return sequelize.literal(
|
||||
`json_extract(customFields, '$.${safeFieldName}') LIKE '%${escapedKeyword}%' ESCAPE '\\'`
|
||||
`CAST(json_extract(customFields, '$.${safeFieldName}') AS TEXT) LIKE '%${escapedKeyword}%' ESCAPE '\\'`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -691,4 +691,40 @@ router.post('/import', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 更新机柜位置
|
||||
router.put('/:rackId/position', async (req, res) => {
|
||||
try {
|
||||
const { rowPos, colPos, facing } = req.body;
|
||||
const rack = await Rack.findByPk(req.params.rackId);
|
||||
if (!rack) {
|
||||
return res.status(404).json({ error: '机柜不存在' });
|
||||
}
|
||||
|
||||
if (rowPos !== undefined && colPos !== undefined) {
|
||||
const conflict = await Rack.findOne({
|
||||
where: {
|
||||
roomId: rack.roomId,
|
||||
rackId: { [require('sequelize').Op.ne]: req.params.rackId },
|
||||
rowPos,
|
||||
colPos,
|
||||
},
|
||||
});
|
||||
if (conflict) {
|
||||
return res.status(409).json({ error: `位置(${rowPos},${colPos})已被机柜${conflict.name}占用` });
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (rowPos !== undefined) updateData.rowPos = rowPos;
|
||||
if (colPos !== undefined) updateData.colPos = colPos;
|
||||
if (facing !== undefined) updateData.facing = facing;
|
||||
|
||||
await Rack.update(updateData, { where: { rackId: req.params.rackId } });
|
||||
const updatedRack = await Rack.findByPk(req.params.rackId);
|
||||
res.json(updatedRack);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+169
-3
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Room = require('../models/Room');
|
||||
const Rack = require('../models/Rack');
|
||||
const { Room, Rack, Device } = require('../models');
|
||||
const { sequelize } = require('../db');
|
||||
const { validateBody } = require('../middleware/validation');
|
||||
const { createRoomSchema, updateRoomSchema } = require('../validation/roomSchema');
|
||||
|
||||
@@ -72,7 +72,6 @@ router.put('/:roomId', validateBody(updateRoomSchema), async (req, res) => {
|
||||
// 删除机房
|
||||
router.delete('/:roomId', async (req, res) => {
|
||||
try {
|
||||
// 检查是否有机柜关联
|
||||
const racks = await Rack.findAll({ where: { roomId: req.params.roomId } });
|
||||
if (racks.length > 0) {
|
||||
return res.status(400).json({ error: '该机房下有机柜,无法删除' });
|
||||
@@ -91,4 +90,171 @@ router.delete('/:roomId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取机房平面图布局数据
|
||||
router.get('/:roomId/layout', async (req, res) => {
|
||||
try {
|
||||
const room = await Room.findByPk(req.params.roomId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: '机房不存在' });
|
||||
}
|
||||
|
||||
const racks = await Rack.findAll({
|
||||
where: { roomId: req.params.roomId },
|
||||
include: [{
|
||||
model: Device,
|
||||
as: 'Devices',
|
||||
attributes: ['deviceId', 'name', 'type', 'status', 'position', 'height', 'powerConsumption', 'ipAddress', 'model'],
|
||||
}],
|
||||
});
|
||||
|
||||
const rackData = racks.map(rack => {
|
||||
const rackJson = rack.toJSON();
|
||||
const devices = rackJson.Devices || [];
|
||||
const usedU = devices.reduce((sum, d) => sum + (d.height || 1), 0);
|
||||
const totalPower = devices.reduce((sum, d) => sum + (d.powerConsumption || 0), 0);
|
||||
return {
|
||||
rackId: rackJson.rackId,
|
||||
name: rackJson.name,
|
||||
rowPos: rackJson.rowPos,
|
||||
colPos: rackJson.colPos,
|
||||
facing: rackJson.facing,
|
||||
status: rackJson.status,
|
||||
height: rackJson.height,
|
||||
maxPower: rackJson.maxPower,
|
||||
currentPower: rackJson.currentPower,
|
||||
deviceCount: devices.length,
|
||||
usedU,
|
||||
totalU: rackJson.height,
|
||||
utilization: rackJson.height > 0 ? usedU / rackJson.height : 0,
|
||||
totalPower,
|
||||
Devices: devices,
|
||||
};
|
||||
});
|
||||
|
||||
const activeRacks = rackData.filter(r => r.status === 'active').length;
|
||||
const maintenanceRacks = rackData.filter(r => r.status === 'maintenance').length;
|
||||
const inactiveRacks = rackData.filter(r => r.status === 'inactive').length;
|
||||
const totalMaxPower = rackData.reduce((sum, r) => sum + (r.maxPower || 0), 0);
|
||||
const totalCurrentPower = rackData.reduce((sum, r) => sum + (r.currentPower || 0), 0);
|
||||
const avgUtilization = rackData.length > 0
|
||||
? rackData.reduce((sum, r) => sum + r.utilization, 0) / rackData.length
|
||||
: 0;
|
||||
|
||||
res.json({
|
||||
room: {
|
||||
roomId: room.roomId,
|
||||
name: room.name,
|
||||
location: room.location,
|
||||
area: room.area,
|
||||
capacity: room.capacity,
|
||||
gridRows: room.gridRows,
|
||||
gridCols: room.gridCols,
|
||||
layoutConfig: room.layoutConfig,
|
||||
},
|
||||
racks: rackData,
|
||||
stats: {
|
||||
totalRacks: rackData.length,
|
||||
activeRacks,
|
||||
maintenanceRacks,
|
||||
inactiveRacks,
|
||||
avgUtilization: Math.round(avgUtilization * 100) / 100,
|
||||
totalCurrentPower,
|
||||
totalMaxPower,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新机房布局参数
|
||||
router.put('/:roomId/layout', async (req, res) => {
|
||||
try {
|
||||
const { gridRows, gridCols, layoutConfig } = req.body;
|
||||
const room = await Room.findByPk(req.params.roomId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: '机房不存在' });
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (gridRows !== undefined) updateData.gridRows = gridRows;
|
||||
if (gridCols !== undefined) updateData.gridCols = gridCols;
|
||||
if (layoutConfig !== undefined) updateData.layoutConfig = layoutConfig;
|
||||
|
||||
await Room.update(updateData, { where: { roomId: req.params.roomId } });
|
||||
const updatedRoom = await Room.findByPk(req.params.roomId);
|
||||
res.json(updatedRoom);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 批量更新机房内机柜位置
|
||||
router.put('/:roomId/racks-positions', async (req, res) => {
|
||||
try {
|
||||
const { positions } = req.body;
|
||||
if (!Array.isArray(positions)) {
|
||||
return res.status(400).json({ error: 'positions 必须是数组' });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const pos of positions) {
|
||||
const { rackId, rowPos, colPos, facing } = pos;
|
||||
if (!rackId) continue;
|
||||
|
||||
const rack = await Rack.findByPk(rackId);
|
||||
if (!rack || rack.roomId !== req.params.roomId) continue;
|
||||
|
||||
await Rack.update(
|
||||
{ rowPos, colPos, facing },
|
||||
{ where: { rackId } }
|
||||
);
|
||||
results.push(rackId);
|
||||
}
|
||||
|
||||
res.json({ success: true, updated: results.length });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化机房布局(自动分配所有机柜位置)
|
||||
router.post('/:roomId/init-layout', async (req, res) => {
|
||||
try {
|
||||
const { gridRows, gridCols } = req.body;
|
||||
const rows = gridRows || 10;
|
||||
const cols = gridCols || 10;
|
||||
|
||||
const racks = await Rack.findAll({
|
||||
where: { roomId: req.params.roomId },
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
const positions = [];
|
||||
let index = 0;
|
||||
for (let row = 0; row < rows && index < racks.length; row++) {
|
||||
for (let col = 0; col < cols && index < racks.length; col++) {
|
||||
positions.push({
|
||||
rackId: racks[index].rackId,
|
||||
rowPos: row,
|
||||
colPos: col,
|
||||
facing: 'front',
|
||||
});
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pos of positions) {
|
||||
await Rack.update(
|
||||
{ rowPos: pos.rowPos, colPos: pos.colPos, facing: pos.facing },
|
||||
{ where: { rackId: pos.rackId } }
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true, assigned: positions.length });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -118,6 +118,16 @@ const migrations = [
|
||||
description: '为 consumable_logs 表添加 lastNameSyncAt 字段,支持名称同步',
|
||||
migrate: migrateConsumableLogNameSync,
|
||||
},
|
||||
{
|
||||
name: '机房布局字段',
|
||||
description: '为 rooms 表添加 gridRows、gridCols、layoutConfig 字段,支持平面图布局',
|
||||
migrate: migrateRoomLayoutFields,
|
||||
},
|
||||
{
|
||||
name: '机柜位置字段',
|
||||
description: '为 racks 表添加 rowPos、colPos、facing 字段,支持机柜位置定位',
|
||||
migrate: migrateRackPositionFields,
|
||||
},
|
||||
];
|
||||
|
||||
async function runMigrations() {
|
||||
@@ -862,6 +872,34 @@ async function migrateConsumableLogNameSync() {
|
||||
console.log(' 耗材日志名称同步迁移完成');
|
||||
}
|
||||
|
||||
async function migrateRoomLayoutFields() {
|
||||
const tableName = 'rooms';
|
||||
const columns = [
|
||||
{ name: 'gridRows', def: 'INTEGER DEFAULT 10' },
|
||||
{ name: 'gridCols', def: 'INTEGER DEFAULT 10' },
|
||||
{ name: 'layoutConfig', def: dbDialect === 'sqlite' ? 'TEXT' : 'JSON' },
|
||||
];
|
||||
|
||||
for (const col of columns) {
|
||||
await addColumnIfNotExists(tableName, col.name, col.def);
|
||||
}
|
||||
console.log(' 机房布局字段迁移完成');
|
||||
}
|
||||
|
||||
async function migrateRackPositionFields() {
|
||||
const tableName = 'racks';
|
||||
const columns = [
|
||||
{ name: 'rowPos', def: 'INTEGER' },
|
||||
{ name: 'colPos', def: 'INTEGER' },
|
||||
{ name: 'facing', def: "VARCHAR(20) DEFAULT 'front'" },
|
||||
];
|
||||
|
||||
for (const col of columns) {
|
||||
await addColumnIfNotExists(tableName, col.name, col.def);
|
||||
}
|
||||
console.log(' 机柜位置字段迁移完成');
|
||||
}
|
||||
|
||||
// 执行迁移
|
||||
runMigrations().catch(error => {
|
||||
console.error('迁移执行失败:', error);
|
||||
|
||||
Reference in New Issue
Block a user