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: {
|
height: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 45, // 标准机柜高度(U数)
|
defaultValue: 45,
|
||||||
},
|
},
|
||||||
maxPower: {
|
maxPower: {
|
||||||
type: DataTypes.FLOAT,
|
type: DataTypes.FLOAT,
|
||||||
@@ -41,6 +41,18 @@ const Rack = sequelize.define(
|
|||||||
key: 'roomId',
|
key: 'roomId',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
rowPos: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: null,
|
||||||
|
},
|
||||||
|
colPos: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: null,
|
||||||
|
},
|
||||||
|
facing: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: 'front',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: 'racks',
|
tableName: 'racks',
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ const Room = sequelize.define(
|
|||||||
description: {
|
description: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
|
gridRows: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 10,
|
||||||
|
},
|
||||||
|
gridCols: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 10,
|
||||||
|
},
|
||||||
|
layoutConfig: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
defaultValue: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: 'rooms',
|
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 csv = require('csv-parser');
|
||||||
const { createObjectCsvWriter } = require('csv-writer');
|
const { createObjectCsvWriter } = require('csv-writer');
|
||||||
const iconv = require('iconv-lite');
|
const iconv = require('iconv-lite');
|
||||||
const Device = require('../models/Device');
|
const { Room, Rack, Device, DeviceField, Ticket, DevicePort, Cable, NetworkCard, InventoryRecord } = require('../models');
|
||||||
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 {
|
const {
|
||||||
logDeviceOperation,
|
logDeviceOperation,
|
||||||
generateDeviceDescription,
|
generateDeviceDescription,
|
||||||
@@ -32,11 +24,6 @@ const {
|
|||||||
queryDeviceSchema,
|
queryDeviceSchema,
|
||||||
} = require('../validation/deviceSchema');
|
} = 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;
|
const PREVIEW_COUNT = 20;
|
||||||
|
|
||||||
async function checkPositionAvailable(
|
async function checkPositionAvailable(
|
||||||
@@ -577,11 +564,13 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
|
|||||||
// 使用 sequelize.literal 构建原始SQL条件
|
// 使用 sequelize.literal 构建原始SQL条件
|
||||||
if (dbDialect === 'mysql') {
|
if (dbDialect === 'mysql') {
|
||||||
return sequelize.literal(
|
return sequelize.literal(
|
||||||
`JSON_EXTRACT(customFields, '$."${safeFieldName}"') LIKE '%${escapedKeyword}%' ESCAPE '\\\\'`
|
`JSON_UNQUOTE(JSON_EXTRACT(customFields, '$."${safeFieldName}"')) LIKE '%${escapedKeyword}%' ESCAPE '\\\\'`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// SQLite: json_extract 返回 JSON 格式值,需要转换为文本进行比较
|
||||||
|
// 使用 ->> 操作符或 CAST 获取纯文本值
|
||||||
return sequelize.literal(
|
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;
|
module.exports = router;
|
||||||
|
|||||||
+169
-3
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const Room = require('../models/Room');
|
const { Room, Rack, Device } = require('../models');
|
||||||
const Rack = require('../models/Rack');
|
const { sequelize } = require('../db');
|
||||||
const { validateBody } = require('../middleware/validation');
|
const { validateBody } = require('../middleware/validation');
|
||||||
const { createRoomSchema, updateRoomSchema } = require('../validation/roomSchema');
|
const { createRoomSchema, updateRoomSchema } = require('../validation/roomSchema');
|
||||||
|
|
||||||
@@ -72,7 +72,6 @@ router.put('/:roomId', validateBody(updateRoomSchema), async (req, res) => {
|
|||||||
// 删除机房
|
// 删除机房
|
||||||
router.delete('/:roomId', async (req, res) => {
|
router.delete('/:roomId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// 检查是否有机柜关联
|
|
||||||
const racks = await Rack.findAll({ where: { roomId: req.params.roomId } });
|
const racks = await Rack.findAll({ where: { roomId: req.params.roomId } });
|
||||||
if (racks.length > 0) {
|
if (racks.length > 0) {
|
||||||
return res.status(400).json({ error: '该机房下有机柜,无法删除' });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ const migrations = [
|
|||||||
description: '为 consumable_logs 表添加 lastNameSyncAt 字段,支持名称同步',
|
description: '为 consumable_logs 表添加 lastNameSyncAt 字段,支持名称同步',
|
||||||
migrate: migrateConsumableLogNameSync,
|
migrate: migrateConsumableLogNameSync,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '机房布局字段',
|
||||||
|
description: '为 rooms 表添加 gridRows、gridCols、layoutConfig 字段,支持平面图布局',
|
||||||
|
migrate: migrateRoomLayoutFields,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '机柜位置字段',
|
||||||
|
description: '为 racks 表添加 rowPos、colPos、facing 字段,支持机柜位置定位',
|
||||||
|
migrate: migrateRackPositionFields,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
@@ -862,6 +872,34 @@ async function migrateConsumableLogNameSync() {
|
|||||||
console.log(' 耗材日志名称同步迁移完成');
|
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 => {
|
runMigrations().catch(error => {
|
||||||
console.error('迁移执行失败:', error);
|
console.error('迁移执行失败:', error);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
PartitionOutlined,
|
PartitionOutlined,
|
||||||
CodepenOutlined,
|
CodepenOutlined,
|
||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
|
LayoutOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
BrowserRouter as Router,
|
BrowserRouter as Router,
|
||||||
@@ -83,6 +84,7 @@ const RemoteBackupSettings = lazy(() => import('./pages/RemoteBackupSettings'));
|
|||||||
const OperationLogs = lazy(() => import('./pages/OperationLogs'));
|
const OperationLogs = lazy(() => import('./pages/OperationLogs'));
|
||||||
const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest'));
|
const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest'));
|
||||||
const IdleDeviceManagement = lazy(() => import('./pages/IdleDeviceManagement'));
|
const IdleDeviceManagement = lazy(() => import('./pages/IdleDeviceManagement'));
|
||||||
|
const RoomFloorPlan = lazy(() => import('./pages/RoomFloorPlan'));
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Header, Content, Sider } = Layout;
|
||||||
|
|
||||||
@@ -242,6 +244,11 @@ const AppLayout = ({ children }) => {
|
|||||||
icon: <CodepenOutlined style={{ fontSize: '16px' }} />,
|
icon: <CodepenOutlined style={{ fontSize: '16px' }} />,
|
||||||
label: <Link to="/visualization-3d">3D机柜可视化</Link>,
|
label: <Link to="/visualization-3d">3D机柜可视化</Link>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'floor-plan',
|
||||||
|
icon: <LayoutOutlined style={{ fontSize: '16px' }} />,
|
||||||
|
label: <Link to="/floor-plan">机房平面图</Link>,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -608,6 +615,7 @@ const routeConfig = [
|
|||||||
{ path: '/operation-logs', component: OperationLogs },
|
{ path: '/operation-logs', component: OperationLogs },
|
||||||
{ path: '/error-boundary-test', component: ErrorBoundaryTest },
|
{ path: '/error-boundary-test', component: ErrorBoundaryTest },
|
||||||
{ path: '/idle-devices', component: IdleDeviceManagement },
|
{ path: '/idle-devices', component: IdleDeviceManagement },
|
||||||
|
{ path: '/floor-plan', component: RoomFloorPlan },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ThemeConfig = () => {
|
const ThemeConfig = () => {
|
||||||
|
|||||||
@@ -170,6 +170,22 @@ export const deviceAPI = {
|
|||||||
delete: deviceId => api.delete(`/devices/${deviceId}`),
|
delete: deviceId => api.delete(`/devices/${deviceId}`),
|
||||||
getTickets: (deviceId, params) => api.get(`/devices/${deviceId}/tickets`, { params }),
|
getTickets: (deviceId, params) => api.get(`/devices/${deviceId}/tickets`, { params }),
|
||||||
checkPosition: (rackId, params) => api.get(`/devices/check-position/${rackId}`, { params }),
|
checkPosition: (rackId, params) => api.get(`/devices/check-position/${rackId}`, { params }),
|
||||||
|
importPreview: file => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('csvFile', file);
|
||||||
|
return api.post('/devices/import-preview', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
import: file => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('csvFile', file);
|
||||||
|
return api.post('/devices/import', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getImportTemplate: () => api.get('/devices/import-template', { responseType: 'blob' }),
|
||||||
|
exportDevices: params => api.get('/devices/export', { params, responseType: 'blob' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ticketAPI = {
|
export const ticketAPI = {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import { deviceAPI } from '../../api';
|
||||||
import { designTokens } from '../../config/theme';
|
import { designTokens } from '../../config/theme';
|
||||||
|
|
||||||
const modalHeaderStyle = {
|
const modalHeaderStyle = {
|
||||||
@@ -29,18 +29,6 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
|
|||||||
const [importPhase, setImportPhase] = useState('');
|
const [importPhase, setImportPhase] = useState('');
|
||||||
const [importResult, setImportResult] = useState(null);
|
const [importResult, setImportResult] = useState(null);
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: '/api',
|
|
||||||
});
|
|
||||||
|
|
||||||
api.interceptors.request.use(config => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
setStep('upload');
|
setStep('upload');
|
||||||
setIsPreviewing(false);
|
setIsPreviewing(false);
|
||||||
@@ -65,25 +53,18 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
|
|||||||
setIsPreviewing(true);
|
setIsPreviewing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const response = await deviceAPI.importPreview(actualFile);
|
||||||
formData.append('csvFile', actualFile);
|
|
||||||
|
|
||||||
const response = await api.post('/devices/import-preview', formData, {
|
if (response.success) {
|
||||||
headers: {
|
setPreviewData(response.data);
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
setPreviewData(response.data.data);
|
|
||||||
setStep('preview');
|
setStep('preview');
|
||||||
} else {
|
} else {
|
||||||
message.error(response.data.error || '预览失败');
|
message.error(response.error || '预览失败');
|
||||||
resetState();
|
resetState();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('预览失败:', error);
|
console.error('预览失败:', error);
|
||||||
message.error(error.response?.data?.error || '预览失败,请检查文件格式');
|
message.error(error || '预览失败,请检查文件格式');
|
||||||
resetState();
|
resetState();
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false);
|
setPreviewLoading(false);
|
||||||
@@ -173,12 +154,8 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
|
|||||||
|
|
||||||
const handleDownloadTemplate = async () => {
|
const handleDownloadTemplate = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const response = await deviceAPI.getImportTemplate();
|
||||||
const response = await axios.get('/api/devices/import-template', {
|
const blob = new Blob([response], { type: 'text/csv; charset=gbk' });
|
||||||
responseType: 'blob',
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
});
|
|
||||||
const blob = new Blob([response.data], { type: 'text/csv; charset=gbk' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -189,17 +166,7 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载模板失败:', error);
|
console.error('下载模板失败:', error);
|
||||||
if (error.response?.data) {
|
message.error(error || '下载模板失败');
|
||||||
const text = await error.response.data.text();
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
message.error(json.message || '下载模板失败');
|
|
||||||
} catch {
|
|
||||||
message.error('下载模板失败');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.error('下载模板失败');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// 机柜卡片基础尺寸(会根据屏幕自适应调整)
|
||||||
|
export const CELL_WIDTH = 220;
|
||||||
|
export const CELL_HEIGHT = 480;
|
||||||
|
export const CELL_GAP = 20;
|
||||||
|
|
||||||
|
// 兼容旧引用
|
||||||
|
export const CELL_SIZE = 220;
|
||||||
|
export const ROW_LABEL_WIDTH = 50;
|
||||||
|
export const COL_LABEL_HEIGHT = 30;
|
||||||
|
|
||||||
|
// 机柜内部尺寸
|
||||||
|
export const RACK_PADDING = 10;
|
||||||
|
export const RACK_HEADER_HEIGHT = 36;
|
||||||
|
export const RACK_STATUS_BAR_HEIGHT = 4;
|
||||||
|
|
||||||
|
// U位显示尺寸(双边)
|
||||||
|
export const U_HEIGHT = 10;
|
||||||
|
export const U_LABEL_WIDTH_LEFT = 28;
|
||||||
|
export const U_LABEL_WIDTH_RIGHT = 28;
|
||||||
|
export const U_BODY_WIDTH = 154;
|
||||||
|
export const U_BODY_PADDING = 6;
|
||||||
|
|
||||||
|
// 设备显示尺寸
|
||||||
|
export const DEVICE_INDICATOR_WIDTH = 3;
|
||||||
|
export const DEVICE_STATUS_DOT_SIZE = 2.5;
|
||||||
|
export const DEVICE_NAME_MAX_WIDTH = 130;
|
||||||
|
|
||||||
|
// 小地图
|
||||||
|
export const MINI_MAP_SIZE = 160;
|
||||||
|
export const MINI_MAP_PADDING = 10;
|
||||||
|
|
||||||
|
// 缩放参数
|
||||||
|
export const ZOOM = {
|
||||||
|
MIN: 0.3,
|
||||||
|
MAX: 2,
|
||||||
|
STEP: 0.1,
|
||||||
|
DEFAULT: 1,
|
||||||
|
WHEEL_FACTOR: 0.001,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 颜色定义
|
||||||
|
export const COLORS = {
|
||||||
|
CANVAS_BG: '#f0f2f5',
|
||||||
|
RACK_BG: '#ffffff',
|
||||||
|
RACK_BORDER: '#d9d9d9',
|
||||||
|
RACK_SHADOW: 'rgba(0,0,0,0.08)',
|
||||||
|
RACK_HEADER_BG: '#fafafa',
|
||||||
|
EMPTY_U_BG: '#f5f5f5',
|
||||||
|
GRID_LINE: '#e8e8e8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RACK_STATUS_COLORS = {
|
||||||
|
active: '#1677ff',
|
||||||
|
maintenance: '#faad14',
|
||||||
|
inactive: '#bfbfbf',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEVICE_TYPE_COLORS = {
|
||||||
|
server: '#1677ff',
|
||||||
|
switch: '#52c41a',
|
||||||
|
router: '#faad14',
|
||||||
|
firewall: '#ff4d4f',
|
||||||
|
storage: '#722ed1',
|
||||||
|
other: '#8c8c8c',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEVICE_STATUS_COLORS = {
|
||||||
|
running: '#52c41a',
|
||||||
|
maintenance: '#faad14',
|
||||||
|
offline: '#8c8c8c',
|
||||||
|
fault: '#ff4d4f',
|
||||||
|
idle: '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HEAT_MAP = {
|
||||||
|
LOW: '#52c41a',
|
||||||
|
MEDIUM: '#faad14',
|
||||||
|
HIGH: '#ff4d4f',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 字体定义
|
||||||
|
export const FONT = {
|
||||||
|
RACK_NAME: 'bold 13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
RACK_INFO: '10px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
DEVICE_NAME: '10px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
U_LABEL: '8px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { ZOOM, CELL_WIDTH, CELL_GAP } from './CanvasConstants';
|
||||||
|
|
||||||
|
class CanvasInteraction {
|
||||||
|
constructor(canvas, renderer, callbacks) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.callbacks = callbacks || {};
|
||||||
|
|
||||||
|
this.isPanning = false;
|
||||||
|
this.panStartX = 0;
|
||||||
|
this.panStartY = 0;
|
||||||
|
this.panStartOffsetX = 0;
|
||||||
|
this.panStartOffsetY = 0;
|
||||||
|
this.lastMouseX = 0;
|
||||||
|
this.lastMouseY = 0;
|
||||||
|
|
||||||
|
this.onMouseDown = this.onMouseDown.bind(this);
|
||||||
|
this.onMouseMove = this.onMouseMove.bind(this);
|
||||||
|
this.onMouseUp = this.onMouseUp.bind(this);
|
||||||
|
this.onWheel = this.onWheel.bind(this);
|
||||||
|
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||||
|
this.onContextMenu = this.onContextMenu.bind(this);
|
||||||
|
this.onTouchStart = this.onTouchStart.bind(this);
|
||||||
|
this.onTouchMove = this.onTouchMove.bind(this);
|
||||||
|
this.onTouchEnd = this.onTouchEnd.bind(this);
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||||
|
this.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||||
|
this.canvas.addEventListener('mouseup', this.onMouseUp);
|
||||||
|
this.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||||
|
this.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
||||||
|
this.canvas.addEventListener('dblclick', this.onDoubleClick);
|
||||||
|
this.canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||||
|
this.canvas.addEventListener('touchstart', this.onTouchStart, { passive: false });
|
||||||
|
this.canvas.addEventListener('touchmove', this.onTouchMove, { passive: false });
|
||||||
|
this.canvas.addEventListener('touchend', this.onTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||||
|
this.canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||||
|
this.canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||||
|
this.canvas.removeEventListener('mouseleave', this.onMouseLeave);
|
||||||
|
this.canvas.removeEventListener('wheel', this.onWheel);
|
||||||
|
this.canvas.removeEventListener('dblclick', this.onDoubleClick);
|
||||||
|
this.canvas.removeEventListener('contextmenu', this.onContextMenu);
|
||||||
|
this.canvas.removeEventListener('touchstart', this.onTouchStart);
|
||||||
|
this.canvas.removeEventListener('touchmove', this.onTouchMove);
|
||||||
|
this.canvas.removeEventListener('touchend', this.onTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCanvasCoords(e) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseDown(e) {
|
||||||
|
if (e.button === 2) return;
|
||||||
|
|
||||||
|
const coords = this.getCanvasCoords(e);
|
||||||
|
this.lastMouseX = coords.x;
|
||||||
|
this.lastMouseY = coords.y;
|
||||||
|
this.panStartX = coords.x;
|
||||||
|
this.panStartY = coords.y;
|
||||||
|
this.panStartOffsetX = this.renderer.offsetX;
|
||||||
|
this.panStartOffsetY = this.renderer.offsetY;
|
||||||
|
this.isPanning = true;
|
||||||
|
this.canvas.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(e) {
|
||||||
|
const coords = this.getCanvasCoords(e);
|
||||||
|
this.lastMouseX = coords.x;
|
||||||
|
this.lastMouseY = coords.y;
|
||||||
|
|
||||||
|
if (this.isPanning) {
|
||||||
|
const dx = coords.x - this.panStartX;
|
||||||
|
const dy = coords.y - this.panStartY;
|
||||||
|
this.renderer.setView(
|
||||||
|
this.renderer.zoom,
|
||||||
|
this.panStartOffsetX + dx,
|
||||||
|
this.panStartOffsetY + dy
|
||||||
|
);
|
||||||
|
this.callbacks.onViewChange?.({
|
||||||
|
zoom: this.renderer.zoom,
|
||||||
|
offsetX: this.renderer.offsetX,
|
||||||
|
offsetY: this.renderer.offsetY,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||||
|
|
||||||
|
if (hitResult) {
|
||||||
|
this.renderer.setHoveredRack(hitResult.rack);
|
||||||
|
this.renderer.setHoveredDevice(hitResult.device);
|
||||||
|
this.canvas.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
if (hitResult.device) {
|
||||||
|
this.callbacks.onDeviceHover?.(hitResult.device, hitResult.rack, coords.x, coords.y);
|
||||||
|
} else {
|
||||||
|
this.callbacks.onRackHover?.(hitResult.rack, coords.x, coords.y);
|
||||||
|
this.callbacks.onDeviceHover?.(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.renderer.setHoveredRack(null);
|
||||||
|
this.renderer.setHoveredDevice(null);
|
||||||
|
this.canvas.style.cursor = 'default';
|
||||||
|
this.callbacks.onRackHover?.(null);
|
||||||
|
this.callbacks.onDeviceHover?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseUp(e) {
|
||||||
|
if (this.isPanning) {
|
||||||
|
const dx = Math.abs((e ? this.getCanvasCoords(e).x : this.lastMouseX) - this.panStartX);
|
||||||
|
const dy = Math.abs((e ? this.getCanvasCoords(e).y : this.lastMouseY) - this.panStartY);
|
||||||
|
|
||||||
|
if (dx < 5 && dy < 5) {
|
||||||
|
const coords = e ? this.getCanvasCoords(e) : { x: this.lastMouseX, y: this.lastMouseY };
|
||||||
|
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||||
|
if (hitResult) {
|
||||||
|
if (hitResult.device) {
|
||||||
|
this.callbacks.onDeviceClick?.(hitResult.device, hitResult.rack);
|
||||||
|
} else {
|
||||||
|
this.callbacks.onRackClick?.(hitResult.rack);
|
||||||
|
this.renderer.setSelectedRack(hitResult.rack);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.renderer.setSelectedRack(null);
|
||||||
|
this.callbacks.onRackClick?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.isPanning = false;
|
||||||
|
}
|
||||||
|
this.canvas.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseLeave() {
|
||||||
|
this.isPanning = false;
|
||||||
|
this.renderer.setHoveredRack(null);
|
||||||
|
this.renderer.setHoveredDevice(null);
|
||||||
|
this.canvas.style.cursor = 'default';
|
||||||
|
this.callbacks.onRackHover?.(null);
|
||||||
|
this.callbacks.onDeviceHover?.(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const coords = this.getCanvasCoords(e);
|
||||||
|
const delta = -e.deltaY * ZOOM.WHEEL_FACTOR;
|
||||||
|
const newZoom = Math.max(ZOOM.MIN, Math.min(ZOOM.MAX, this.renderer.zoom * (1 + delta)));
|
||||||
|
|
||||||
|
const scale = newZoom / this.renderer.zoom;
|
||||||
|
const newOffsetX = coords.x - (coords.x - this.renderer.offsetX) * scale;
|
||||||
|
const newOffsetY = coords.y - (coords.y - this.renderer.offsetY) * scale;
|
||||||
|
|
||||||
|
this.renderer.setView(newZoom, newOffsetX, newOffsetY);
|
||||||
|
this.callbacks.onViewChange?.({
|
||||||
|
zoom: newZoom,
|
||||||
|
offsetX: newOffsetX,
|
||||||
|
offsetY: newOffsetY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDoubleClick(e) {
|
||||||
|
const coords = this.getCanvasCoords(e);
|
||||||
|
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||||
|
if (hitResult) {
|
||||||
|
if (hitResult.device) {
|
||||||
|
this.callbacks.onDeviceDoubleClick?.(hitResult.device, hitResult.rack);
|
||||||
|
} else {
|
||||||
|
this.callbacks.onRackDoubleClick?.(hitResult.rack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onContextMenu(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const coords = this.getCanvasCoords(e);
|
||||||
|
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||||
|
if (hitResult) {
|
||||||
|
if (hitResult.device) {
|
||||||
|
this.callbacks.onDeviceContextMenu?.(hitResult.device, hitResult.rack, coords.x, coords.y);
|
||||||
|
} else {
|
||||||
|
this.callbacks.onRackContextMenu?.(hitResult.rack, coords.x, coords.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.onMouseDown({
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY,
|
||||||
|
button: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.onMouseMove({
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
this.onMouseUp({
|
||||||
|
clientX: this.lastMouseX,
|
||||||
|
clientY: this.lastMouseY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomIn() {
|
||||||
|
const center = {
|
||||||
|
x: this.canvas.clientWidth / 2,
|
||||||
|
y: this.canvas.clientHeight / 2,
|
||||||
|
};
|
||||||
|
const newZoom = Math.min(ZOOM.MAX, this.renderer.zoom + ZOOM.STEP);
|
||||||
|
this.applyZoom(newZoom, center.x, center.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomOut() {
|
||||||
|
const center = {
|
||||||
|
x: this.canvas.clientWidth / 2,
|
||||||
|
y: this.canvas.clientHeight / 2,
|
||||||
|
};
|
||||||
|
const newZoom = Math.max(ZOOM.MIN, this.renderer.zoom - ZOOM.STEP);
|
||||||
|
this.applyZoom(newZoom, center.x, center.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomReset() {
|
||||||
|
this.fitToView();
|
||||||
|
}
|
||||||
|
|
||||||
|
fitToView() {
|
||||||
|
const racks = this.renderer.racks;
|
||||||
|
if (!racks || racks.length === 0) {
|
||||||
|
this.renderer.setView(ZOOM.DEFAULT, 24, 24);
|
||||||
|
this.callbacks.onViewChange?.({
|
||||||
|
zoom: ZOOM.DEFAULT,
|
||||||
|
offsetX: 24,
|
||||||
|
offsetY: 24,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算所有机柜的边界框
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
racks.forEach(rack => {
|
||||||
|
const bounds = this.renderer.getRackBounds(rack);
|
||||||
|
minX = Math.min(minX, bounds.x);
|
||||||
|
minY = Math.min(minY, bounds.y);
|
||||||
|
maxX = Math.max(maxX, bounds.x + bounds.width);
|
||||||
|
maxY = Math.max(maxY, bounds.y + bounds.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加padding
|
||||||
|
const padding = 40;
|
||||||
|
minX -= padding;
|
||||||
|
minY -= padding;
|
||||||
|
maxX += padding;
|
||||||
|
maxY += padding;
|
||||||
|
|
||||||
|
const contentWidth = maxX - minX;
|
||||||
|
const contentHeight = maxY - minY;
|
||||||
|
|
||||||
|
// 计算合适的缩放比例
|
||||||
|
const canvasWidth = this.canvas.clientWidth;
|
||||||
|
const canvasHeight = this.canvas.clientHeight;
|
||||||
|
const scaleX = canvasWidth / contentWidth;
|
||||||
|
const scaleY = canvasHeight / contentHeight;
|
||||||
|
const newZoom = Math.min(ZOOM.DEFAULT, scaleX, scaleY);
|
||||||
|
|
||||||
|
// 计算居中的偏移量
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
const newOffsetX = canvasWidth / 2 - centerX * newZoom;
|
||||||
|
const newOffsetY = canvasHeight / 2 - centerY * newZoom;
|
||||||
|
|
||||||
|
this.renderer.setView(newZoom, newOffsetX, newOffsetY);
|
||||||
|
this.callbacks.onViewChange?.({
|
||||||
|
zoom: newZoom,
|
||||||
|
offsetX: newOffsetX,
|
||||||
|
offsetY: newOffsetY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
animateToRack(rack) {
|
||||||
|
const bounds = this.renderer.getRackBounds(rack);
|
||||||
|
const targetX = this.canvas.clientWidth / 2 - (bounds.x + bounds.width / 2) * this.renderer.zoom;
|
||||||
|
const targetY = this.canvas.clientHeight / 2 - (bounds.y + bounds.height / 2) * this.renderer.zoom;
|
||||||
|
this.animateTo(targetX, targetY);
|
||||||
|
this.renderer.setSelectedRack(rack);
|
||||||
|
this.renderer.setSearchHighlight(rack.rackId);
|
||||||
|
setTimeout(() => this.renderer.setSearchHighlight(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
animateTo(targetX, targetY) {
|
||||||
|
const startX = this.renderer.offsetX;
|
||||||
|
const startY = this.renderer.offsetY;
|
||||||
|
const duration = 400;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const animate = (time) => {
|
||||||
|
const elapsed = time - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
|
||||||
|
const currentX = startX + (targetX - startX) * eased;
|
||||||
|
const currentY = startY + (targetY - startY) * eased;
|
||||||
|
|
||||||
|
this.renderer.setView(this.renderer.zoom, currentX, currentY);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyZoom(newZoom, centerX, centerY) {
|
||||||
|
const scale = newZoom / this.renderer.zoom;
|
||||||
|
const newOffsetX = centerX - (centerX - this.renderer.offsetX) * scale;
|
||||||
|
const newOffsetY = centerY - (centerY - this.renderer.offsetY) * scale;
|
||||||
|
|
||||||
|
this.renderer.setView(newZoom, newOffsetX, newOffsetY);
|
||||||
|
this.callbacks.onViewChange?.({
|
||||||
|
zoom: newZoom,
|
||||||
|
offsetX: newOffsetX,
|
||||||
|
offsetY: newOffsetY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditMode(editMode) {
|
||||||
|
// 兼容旧API
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CanvasInteraction;
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import {
|
||||||
|
CELL_WIDTH,
|
||||||
|
CELL_HEIGHT,
|
||||||
|
CELL_GAP,
|
||||||
|
RACK_PADDING,
|
||||||
|
RACK_HEADER_HEIGHT,
|
||||||
|
RACK_STATUS_BAR_HEIGHT,
|
||||||
|
U_HEIGHT,
|
||||||
|
U_LABEL_WIDTH_LEFT,
|
||||||
|
U_LABEL_WIDTH_RIGHT,
|
||||||
|
U_BODY_WIDTH,
|
||||||
|
DEVICE_INDICATOR_WIDTH,
|
||||||
|
COLORS,
|
||||||
|
RACK_STATUS_COLORS,
|
||||||
|
DEVICE_TYPE_COLORS,
|
||||||
|
DEVICE_STATUS_COLORS,
|
||||||
|
FONT,
|
||||||
|
} from './CanvasConstants';
|
||||||
|
|
||||||
|
class CanvasRenderer {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.dpr = window.devicePixelRatio || 1;
|
||||||
|
this.room = null;
|
||||||
|
this.racks = [];
|
||||||
|
this.rackMap = new Map();
|
||||||
|
this.zoom = 1;
|
||||||
|
this.offsetX = 24;
|
||||||
|
this.offsetY = 24;
|
||||||
|
this.hoveredRack = null;
|
||||||
|
this.hoveredDevice = null;
|
||||||
|
this.selectedRack = null;
|
||||||
|
this.viewMode = 'standard';
|
||||||
|
this.dragPreview = null;
|
||||||
|
this.dropTarget = null;
|
||||||
|
this.searchHighlight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDPR() {
|
||||||
|
this.dpr = window.devicePixelRatio || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width, height) {
|
||||||
|
this.setDPR();
|
||||||
|
this.canvas.width = width * this.dpr;
|
||||||
|
this.canvas.height = height * this.dpr;
|
||||||
|
this.canvas.style.width = `${width}px`;
|
||||||
|
this.canvas.style.height = `${height}px`;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(room, racks) {
|
||||||
|
this.room = room;
|
||||||
|
this.racks = (racks || []).slice().sort((a, b) => {
|
||||||
|
const nameA = a.name || '';
|
||||||
|
const nameB = b.name || '';
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
this.rackMap.clear();
|
||||||
|
this.racks.forEach(r => {
|
||||||
|
if (r.rackId) this.rackMap.set(r.rackId, r);
|
||||||
|
});
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(zoom, offsetX, offsetY) {
|
||||||
|
this.zoom = zoom;
|
||||||
|
this.offsetX = offsetX;
|
||||||
|
this.offsetY = offsetY;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setHoveredRack(rack) {
|
||||||
|
if (this.hoveredRack !== rack) {
|
||||||
|
this.hoveredRack = rack;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHoveredDevice(device) {
|
||||||
|
if (this.hoveredDevice !== device) {
|
||||||
|
this.hoveredDevice = device;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedRack(rack) {
|
||||||
|
if (this.selectedRack !== rack) {
|
||||||
|
this.selectedRack = rack;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewMode(mode, heatMapDimension) {
|
||||||
|
this.viewMode = mode;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragPreview(rack, gridRow, gridCol) {
|
||||||
|
this.dragPreview = rack ? { rack, gridRow, gridCol } : null;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDropTarget(row, col) {
|
||||||
|
this.dropTarget = row != null ? { row, col } : null;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchHighlight(rackId) {
|
||||||
|
this.searchHighlight = rackId;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
getRackBounds(rack) {
|
||||||
|
const name = rack.name || '';
|
||||||
|
const firstChar = name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
let row = 0;
|
||||||
|
if (/[A-Z]/.test(firstChar)) {
|
||||||
|
row = firstChar.charCodeAt(0) - 65;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameRowRacks = this.racks.filter(r => {
|
||||||
|
const rName = r.name || '';
|
||||||
|
const rFirst = rName.charAt(0).toUpperCase();
|
||||||
|
let rRow = 0;
|
||||||
|
if (/[A-Z]/.test(rFirst)) {
|
||||||
|
rRow = rFirst.charCodeAt(0) - 65;
|
||||||
|
}
|
||||||
|
return rRow === row;
|
||||||
|
}).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
|
||||||
|
const col = sameRowRacks.indexOf(rack);
|
||||||
|
|
||||||
|
const startX = 24;
|
||||||
|
const startY = 24;
|
||||||
|
const x = startX + col * (CELL_WIDTH + CELL_GAP);
|
||||||
|
const y = startY + row * (CELL_HEIGHT + CELL_GAP);
|
||||||
|
return { x, y, width: CELL_WIDTH, height: CELL_HEIGHT };
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeviceBounds(rack, device) {
|
||||||
|
const rackBounds = this.getRackBounds(rack);
|
||||||
|
const totalU = rack.height || 42;
|
||||||
|
const bodyY = rackBounds.y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT;
|
||||||
|
const bodyHeight = rackBounds.height - RACK_STATUS_BAR_HEIGHT - RACK_HEADER_HEIGHT;
|
||||||
|
const uHeight = Math.min(U_HEIGHT, (bodyHeight - 8) / totalU);
|
||||||
|
|
||||||
|
const leftLabelW = U_LABEL_WIDTH_LEFT;
|
||||||
|
const bodyX = rackBounds.x + RACK_PADDING + leftLabelW;
|
||||||
|
const bodyW = U_BODY_WIDTH;
|
||||||
|
|
||||||
|
// 从底部开始计算坐标(和渲染逻辑一致)
|
||||||
|
const bodyStartY = bodyY + 4 + (totalU * uHeight); // 底部Y坐标
|
||||||
|
const deviceY = bodyStartY - device.position * uHeight;
|
||||||
|
const deviceH = uHeight * (device.height || 1);
|
||||||
|
|
||||||
|
return { x: bodyX, y: deviceY, width: bodyW, height: deviceH };
|
||||||
|
}
|
||||||
|
|
||||||
|
hitTest(canvasX, canvasY) {
|
||||||
|
const viewX = (canvasX - this.offsetX) / this.zoom;
|
||||||
|
const viewY = (canvasY - this.offsetY) / this.zoom;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.racks.length; i++) {
|
||||||
|
const rack = this.racks[i];
|
||||||
|
const bounds = this.getRackBounds(rack);
|
||||||
|
if (viewX >= bounds.x && viewX <= bounds.x + bounds.width && viewY >= bounds.y && viewY <= bounds.y + bounds.height) {
|
||||||
|
// 先检测设备
|
||||||
|
const devices = rack.Devices || [];
|
||||||
|
for (const device of devices) {
|
||||||
|
if (device.position != null) {
|
||||||
|
const deviceBounds = this.getDeviceBounds(rack, device);
|
||||||
|
if (viewX >= deviceBounds.x && viewX <= deviceBounds.x + deviceBounds.width &&
|
||||||
|
viewY >= deviceBounds.y && viewY <= deviceBounds.y + deviceBounds.height) {
|
||||||
|
return { rack, device };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rack, device: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.ctx || !this.room) return;
|
||||||
|
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const { width, height } = this.canvas;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(this.dpr, this.dpr);
|
||||||
|
|
||||||
|
const displayWidth = width / this.dpr;
|
||||||
|
const displayHeight = height / this.dpr;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, displayWidth, displayHeight);
|
||||||
|
|
||||||
|
// 优雅的渐变背景
|
||||||
|
const bgGradient = ctx.createLinearGradient(0, 0, 0, displayHeight);
|
||||||
|
bgGradient.addColorStop(0, '#f8fafc');
|
||||||
|
bgGradient.addColorStop(0.5, '#f1f5f9');
|
||||||
|
bgGradient.addColorStop(1, '#e2e8f0');
|
||||||
|
ctx.fillStyle = bgGradient;
|
||||||
|
ctx.fillRect(0, 0, displayWidth, displayHeight);
|
||||||
|
|
||||||
|
// 细微网格效果
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = 'rgba(148,163,184,0.15)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const gridSize = 40;
|
||||||
|
for (let x = 0; x < displayWidth; x += gridSize) {
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, displayHeight);
|
||||||
|
}
|
||||||
|
for (let y = 0; y < displayHeight; y += gridSize) {
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(displayWidth, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(this.offsetX, this.offsetY);
|
||||||
|
ctx.scale(this.zoom, this.zoom);
|
||||||
|
|
||||||
|
for (let i = 0; i < this.racks.length; i++) {
|
||||||
|
const rack = this.racks[i];
|
||||||
|
const bounds = this.getRackBounds(rack);
|
||||||
|
this.drawRack(ctx, rack, bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRack(ctx, rack, bounds) {
|
||||||
|
const { x, y, width, height } = bounds;
|
||||||
|
const isSelected = this.selectedRack?.rackId === rack.rackId;
|
||||||
|
const isHovered = this.hoveredRack?.rackId === rack.rackId;
|
||||||
|
const isSearchHighlight = this.searchHighlight === rack.rackId;
|
||||||
|
const statusColor = RACK_STATUS_COLORS[rack.status] || RACK_STATUS_COLORS.active;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
this.roundRect(ctx, x, y, width, height, 10);
|
||||||
|
|
||||||
|
// 渐变阴影
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.shadowColor = 'rgba(22,119,255,0.25)';
|
||||||
|
ctx.shadowBlur = 16;
|
||||||
|
ctx.shadowOffsetX = 0;
|
||||||
|
ctx.shadowOffsetY = 6;
|
||||||
|
} else if (isHovered) {
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.15)';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowOffsetX = 0;
|
||||||
|
ctx.shadowOffsetY = 4;
|
||||||
|
} else {
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.08)';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.shadowOffsetX = 0;
|
||||||
|
ctx.shadowOffsetY = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 机柜主体渐变背景
|
||||||
|
const gradient = ctx.createLinearGradient(x, y, x, y + height);
|
||||||
|
gradient.addColorStop(0, '#ffffff');
|
||||||
|
gradient.addColorStop(1, '#f9fafb');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.strokeStyle = isSelected ? '#1677ff' : isHovered ? '#bae7ff' : '#e5e7eb';
|
||||||
|
ctx.lineWidth = isSelected ? 2 : 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (isSearchHighlight) {
|
||||||
|
ctx.strokeStyle = '#52c41a';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.setLineDash([10, 5]);
|
||||||
|
ctx.strokeRect(x - 5, y - 5, width + 10, height + 10);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态栏(带圆角)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + 10, y);
|
||||||
|
ctx.lineTo(x + width - 10, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + RACK_STATUS_BAR_HEIGHT);
|
||||||
|
ctx.lineTo(x, y + RACK_STATUS_BAR_HEIGHT);
|
||||||
|
ctx.lineTo(x, y + RACK_STATUS_BAR_HEIGHT);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + 10, y);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = statusColor;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 标题栏背景(轻微渐变)
|
||||||
|
const headerGradient = ctx.createLinearGradient(x, y + RACK_STATUS_BAR_HEIGHT, x, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT);
|
||||||
|
headerGradient.addColorStop(0, '#fafafa');
|
||||||
|
headerGradient.addColorStop(1, '#f5f5f5');
|
||||||
|
ctx.fillStyle = headerGradient;
|
||||||
|
ctx.fillRect(x, y + RACK_STATUS_BAR_HEIGHT, width, RACK_HEADER_HEIGHT);
|
||||||
|
|
||||||
|
// 标题栏底部细线
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + 10, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT);
|
||||||
|
ctx.lineTo(x + width - 10, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT);
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 机柜名称(居中)
|
||||||
|
ctx.fillStyle = '#1f2937';
|
||||||
|
ctx.font = FONT.RACK_NAME;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(rack.name, x + width / 2, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT / 2);
|
||||||
|
|
||||||
|
// U位和设备数量
|
||||||
|
const deviceCount = rack.deviceCount || 0;
|
||||||
|
const usageText = `${rack.height || 42}U • ${deviceCount}台`;
|
||||||
|
ctx.fillStyle = '#6b7280';
|
||||||
|
ctx.font = FONT.RACK_INFO;
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(usageText, x + width - RACK_PADDING, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT / 2);
|
||||||
|
|
||||||
|
// U位区域
|
||||||
|
const bodyY = y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT;
|
||||||
|
const bodyHeight = height - RACK_STATUS_BAR_HEIGHT - RACK_HEADER_HEIGHT;
|
||||||
|
this.drawRackBody(ctx, rack, x, bodyY, width, bodyHeight);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRackBody(ctx, rack, x, y, w, h) {
|
||||||
|
const totalU = rack.height || 42;
|
||||||
|
const leftLabelW = U_LABEL_WIDTH_LEFT;
|
||||||
|
const rightLabelW = U_LABEL_WIDTH_RIGHT;
|
||||||
|
const bodyW = U_BODY_WIDTH;
|
||||||
|
|
||||||
|
// 计算各区域起始位置
|
||||||
|
const leftLabelX = x + RACK_PADDING;
|
||||||
|
const bodyX = x + RACK_PADDING + leftLabelW;
|
||||||
|
const rightLabelX = x + w - RACK_PADDING - rightLabelW;
|
||||||
|
|
||||||
|
// U位高度
|
||||||
|
const uHeight = Math.min(U_HEIGHT, (h - 8) / totalU);
|
||||||
|
|
||||||
|
// 设备数据 - 按position分组
|
||||||
|
const devices = (rack.Devices || []).filter(d => d.position != null);
|
||||||
|
const deviceByU = new Map();
|
||||||
|
devices.forEach(d => {
|
||||||
|
const startU = d.position;
|
||||||
|
const endU = startU + (d.height || 1) - 1;
|
||||||
|
for (let u = startU; u <= endU; u++) {
|
||||||
|
deviceByU.set(u, d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染左侧U位标签(从下到上:1U在底部)
|
||||||
|
this.drawULabels(ctx, leftLabelX, y + 4, leftLabelW, totalU, uHeight, 'left');
|
||||||
|
|
||||||
|
// 渲染设备区域和空闲U位(从下到上:1U在底部)
|
||||||
|
// 从底部开始渲染
|
||||||
|
const bodyStartY = y + 4 + (totalU * uHeight); // 底部Y坐标
|
||||||
|
for (let u = 1; u <= totalU; u++) {
|
||||||
|
// 计算当前U位的Y坐标(从底部向上)
|
||||||
|
const currentY = bodyStartY - u * uHeight;
|
||||||
|
const d = deviceByU.get(u);
|
||||||
|
if (d && d.position === u) {
|
||||||
|
const isHovered = this.hoveredDevice?.deviceId === d.deviceId;
|
||||||
|
this.drawDevice(ctx, d, bodyX, currentY, bodyW, uHeight * (d.height || 1), isHovered);
|
||||||
|
} else if (!d) {
|
||||||
|
this.drawEmptyU(ctx, bodyX, currentY, bodyW, uHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染右侧U位标签(从下到上:1U在底部)
|
||||||
|
this.drawULabels(ctx, rightLabelX, y + 4, rightLabelW, totalU, uHeight, 'right');
|
||||||
|
}
|
||||||
|
|
||||||
|
drawULabels(ctx, x, y, width, totalU, uHeight, side) {
|
||||||
|
ctx.font = FONT.U_LABEL;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
// 从底部开始渲染标签(1U在底部)
|
||||||
|
for (let u = 1; u <= totalU; u++) {
|
||||||
|
// 从底部向上计算Y坐标
|
||||||
|
const yPos = y + (totalU - u) * uHeight + uHeight / 2;
|
||||||
|
const label = `${u}U`;
|
||||||
|
|
||||||
|
// 偶数U位颜色稍淡,创造层次感
|
||||||
|
if (u % 2 === 0) {
|
||||||
|
ctx.fillStyle = 'rgba(100,116,139,0.45)';
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(71,85,105,0.7)';
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(label, x + width / 2, yPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawEmptyU(ctx, x, y, w, h) {
|
||||||
|
// 更淡的背景色
|
||||||
|
ctx.fillStyle = 'rgba(148,163,184,0.08)';
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
|
||||||
|
// 细微的分隔线
|
||||||
|
ctx.strokeStyle = 'rgba(148,163,184,0.25)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y + h);
|
||||||
|
ctx.lineTo(x + w, y + h);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDevice(ctx, device, x, y, w, h, isHovered) {
|
||||||
|
const typeColor = DEVICE_TYPE_COLORS[device.type] || DEVICE_TYPE_COLORS.other;
|
||||||
|
const statusColor = DEVICE_STATUS_COLORS[device.status] || DEVICE_STATUS_COLORS.offline;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
// 绘制圆角
|
||||||
|
const radius = 3;
|
||||||
|
this.roundRect(ctx, x, y, w, h, radius);
|
||||||
|
|
||||||
|
// 悬停高亮
|
||||||
|
if (isHovered) {
|
||||||
|
ctx.fillStyle = 'rgba(22,119,255,0.15)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowColor = 'rgba(22,119,255,0.3)';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.shadowOffsetY = 2;
|
||||||
|
} else {
|
||||||
|
// 设备背景
|
||||||
|
const bgGradient = ctx.createLinearGradient(x, y, x, y + h);
|
||||||
|
bgGradient.addColorStop(0, '#ffffff');
|
||||||
|
bgGradient.addColorStop(1, '#f9fafb');
|
||||||
|
ctx.fillStyle = bgGradient;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// 左侧类型色条(更宽更明显)
|
||||||
|
ctx.fillStyle = typeColor;
|
||||||
|
ctx.fillRect(x, y, 5, h);
|
||||||
|
|
||||||
|
// 设备边框
|
||||||
|
ctx.strokeStyle = isHovered ? '#1677ff' : 'rgba(148,163,184,0.45)';
|
||||||
|
ctx.lineWidth = isHovered ? 1.5 : 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 状态圆点(更大更明显)
|
||||||
|
ctx.fillStyle = statusColor;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + 12, y + h / 2, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 状态圆点外圈
|
||||||
|
ctx.strokeStyle = isHovered ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 设备名称
|
||||||
|
ctx.fillStyle = isHovered ? '#0f172a' : '#374151';
|
||||||
|
ctx.font = FONT.DEVICE_NAME;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const dName = device.name || '未命名';
|
||||||
|
ctx.fillText(this.truncateText(ctx, dName, w - 26), x + 22, y + h / 2);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
roundRect(ctx, x, y, w, h, r) {
|
||||||
|
if (typeof r === 'number') {
|
||||||
|
r = [r, r, r, r];
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r[0], y);
|
||||||
|
ctx.lineTo(x + w - r[1], y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r[1]);
|
||||||
|
ctx.lineTo(x + w, y + h - r[2]);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r[2], y + h);
|
||||||
|
ctx.lineTo(x + r[3], y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r[3]);
|
||||||
|
ctx.lineTo(x, y + r[0]);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r[0], y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
truncateText(ctx, text, maxWidth) {
|
||||||
|
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||||
|
let tr = text;
|
||||||
|
while (tr.length > 0 && ctx.measureText(tr + '…').width > maxWidth) {
|
||||||
|
tr = tr.slice(0, -1);
|
||||||
|
}
|
||||||
|
return tr + '…';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CanvasRenderer;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import CanvasRenderer from './CanvasRenderer';
|
||||||
|
import CanvasInteraction from './CanvasInteraction';
|
||||||
|
|
||||||
|
const FloorPlanCanvas = forwardRef(({ room, racks, viewMode, heatMapDimension, editMode, onRackClick, onRackDoubleClick, onRackHover, onDeviceHover, onRackDragEnd, onViewChange }, ref) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const rendererRef = useRef(null);
|
||||||
|
const interactionRef = useRef(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getRenderer: () => rendererRef.current,
|
||||||
|
getInteraction: () => interactionRef.current,
|
||||||
|
zoomIn: () => interactionRef.current?.zoomIn(),
|
||||||
|
zoomOut: () => interactionRef.current?.zoomOut(),
|
||||||
|
zoomReset: () => interactionRef.current?.zoomReset(),
|
||||||
|
fitToView: () => interactionRef.current?.fitToView(),
|
||||||
|
animateToRack: (rack) => interactionRef.current?.animateToRack(rack),
|
||||||
|
setSearchHighlight: (rackId) => rendererRef.current?.setSearchHighlight(rackId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
if (!containerRef.current || !rendererRef.current) return;
|
||||||
|
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||||
|
rendererRef.current.resize(width, height);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const renderer = new CanvasRenderer(canvasRef.current);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
const interaction = new CanvasInteraction(canvasRef.current, renderer, {
|
||||||
|
onRackClick,
|
||||||
|
onRackDoubleClick,
|
||||||
|
onRackHover,
|
||||||
|
onDeviceHover,
|
||||||
|
onRackDragEnd,
|
||||||
|
onViewChange,
|
||||||
|
});
|
||||||
|
interactionRef.current = interaction;
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
handleResize();
|
||||||
|
});
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
interaction.destroy();
|
||||||
|
rendererRef.current = null;
|
||||||
|
interactionRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.setData(room, racks || []);
|
||||||
|
// 数据更新后自动居中显示
|
||||||
|
if (room) {
|
||||||
|
setTimeout(() => {
|
||||||
|
interactionRef.current?.fitToView();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [room, racks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.setViewMode(viewMode, heatMapDimension);
|
||||||
|
}
|
||||||
|
}, [viewMode, heatMapDimension]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (interactionRef.current) {
|
||||||
|
interactionRef.current.setEditMode(editMode);
|
||||||
|
}
|
||||||
|
}, [editMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rendererRef.current && room) {
|
||||||
|
setTimeout(() => {
|
||||||
|
interactionRef.current?.fitToView();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [room?.roomId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FloorPlanCanvas.displayName = 'FloorPlanCanvas';
|
||||||
|
|
||||||
|
export default FloorPlanCanvas;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as FloorPlanCanvas } from './FloorPlanCanvas';
|
||||||
|
export { default as CanvasRenderer } from './CanvasRenderer';
|
||||||
|
export { default as CanvasInteraction } from './CanvasInteraction';
|
||||||
|
export * from './CanvasConstants';
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Button, Space, Modal, Form, InputNumber, message, Popconfirm } from 'antd';
|
||||||
|
import { SaveOutlined, CloseOutlined, UndoOutlined, ApartmentOutlined } from '@ant-design/icons';
|
||||||
|
import PositionValidator from './PositionValidator';
|
||||||
|
|
||||||
|
const LayoutEditor = ({ room, racks, editMode, onSave, onCancel, onInitLayout }) => {
|
||||||
|
const [initModalVisible, setInitModalVisible] = useState(false);
|
||||||
|
const [initForm] = Form.useForm();
|
||||||
|
const [pendingPositions, setPendingPositions] = useState([]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((rack, newRow, newCol) => {
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const validation = PositionValidator.validate(
|
||||||
|
rack,
|
||||||
|
newRow,
|
||||||
|
newCol,
|
||||||
|
racks,
|
||||||
|
room.gridRows,
|
||||||
|
room.gridCols
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
message.warning(validation.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingPositions(prev => {
|
||||||
|
const filtered = prev.filter(p => p.rackId !== rack.rackId);
|
||||||
|
return [...filtered, { rackId: rack.rackId, rowPos: newRow, colPos: newCol, facing: rack.facing || 'front' }];
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [room, racks]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (pendingPositions.length === 0) {
|
||||||
|
message.info('没有需要保存的更改');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await onSave(pendingPositions);
|
||||||
|
if (success) {
|
||||||
|
setPendingPositions([]);
|
||||||
|
}
|
||||||
|
}, [pendingPositions, onSave]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setPendingPositions([]);
|
||||||
|
onCancel();
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
const handleInitLayout = useCallback(async () => {
|
||||||
|
const values = await initForm.validateFields();
|
||||||
|
const success = await onInitLayout(values.gridRows, values.gridCols);
|
||||||
|
if (success) {
|
||||||
|
setInitModalVisible(false);
|
||||||
|
setPendingPositions([]);
|
||||||
|
}
|
||||||
|
}, [initForm, onInitLayout]);
|
||||||
|
|
||||||
|
if (!editMode) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Space
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 16,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: '#fff',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<ApartmentOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
initForm.setFieldsValue({
|
||||||
|
gridRows: room?.gridRows || 10,
|
||||||
|
gridCols: room?.gridCols || 10,
|
||||||
|
});
|
||||||
|
setInitModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
初始化布局
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{pendingPositions.length > 0 && (
|
||||||
|
<span style={{ color: '#faad14', fontSize: 12 }}>
|
||||||
|
{pendingPositions.length} 处待保存
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title="确定取消编辑?未保存的更改将丢失"
|
||||||
|
onConfirm={handleCancel}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="继续编辑"
|
||||||
|
>
|
||||||
|
<Button icon={<CloseOutlined />}>取消</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={pendingPositions.length === 0}
|
||||||
|
>
|
||||||
|
保存布局
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="初始化机房布局"
|
||||||
|
open={initModalVisible}
|
||||||
|
onOk={handleInitLayout}
|
||||||
|
onCancel={() => setInitModalVisible(false)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form form={initForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="gridRows"
|
||||||
|
label="行数(排数)"
|
||||||
|
rules={[{ required: true, message: '请输入行数' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="gridCols"
|
||||||
|
label="列数"
|
||||||
|
rules={[{ required: true, message: '请输入列数' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<p style={{ color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
|
||||||
|
初始化将按行列顺序自动排列所有机柜,已有位置信息将被覆盖。
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutEditor;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const PositionValidator = {
|
||||||
|
validate(rack, newRow, newCol, allRacks, gridRows, gridCols) {
|
||||||
|
if (newRow < 0 || newRow >= gridRows) {
|
||||||
|
return { valid: false, error: `行位置超出范围(0-${gridRows - 1})` };
|
||||||
|
}
|
||||||
|
if (newCol < 0 || newCol >= gridCols) {
|
||||||
|
return { valid: false, error: `列位置超出范围(0-${gridCols - 1})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflict = allRacks.find(
|
||||||
|
r => r.rowPos === newRow &&
|
||||||
|
r.colPos === newCol &&
|
||||||
|
r.rackId !== rack.rackId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conflict) {
|
||||||
|
return { valid: false, error: `位置已被机柜"${conflict.name}"占用` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
validateBatch(positions, allRacks, gridRows, gridCols) {
|
||||||
|
const errors = [];
|
||||||
|
const occupiedMap = new Map();
|
||||||
|
|
||||||
|
allRacks.forEach(r => {
|
||||||
|
if (r.rowPos != null && r.colPos != null) {
|
||||||
|
occupiedMap.set(`${r.rowPos}-${r.colPos}`, r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
positions.forEach((pos, index) => {
|
||||||
|
if (pos.rowPos < 0 || pos.rowPos >= gridRows) {
|
||||||
|
errors.push({ index, error: `行位置${pos.rowPos}超出范围` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pos.colPos < 0 || pos.colPos >= gridCols) {
|
||||||
|
errors.push({ index, error: `列位置${pos.colPos}超出范围` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${pos.rowPos}-${pos.colPos}`;
|
||||||
|
const existing = occupiedMap.get(key);
|
||||||
|
if (existing && existing.rackId !== pos.rackId) {
|
||||||
|
errors.push({ index, error: `位置(${pos.rowPos},${pos.colPos})已被"${existing.name}"占用` });
|
||||||
|
}
|
||||||
|
|
||||||
|
occupiedMap.set(key, { rackId: pos.rackId, name: pos.rackId });
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PositionValidator;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as LayoutEditor } from './LayoutEditor';
|
||||||
|
export { default as PositionValidator } from './PositionValidator';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { FloorPlanCanvas } from './canvas';
|
||||||
|
export { FloorPlanToolbar } from './toolbar';
|
||||||
|
export { RackDetailPanel, FloorPlanStats } from './panels';
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tag } from 'antd';
|
||||||
|
import {
|
||||||
|
DatabaseOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const FloorPlanStats = ({ stats }) => {
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<DatabaseOutlined style={{ color: '#1677ff', fontSize: 14 }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'rgba(0,0,0,0.85)' }}>
|
||||||
|
概览
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'rgba(0,0,0,0.45)' }}>
|
||||||
|
{stats.totalRacks} 台
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, marginBottom: 10 }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: '#1677ff', lineHeight: 1.2 }}>
|
||||||
|
{stats.activeRacks}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.65)', marginTop: 2 }}>
|
||||||
|
在用
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: '#faad14', lineHeight: 1.2 }}>
|
||||||
|
{stats.maintenanceRacks}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.65)', marginTop: 2 }}>
|
||||||
|
维护
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: '#bfbfbf', lineHeight: 1.2 }}>
|
||||||
|
{stats.inactiveRacks}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.65)', marginTop: 2 }}>
|
||||||
|
停用
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 8px',
|
||||||
|
}}>
|
||||||
|
<BulbOutlined style={{ fontSize: 12, color: '#1677ff' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{Math.round((stats.avgUtilization || 0) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
background: '#fff7e6',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 8px',
|
||||||
|
}}>
|
||||||
|
<ThunderboltOutlined style={{ fontSize: 12, color: '#faad14' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{stats.totalCurrentPower || 0}W
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloorPlanStats;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HEAT_MAP } from '../canvas/CanvasConstants';
|
||||||
|
|
||||||
|
const HeatMapLegend = ({ dimension }) => {
|
||||||
|
const labels = {
|
||||||
|
utilization: 'U位使用率',
|
||||||
|
power: '功率负载',
|
||||||
|
density: '设备密度',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 16,
|
||||||
|
right: 16,
|
||||||
|
background: 'rgba(255,255,255,0.92)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 12px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 4, fontWeight: 500, color: 'rgba(0,0,0,0.65)' }}>
|
||||||
|
{labels[dimension] || '热力图'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ color: 'rgba(0,0,0,0.45)' }}>低</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: `linear-gradient(to right, ${HEAT_MAP.LOW}, ${HEAT_MAP.MEDIUM}, ${HEAT_MAP.HIGH})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'rgba(0,0,0,0.45)' }}>高</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeatMapLegend;
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
MINI_MAP_SIZE,
|
||||||
|
MINI_MAP_PADDING,
|
||||||
|
ROW_LABEL_WIDTH,
|
||||||
|
COL_LABEL_HEIGHT,
|
||||||
|
CELL_SIZE,
|
||||||
|
CELL_GAP,
|
||||||
|
COLORS,
|
||||||
|
RACK_STATUS_COLORS,
|
||||||
|
} from '../canvas/CanvasConstants';
|
||||||
|
|
||||||
|
const MiniMap = ({ room, racks, zoom, offsetX, offsetY, canvasWidth, canvasHeight, onNavigate }) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || !room) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
canvas.width = MINI_MAP_SIZE * dpr;
|
||||||
|
canvas.height = MINI_MAP_SIZE * dpr;
|
||||||
|
canvas.style.width = `${MINI_MAP_SIZE}px`;
|
||||||
|
canvas.style.height = `${MINI_MAP_SIZE}px`;
|
||||||
|
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.clearRect(0, 0, MINI_MAP_SIZE, MINI_MAP_SIZE);
|
||||||
|
|
||||||
|
const rows = room.gridRows || 10;
|
||||||
|
const cols = room.gridCols || 10;
|
||||||
|
const gridSize = {
|
||||||
|
width: ROW_LABEL_WIDTH + cols * (CELL_SIZE + CELL_GAP),
|
||||||
|
height: COL_LABEL_HEIGHT + rows * (CELL_SIZE + CELL_GAP),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scaleX = (MINI_MAP_SIZE - MINI_MAP_PADDING * 2) / gridSize.width;
|
||||||
|
const scaleY = (MINI_MAP_SIZE - MINI_MAP_PADDING * 2) / gridSize.height;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
const mapOffsetX = (MINI_MAP_SIZE - gridSize.width * scale) / 2;
|
||||||
|
const mapOffsetY = (MINI_MAP_SIZE - gridSize.height * scale) / 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f5f5f5';
|
||||||
|
ctx.fillRect(0, 0, MINI_MAP_SIZE, MINI_MAP_SIZE);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(mapOffsetX, mapOffsetY);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
ctx.strokeStyle = COLORS.GRID_LINE;
|
||||||
|
ctx.lineWidth = 1 / scale;
|
||||||
|
ctx.strokeRect(ROW_LABEL_WIDTH, COL_LABEL_HEIGHT, cols * (CELL_SIZE + CELL_GAP) - CELL_GAP, rows * (CELL_SIZE + CELL_GAP) - CELL_GAP);
|
||||||
|
|
||||||
|
const rackMap = new Map();
|
||||||
|
(racks || []).forEach(r => {
|
||||||
|
if (r.rowPos != null && r.colPos != null) {
|
||||||
|
rackMap.set(`${r.rowPos}-${r.colPos}`, r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const x = ROW_LABEL_WIDTH + c * (CELL_SIZE + CELL_GAP);
|
||||||
|
const y = COL_LABEL_HEIGHT + r * (CELL_SIZE + CELL_GAP);
|
||||||
|
const rack = rackMap.get(`${r}-${c}`);
|
||||||
|
|
||||||
|
if (rack) {
|
||||||
|
ctx.fillStyle = RACK_STATUS_COLORS[rack.status] || RACK_STATUS_COLORS.active;
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
ctx.fillRect(x, y, CELL_SIZE, CELL_SIZE);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = '#e8e8e8';
|
||||||
|
ctx.fillRect(x, y, CELL_SIZE, CELL_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
if (canvasWidth && canvasHeight && zoom) {
|
||||||
|
const viewLeft = -offsetX / zoom;
|
||||||
|
const viewTop = -offsetY / zoom;
|
||||||
|
const viewWidth = canvasWidth / zoom;
|
||||||
|
const viewHeight = canvasHeight / zoom;
|
||||||
|
|
||||||
|
const vpX = viewLeft * scale + mapOffsetX;
|
||||||
|
const vpY = viewTop * scale + mapOffsetY;
|
||||||
|
const vpW = viewWidth * scale;
|
||||||
|
const vpH = viewHeight * scale;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#1677ff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.strokeRect(vpX, vpY, vpW, vpH);
|
||||||
|
ctx.fillStyle = 'rgba(22,119,255,0.05)';
|
||||||
|
ctx.fillRect(vpX, vpY, vpW, vpH);
|
||||||
|
}
|
||||||
|
}, [room, racks, zoom, offsetX, offsetY, canvasWidth, canvasHeight]);
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
if (!onNavigate || !room) return;
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const clickY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const rows = room.gridRows || 10;
|
||||||
|
const cols = room.gridCols || 10;
|
||||||
|
const gridSize = {
|
||||||
|
width: ROW_LABEL_WIDTH + cols * (CELL_SIZE + CELL_GAP),
|
||||||
|
height: COL_LABEL_HEIGHT + rows * (CELL_SIZE + CELL_GAP),
|
||||||
|
};
|
||||||
|
const scaleX = (MINI_MAP_SIZE - MINI_MAP_PADDING * 2) / gridSize.width;
|
||||||
|
const scaleY = (MINI_MAP_SIZE - MINI_MAP_PADDING * 2) / gridSize.height;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
const mapOffsetX = (MINI_MAP_SIZE - gridSize.width * scale) / 2;
|
||||||
|
const mapOffsetY = (MINI_MAP_SIZE - gridSize.height * scale) / 2;
|
||||||
|
|
||||||
|
const gridX = (clickX - mapOffsetX) / scale;
|
||||||
|
const gridY = (clickY - mapOffsetY) / scale;
|
||||||
|
|
||||||
|
onNavigate({
|
||||||
|
targetOffsetX: -(gridX * zoom - canvasWidth / 2),
|
||||||
|
targetOffsetY: -(gridY * zoom - canvasHeight / 2),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
width: MINI_MAP_SIZE,
|
||||||
|
height: MINI_MAP_SIZE,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MiniMap;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Drawer, Descriptions, Tag, Progress, Button, Space } from 'antd';
|
||||||
|
import {
|
||||||
|
CloudServerOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { RACK_STATUS_COLORS } from '../canvas/CanvasConstants';
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
active: { text: '在用', color: 'blue' },
|
||||||
|
maintenance: { text: '维护中', color: 'orange' },
|
||||||
|
inactive: { text: '停用', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RackDetailPanel = ({ rack, visible, onClose }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!rack) return null;
|
||||||
|
|
||||||
|
const statusInfo = statusMap[rack.status] || { text: rack.status, color: 'default' };
|
||||||
|
const utilization = rack.utilization != null ? Math.round(rack.utilization * 100) : 0;
|
||||||
|
const powerPercent = rack.maxPower > 0
|
||||||
|
? Math.round(((rack.currentPower || 0) / rack.maxPower) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const handleView3D = () => {
|
||||||
|
navigate('/visualization-3d', { state: { rackId: rack.rackId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDevices = () => {
|
||||||
|
navigate('/devices', { state: { rackId: rack.rackId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<CloudServerOutlined style={{ color: RACK_STATUS_COLORS[rack.status] || '#1677ff' }} />
|
||||||
|
{rack.name}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
width={360}
|
||||||
|
open={visible}
|
||||||
|
onClose={onClose}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={handleView3D}
|
||||||
|
>
|
||||||
|
3D视图
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={handleViewDevices}
|
||||||
|
>
|
||||||
|
设备
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="机柜ID">{rack.rackId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={statusInfo.color}>{statusInfo.text}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="位置">
|
||||||
|
{rack.rowPos != null && rack.colPos != null
|
||||||
|
? `第${rack.rowPos + 1}排 第${rack.colPos + 1}列`
|
||||||
|
: '未分配'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="朝向">{rack.facing || 'front'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="高度">{rack.height}U</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="设备数量">{rack.deviceCount || 0} 台</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>U位使用率</div>
|
||||||
|
<Progress
|
||||||
|
percent={utilization}
|
||||||
|
strokeColor={
|
||||||
|
utilization < 30 ? '#52c41a' :
|
||||||
|
utilization < 70 ? '#faad14' : '#ff4d4f'
|
||||||
|
}
|
||||||
|
format={() => `${rack.usedU || 0}/${rack.totalU || rack.height || 0}U`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>
|
||||||
|
<ThunderboltOutlined /> 功率
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={powerPercent}
|
||||||
|
strokeColor={
|
||||||
|
powerPercent < 50 ? '#52c41a' :
|
||||||
|
powerPercent < 80 ? '#faad14' : '#ff4d4f'
|
||||||
|
}
|
||||||
|
format={() => `${rack.currentPower || 0}/${rack.maxPower || 0}W`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RackDetailPanel;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as RackDetailPanel } from './RackDetailPanel';
|
||||||
|
export { default as FloorPlanStats } from './FloorPlanStats';
|
||||||
|
export { default as HeatMapLegend } from './HeatMapLegend';
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Space, Tooltip } from 'antd';
|
||||||
|
import { ReloadOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
|
||||||
|
import RoomSelector from './RoomSelector';
|
||||||
|
import ZoomControls from './ZoomControls';
|
||||||
|
|
||||||
|
const FloorPlanToolbar = ({
|
||||||
|
selectedRoomId,
|
||||||
|
onRoomChange,
|
||||||
|
zoom,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onZoomReset,
|
||||||
|
onRefresh,
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#ffffff',
|
||||||
|
borderBottom: '1px solid rgba(0,0,0,0.05)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.03)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={14} align="center">
|
||||||
|
<RoomSelector
|
||||||
|
selectedRoomId={selectedRoomId}
|
||||||
|
onRoomChange={onRoomChange}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
width: 1,
|
||||||
|
height: 28,
|
||||||
|
background: '#e8e8e8',
|
||||||
|
}} />
|
||||||
|
<ZoomControls
|
||||||
|
zoom={zoom}
|
||||||
|
onZoomIn={onZoomIn}
|
||||||
|
onZoomOut={onZoomOut}
|
||||||
|
onZoomReset={onZoomReset}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space size={10} align="center">
|
||||||
|
<Tooltip title={isFullscreen ? '退出全屏' : '全屏查看'} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#595959',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.background = '#f8f9fa';
|
||||||
|
e.target.style.borderColor = '#d9d9d9';
|
||||||
|
e.target.style.color = '#262626';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = '#fff';
|
||||||
|
e.target.style.borderColor = '#e8e8e8';
|
||||||
|
e.target.style.color = '#595959';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip title="刷新数据" placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#595959',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.background = '#f8f9fa';
|
||||||
|
e.target.style.borderColor = '#d9d9d9';
|
||||||
|
e.target.style.color = '#262626';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = '#fff';
|
||||||
|
e.target.style.borderColor = '#e8e8e8';
|
||||||
|
e.target.style.color = '#595959';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloorPlanToolbar;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Select, Spin } from 'antd';
|
||||||
|
import { HomeOutlined } from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const RoomSelector = ({ selectedRoomId, onRoomChange }) => {
|
||||||
|
const [rooms, setRooms] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchRooms = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/rooms', { params: { pageSize: 1000 } });
|
||||||
|
setRooms(response.data.rooms || []);
|
||||||
|
if (!selectedRoomId && response.data.rooms?.length > 0) {
|
||||||
|
onRoomChange(response.data.rooms[0].roomId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取机房列表失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedRoomId, onRoomChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRooms();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={selectedRoomId}
|
||||||
|
onChange={onRoomChange}
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
placeholder="选择机房"
|
||||||
|
suffixIcon={loading ? <Spin size="small" /> : <HomeOutlined />}
|
||||||
|
options={rooms.map(r => ({
|
||||||
|
value: r.roomId,
|
||||||
|
label: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#1677ff',
|
||||||
|
}} />
|
||||||
|
{r.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
notFoundContent={loading ? <Spin size="small" /> : '暂无机房'}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoomSelector;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const SearchBar = ({ racks, onLocateRack }) => {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
|
||||||
|
const handleSearch = useCallback((value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setKeyword('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = racks?.find(
|
||||||
|
r => r.name?.toLowerCase().includes(trimmed.toLowerCase()) ||
|
||||||
|
r.rackId?.toLowerCase().includes(trimmed.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
onLocateRack(matched);
|
||||||
|
}
|
||||||
|
setKeyword(trimmed);
|
||||||
|
}, [racks, onLocateRack]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Search
|
||||||
|
size="small"
|
||||||
|
placeholder="搜索机柜..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchBar;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Radio, Select, Space } from 'antd';
|
||||||
|
import { AppstoreOutlined, FireOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const ViewModeSwitch = ({ viewMode, heatMapDimension, editMode, onViewModeChange, onEditModeChange }) => {
|
||||||
|
return (
|
||||||
|
<Space size={8}>
|
||||||
|
<Radio.Group
|
||||||
|
value={editMode ? 'edit' : viewMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === 'edit') {
|
||||||
|
onEditModeChange(true);
|
||||||
|
onViewModeChange('standard');
|
||||||
|
} else {
|
||||||
|
onEditModeChange(false);
|
||||||
|
onViewModeChange(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Radio.Button value="standard">
|
||||||
|
<AppstoreOutlined /> 标准
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value="heatmap">
|
||||||
|
<FireOutlined /> 热力图
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value="edit">
|
||||||
|
<EditOutlined /> 编辑
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{viewMode === 'heatmap' && !editMode && (
|
||||||
|
<Select
|
||||||
|
value={heatMapDimension}
|
||||||
|
onChange={(val) => onViewModeChange('heatmap', val)}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'utilization', label: 'U位使用率' },
|
||||||
|
{ value: 'power', label: '功率负载' },
|
||||||
|
{ value: 'density', label: '设备密度' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ViewModeSwitch;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Space, Tooltip } from 'antd';
|
||||||
|
import { ZoomInOutlined, ZoomOutOutlined, OneToOneOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const ZoomControls = ({ zoom, onZoomIn, onZoomOut, onZoomReset }) => {
|
||||||
|
const percent = Math.round(zoom * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space size={6} align="center" style={{ background: '#f8f9fa', padding: '6px 10px', borderRadius: 8 }}>
|
||||||
|
<Tooltip title="缩小" placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={onZoomOut}
|
||||||
|
disabled={zoom <= 0.3}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: zoom <= 0.3 ? '#f5f5f5' : '#fff',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: zoom <= 0.3 ? 'not-allowed' : 'pointer',
|
||||||
|
color: zoom <= 0.3 ? '#d9d9d9' : '#595959',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (zoom > 0.3) {
|
||||||
|
e.target.style.background = '#e6f7ff';
|
||||||
|
e.target.style.borderColor = '#91d5ff';
|
||||||
|
e.target.style.color = '#1890ff';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = zoom <= 0.3 ? '#f5f5f5' : '#fff';
|
||||||
|
e.target.style.borderColor = '#e8e8e8';
|
||||||
|
e.target.style.color = zoom <= 0.3 ? '#d9d9d9' : '#595959';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ZoomOutOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
minWidth: 52,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#595959',
|
||||||
|
userSelect: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '0 4px',
|
||||||
|
}}>
|
||||||
|
{percent}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip title="放大" placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={onZoomIn}
|
||||||
|
disabled={zoom >= 2}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: zoom >= 2 ? '#f5f5f5' : '#fff',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: zoom >= 2 ? 'not-allowed' : 'pointer',
|
||||||
|
color: zoom >= 2 ? '#d9d9d9' : '#595959',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (zoom < 2) {
|
||||||
|
e.target.style.background = '#e6f7ff';
|
||||||
|
e.target.style.borderColor = '#91d5ff';
|
||||||
|
e.target.style.color = '#1890ff';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = zoom >= 2 ? '#f5f5f5' : '#fff';
|
||||||
|
e.target.style.borderColor = '#e8e8e8';
|
||||||
|
e.target.style.color = zoom >= 2 ? '#d9d9d9' : '#595959';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ZoomInOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="重置缩放" placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={onZoomReset}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#595959',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.background = '#e6f7ff';
|
||||||
|
e.target.style.borderColor = '#91d5ff';
|
||||||
|
e.target.style.color = '#1890ff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = '#fff';
|
||||||
|
e.target.style.borderColor = '#e8e8e8';
|
||||||
|
e.target.style.color = '#595959';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OneToOneOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoomControls;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as FloorPlanToolbar } from './FloorPlanToolbar';
|
||||||
|
export { default as RoomSelector } from './RoomSelector';
|
||||||
|
export { default as ZoomControls } from './ZoomControls';
|
||||||
|
export { default as ViewModeSwitch } from './ViewModeSwitch';
|
||||||
|
export { default as SearchBar } from './SearchBar';
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { createContext, useReducer, useCallback } from 'react';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
selectedRoomId: null,
|
||||||
|
selectedRack: null,
|
||||||
|
hoveredRack: null,
|
||||||
|
viewMode: 'standard',
|
||||||
|
heatMapDimension: 'utilization',
|
||||||
|
editMode: false,
|
||||||
|
zoom: 1,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
searchRackId: null,
|
||||||
|
detailRack: null,
|
||||||
|
detailVisible: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
SET_SELECTED_ROOM: 'SET_SELECTED_ROOM',
|
||||||
|
SET_SELECTED_RACK: 'SET_SELECTED_RACK',
|
||||||
|
SET_HOVERED_RACK: 'SET_HOVERED_RACK',
|
||||||
|
SET_VIEW_MODE: 'SET_VIEW_MODE',
|
||||||
|
SET_EDIT_MODE: 'SET_EDIT_MODE',
|
||||||
|
SET_VIEW_CHANGE: 'SET_VIEW_CHANGE',
|
||||||
|
SET_SEARCH_RACK: 'SET_SEARCH_RACK',
|
||||||
|
SHOW_DETAIL: 'SHOW_DETAIL',
|
||||||
|
HIDE_DETAIL: 'HIDE_DETAIL',
|
||||||
|
RESET: 'RESET',
|
||||||
|
};
|
||||||
|
|
||||||
|
function floorPlanReducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case actionTypes.SET_SELECTED_ROOM:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedRoomId: action.payload,
|
||||||
|
selectedRack: null,
|
||||||
|
hoveredRack: null,
|
||||||
|
searchRackId: null,
|
||||||
|
detailRack: null,
|
||||||
|
detailVisible: false,
|
||||||
|
};
|
||||||
|
case actionTypes.SET_SELECTED_RACK:
|
||||||
|
return { ...state, selectedRack: action.payload };
|
||||||
|
case actionTypes.SET_HOVERED_RACK:
|
||||||
|
return { ...state, hoveredRack: action.payload };
|
||||||
|
case actionTypes.SET_VIEW_MODE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
viewMode: action.payload.mode,
|
||||||
|
heatMapDimension: action.payload.dimension || state.heatMapDimension,
|
||||||
|
};
|
||||||
|
case actionTypes.SET_EDIT_MODE:
|
||||||
|
return { ...state, editMode: action.payload };
|
||||||
|
case actionTypes.SET_VIEW_CHANGE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
zoom: action.payload.zoom,
|
||||||
|
offsetX: action.payload.offsetX,
|
||||||
|
offsetY: action.payload.offsetY,
|
||||||
|
};
|
||||||
|
case actionTypes.SET_SEARCH_RACK:
|
||||||
|
return { ...state, searchRackId: action.payload };
|
||||||
|
case actionTypes.SHOW_DETAIL:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detailRack: action.payload,
|
||||||
|
detailVisible: true,
|
||||||
|
};
|
||||||
|
case actionTypes.HIDE_DETAIL:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detailRack: null,
|
||||||
|
detailVisible: false,
|
||||||
|
};
|
||||||
|
case actionTypes.RESET:
|
||||||
|
return { ...initialState };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloorPlanContext = createContext(null);
|
||||||
|
|
||||||
|
export const FloorPlanProvider = ({ children }) => {
|
||||||
|
const [state, dispatch] = useReducer(floorPlanReducer, initialState);
|
||||||
|
|
||||||
|
const setSelectedRoom = useCallback((roomId) => {
|
||||||
|
dispatch({ type: actionTypes.SET_SELECTED_ROOM, payload: roomId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSelectedRack = useCallback((rack) => {
|
||||||
|
dispatch({ type: actionTypes.SET_SELECTED_RACK, payload: rack });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setHoveredRack = useCallback((rack) => {
|
||||||
|
dispatch({ type: actionTypes.SET_HOVERED_RACK, payload: rack });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setViewMode = useCallback((mode, dimension) => {
|
||||||
|
dispatch({ type: actionTypes.SET_VIEW_MODE, payload: { mode, dimension } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEditMode = useCallback((enabled) => {
|
||||||
|
dispatch({ type: actionTypes.SET_EDIT_MODE, payload: enabled });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setViewChange = useCallback((viewState) => {
|
||||||
|
dispatch({ type: actionTypes.SET_VIEW_CHANGE, payload: viewState });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSearchRack = useCallback((rackId) => {
|
||||||
|
dispatch({ type: actionTypes.SET_SEARCH_RACK, payload: rackId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showDetail = useCallback((rack) => {
|
||||||
|
dispatch({ type: actionTypes.SHOW_DETAIL, payload: rack });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hideDetail = useCallback(() => {
|
||||||
|
dispatch({ type: actionTypes.HIDE_DETAIL });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
...state,
|
||||||
|
setSelectedRoom,
|
||||||
|
setSelectedRack,
|
||||||
|
setHoveredRack,
|
||||||
|
setViewMode,
|
||||||
|
setEditMode,
|
||||||
|
setViewChange,
|
||||||
|
setSearchRack,
|
||||||
|
showDetail,
|
||||||
|
hideDetail,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloorPlanContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</FloorPlanContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloorPlanContext;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import FloorPlanContext from '../../context/FloorPlanContext';
|
||||||
|
|
||||||
|
const useFloorPlanContext = () => {
|
||||||
|
const context = useContext(FloorPlanContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useFloorPlanContext 必须在 FloorPlanProvider 内使用');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFloorPlanContext;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const useFloorPlanData = (roomId) => {
|
||||||
|
const [layoutData, setLayoutData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const fetchLayout = useCallback(async () => {
|
||||||
|
if (!roomId) {
|
||||||
|
setLayoutData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/rooms/${roomId}/layout`);
|
||||||
|
setLayoutData(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || err.message);
|
||||||
|
setLayoutData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLayout();
|
||||||
|
}, [fetchLayout]);
|
||||||
|
|
||||||
|
const updateRackPosition = useCallback(async (rackId, rowPos, colPos, facing) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/racks/${rackId}/position`, { rowPos, colPos, facing });
|
||||||
|
await fetchLayout();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [fetchLayout]);
|
||||||
|
|
||||||
|
const batchUpdatePositions = useCallback(async (positions) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/rooms/${roomId}/racks-positions`, { positions });
|
||||||
|
await fetchLayout();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [roomId, fetchLayout]);
|
||||||
|
|
||||||
|
const updateLayout = useCallback(async (gridRows, gridCols, layoutConfig) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/rooms/${roomId}/layout`, { gridRows, gridCols, layoutConfig });
|
||||||
|
await fetchLayout();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [roomId, fetchLayout]);
|
||||||
|
|
||||||
|
const initLayout = useCallback(async (gridRows, gridCols, layoutConfig) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/rooms/${roomId}/init-layout`, { gridRows, gridCols, layoutConfig });
|
||||||
|
await fetchLayout();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [roomId, fetchLayout]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layoutData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchLayout,
|
||||||
|
updateRackPosition,
|
||||||
|
batchUpdatePositions,
|
||||||
|
updateLayout,
|
||||||
|
initLayout,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFloorPlanData;
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import React, { useRef, useCallback, useState, useEffect } from 'react';
|
||||||
|
import { Spin, Empty, message, Tag, Divider, List } from 'antd';
|
||||||
|
import { SwapOutlined, WarningOutlined } from '@ant-design/icons';
|
||||||
|
import { FloorPlanProvider } from '../context/FloorPlanContext';
|
||||||
|
import useFloorPlanContext from '../hooks/floorplan/useFloorPlanContext';
|
||||||
|
import useFloorPlanData from '../hooks/floorplan/useFloorPlanData';
|
||||||
|
import { FloorPlanCanvas, FloorPlanToolbar, RackDetailPanel, FloorPlanStats } from '../components/floorplan';
|
||||||
|
|
||||||
|
const DEVICE_TYPE_NAMES = {
|
||||||
|
server: '服务器',
|
||||||
|
switch: '交换机',
|
||||||
|
router: '路由器',
|
||||||
|
firewall: '防火墙',
|
||||||
|
storage: '存储设备',
|
||||||
|
other: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_STATUS_NAMES = {
|
||||||
|
running: '运行中',
|
||||||
|
maintenance: '维护中',
|
||||||
|
offline: '离线',
|
||||||
|
fault: '故障',
|
||||||
|
idle: '空闲',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeviceTooltip = ({ device, rack, x, y }) => {
|
||||||
|
if (!device) return null;
|
||||||
|
|
||||||
|
const typeColor = {
|
||||||
|
server: '#1677ff',
|
||||||
|
switch: '#52c41a',
|
||||||
|
router: '#faad14',
|
||||||
|
firewall: '#ff4d4f',
|
||||||
|
storage: '#722ed1',
|
||||||
|
other: '#8c8c8c',
|
||||||
|
}[device.type] || '#8c8c8c';
|
||||||
|
|
||||||
|
const statusColor = {
|
||||||
|
running: '#52c41a',
|
||||||
|
maintenance: '#faad14',
|
||||||
|
offline: '#8c8c8c',
|
||||||
|
fault: '#ff4d4f',
|
||||||
|
idle: '#d9d9d9',
|
||||||
|
}[device.status] || '#8c8c8c';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: x + 18,
|
||||||
|
top: y + 18,
|
||||||
|
background: '#ffffff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||||
|
padding: '14px 18px',
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: 220,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||||
|
<div style={{ width: 5, height: 20, background: typeColor, borderRadius: 3 }} />
|
||||||
|
<strong style={{ fontSize: 15, color: '#111827' }}>{device.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#4b5563', lineHeight: '22px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>类型:</span>
|
||||||
|
<span style={{ fontWeight: 500 }}>{DEVICE_TYPE_NAMES[device.type] || device.type}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>状态:</span>
|
||||||
|
<span style={{ color: statusColor, fontWeight: 500 }}>
|
||||||
|
{DEVICE_STATUS_NAMES[device.status] || device.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>位置:</span>
|
||||||
|
<span style={{ fontWeight: 500 }}>{rack?.name} - {device.position}U</span>
|
||||||
|
</div>
|
||||||
|
{device.ipAddress && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>IP:</span>
|
||||||
|
<span style={{ fontFamily: 'SFMono-Regular,Monaco,Consolas', fontWeight: 500 }}>
|
||||||
|
{device.ipAddress}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{device.model && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>型号:</span>
|
||||||
|
<span style={{ fontWeight: 500 }}>{device.model}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{device.height > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>高度:</span>
|
||||||
|
<span style={{ fontWeight: 500 }}>{device.height}U</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UnassignedRacksPanel = ({ racks, onAutoAssign }) => {
|
||||||
|
const unassigned = (racks || []).filter(r => r.rowPos == null || r.colPos == null);
|
||||||
|
|
||||||
|
if (unassigned.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 12,
|
||||||
|
border: '1px solid #ffe58f',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fffbe6',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<WarningOutlined style={{ color: '#faad14', fontSize: 14 }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#ad6800' }}>
|
||||||
|
未分配
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10,
|
||||||
|
background: '#ffd666',
|
||||||
|
color: '#874d00',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{unassigned.length}台
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{unassigned.slice(0, 6).map((r, i) => (
|
||||||
|
<span
|
||||||
|
key={r.rackId || i}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
background: '#fff',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid #ffe58f',
|
||||||
|
color: 'rgba(0,0,0,0.65)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{unassigned.length > 6 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'rgba(0,0,0,0.45)',
|
||||||
|
padding: '3px 4px',
|
||||||
|
}}>
|
||||||
|
+{unassigned.length - 6}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onAutoAssign}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '6px 0',
|
||||||
|
background: '#faad14',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.background = '#d48806'}
|
||||||
|
onMouseLeave={(e) => e.target.style.background = '#faad14'}
|
||||||
|
>
|
||||||
|
<SwapOutlined />
|
||||||
|
一键分配
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FloorPlanContent = () => {
|
||||||
|
const {
|
||||||
|
selectedRoomId,
|
||||||
|
setSelectedRoom,
|
||||||
|
zoom,
|
||||||
|
editMode,
|
||||||
|
detailRack,
|
||||||
|
detailVisible,
|
||||||
|
showDetail,
|
||||||
|
hideDetail,
|
||||||
|
} = useFloorPlanContext();
|
||||||
|
|
||||||
|
const { layoutData, loading, error, refetch, updateRackPosition, batchUpdatePositions, initLayout } = useFloorPlanData(selectedRoomId);
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [currentZoom, setCurrentZoom] = useState(1);
|
||||||
|
const [hoveredDevice, setHoveredDevice] = useState(null);
|
||||||
|
const [hoveredDeviceRack, setHoveredDeviceRack] = useState(null);
|
||||||
|
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
message.error(error);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
// 全屏变化监听
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('msfullscreenchange', handleFullscreenChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||||
|
document.removeEventListener('msfullscreenchange', handleFullscreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleFullscreen = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
const elem = containerRef.current;
|
||||||
|
if (elem.requestFullscreen) {
|
||||||
|
elem.requestFullscreen();
|
||||||
|
} else if (elem.webkitRequestFullscreen) {
|
||||||
|
elem.webkitRequestFullscreen();
|
||||||
|
} else if (elem.msRequestFullscreen) {
|
||||||
|
elem.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else if (document.webkitExitFullscreen) {
|
||||||
|
document.webkitExitFullscreen();
|
||||||
|
} else if (document.msExitFullscreen) {
|
||||||
|
document.msExitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRackClick = useCallback((rack) => {
|
||||||
|
if (rack) {
|
||||||
|
showDetail(rack);
|
||||||
|
}
|
||||||
|
}, [showDetail]);
|
||||||
|
|
||||||
|
const handleRackDoubleClick = useCallback((rack) => {
|
||||||
|
if (rack) {
|
||||||
|
showDetail(rack);
|
||||||
|
}
|
||||||
|
}, [showDetail]);
|
||||||
|
|
||||||
|
const handleRackHover = useCallback(() => {}, []);
|
||||||
|
|
||||||
|
const handleDeviceHover = useCallback((device, rack, x, y) => {
|
||||||
|
if (device) {
|
||||||
|
setHoveredDevice(device);
|
||||||
|
setHoveredDeviceRack(rack);
|
||||||
|
setTooltipPosition({ x, y });
|
||||||
|
} else {
|
||||||
|
setHoveredDevice(null);
|
||||||
|
setHoveredDeviceRack(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewChange = useCallback((viewState) => {
|
||||||
|
setCurrentZoom(viewState.zoom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLocateRack = useCallback((rack) => {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
canvasRef.current.animateToRack(rack);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAutoAssign = useCallback(async () => {
|
||||||
|
if (!layoutData) return;
|
||||||
|
const racks = layoutData.racks || [];
|
||||||
|
const unassigned = racks.filter(r => r.rowPos == null || r.colPos == null);
|
||||||
|
if (unassigned.length === 0) {
|
||||||
|
message.info('没有需要分配的机柜');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridCols = layoutData.room?.gridCols || 10;
|
||||||
|
const gridRows = layoutData.room?.gridRows || 10;
|
||||||
|
const occupied = new Set();
|
||||||
|
|
||||||
|
racks.forEach(r => {
|
||||||
|
if (r.rowPos != null && r.colPos != null) {
|
||||||
|
occupied.add(`${r.rowPos}-${r.colPos}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const positions = [];
|
||||||
|
let unassignedIndex = 0;
|
||||||
|
for (let col = 0; col < gridCols && unassignedIndex < unassigned.length; col++) {
|
||||||
|
for (let row = 0; row < gridRows && unassignedIndex < unassigned.length; row++) {
|
||||||
|
if (!occupied.has(`${row}-${col}`)) {
|
||||||
|
positions.push({
|
||||||
|
rackId: unassigned[unassignedIndex].rackId,
|
||||||
|
rowPos: row,
|
||||||
|
colPos: col,
|
||||||
|
facing: 'front'
|
||||||
|
});
|
||||||
|
unassignedIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positions.length === 0) {
|
||||||
|
message.warning('没有足够的空位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await batchUpdatePositions(positions);
|
||||||
|
if (success) {
|
||||||
|
message.success(`已分配 ${positions.length} 台机柜`);
|
||||||
|
}
|
||||||
|
}, [layoutData, batchUpdatePositions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<FloorPlanToolbar
|
||||||
|
selectedRoomId={selectedRoomId}
|
||||||
|
onRoomChange={setSelectedRoom}
|
||||||
|
zoom={currentZoom}
|
||||||
|
onZoomIn={() => canvasRef.current?.zoomIn()}
|
||||||
|
onZoomOut={() => canvasRef.current?.zoomOut()}
|
||||||
|
onZoomReset={() => canvasRef.current?.zoomReset()}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={handleToggleFullscreen}
|
||||||
|
onRefresh={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(255,255,255,0.7)',
|
||||||
|
zIndex: 5,
|
||||||
|
}}>
|
||||||
|
<Spin tip="加载中..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedRoomId && (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Empty description="请选择一个机房查看平面图" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRoomId && layoutData && (
|
||||||
|
<FloorPlanCanvas
|
||||||
|
ref={canvasRef}
|
||||||
|
room={layoutData.room}
|
||||||
|
racks={layoutData.racks}
|
||||||
|
viewMode="standard"
|
||||||
|
heatMapDimension="utilization"
|
||||||
|
editMode={false}
|
||||||
|
onRackClick={handleRackClick}
|
||||||
|
onRackDoubleClick={handleRackDoubleClick}
|
||||||
|
onRackHover={handleRackHover}
|
||||||
|
onDeviceHover={handleDeviceHover}
|
||||||
|
onRackDragEnd={() => {}}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DeviceTooltip
|
||||||
|
device={hoveredDevice}
|
||||||
|
rack={hoveredDeviceRack}
|
||||||
|
x={tooltipPosition.x}
|
||||||
|
y={tooltipPosition.y}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RackDetailPanel
|
||||||
|
rack={detailRack}
|
||||||
|
visible={detailVisible}
|
||||||
|
onClose={hideDetail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoomFloorPlan = () => {
|
||||||
|
return (
|
||||||
|
<FloorPlanProvider>
|
||||||
|
<div style={{ height: 'calc(100vh - 64px)' }}>
|
||||||
|
<FloorPlanContent />
|
||||||
|
</div>
|
||||||
|
</FloorPlanProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoomFloorPlan;
|
||||||
Reference in New Issue
Block a user