feat(floorplan): 新增机房平面图功能模块

refactor(models): 为Room和Rack模型添加布局相关字段
feat(routes): 实现机房平面图相关API接口
feat(components): 添加平面图编辑器、画布渲染及交互组件
feat(hooks): 实现平面图数据管理和状态管理
feat(pages): 新增机房平面图页面入口
style(components): 优化平面图组件样式和交互体验
This commit is contained in:
zhang1106
2026-04-30 16:26:39 +08:00
parent 4ab5265882
commit 979c28fdcd
34 changed files with 3108 additions and 62 deletions
+13 -1
View File
@@ -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',
+12
View File
@@ -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',
+26
View File
@@ -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,
};
+5 -16
View File
@@ -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 '\\'`
);
}
});
+36
View File
@@ -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
View File
@@ -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;
+38
View File
@@ -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);