From 979c28fdcd8279f2989bb75870475007367336ca Mon Sep 17 00:00:00 2001
From: zhang1106 <849185023@qq.com>
Date: Thu, 30 Apr 2026 16:26:39 +0800
Subject: [PATCH] =?UTF-8?q?feat(floorplan):=20=E6=96=B0=E5=A2=9E=E6=9C=BA?=
=?UTF-8?q?=E6=88=BF=E5=B9=B3=E9=9D=A2=E5=9B=BE=E5=8A=9F=E8=83=BD=E6=A8=A1?=
=?UTF-8?q?=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
refactor(models): 为Room和Rack模型添加布局相关字段
feat(routes): 实现机房平面图相关API接口
feat(components): 添加平面图编辑器、画布渲染及交互组件
feat(hooks): 实现平面图数据管理和状态管理
feat(pages): 新增机房平面图页面入口
style(components): 优化平面图组件样式和交互体验
---
backend/models/Rack.js | 14 +-
backend/models/Room.js | 12 +
backend/models/index.js | 26 +
backend/routes/devices.js | 21 +-
backend/routes/racks.js | 36 ++
backend/routes/rooms.js | 172 +++++-
backend/scripts/migrate-all.js | 38 ++
frontend/src/App.jsx | 8 +
frontend/src/api/index.js | 16 +
.../src/components/device/ImportModal.jsx | 51 +-
.../floorplan/canvas/CanvasConstants.js | 87 +++
.../floorplan/canvas/CanvasInteraction.js | 355 ++++++++++++
.../floorplan/canvas/CanvasRenderer.js | 507 ++++++++++++++++++
.../floorplan/canvas/FloorPlanCanvas.jsx | 117 ++++
.../src/components/floorplan/canvas/index.js | 4 +
.../floorplan/editor/LayoutEditor.jsx | 149 +++++
.../floorplan/editor/PositionValidator.js | 56 ++
.../src/components/floorplan/editor/index.js | 2 +
frontend/src/components/floorplan/index.js | 3 +
.../floorplan/panels/FloorPlanStats.jsx | 104 ++++
.../floorplan/panels/HeatMapLegend.jsx | 43 ++
.../components/floorplan/panels/MiniMap.jsx | 145 +++++
.../floorplan/panels/RackDetailPanel.jsx | 112 ++++
.../src/components/floorplan/panels/index.js | 3 +
.../floorplan/toolbar/FloorPlanToolbar.jsx | 121 +++++
.../floorplan/toolbar/RoomSelector.jsx | 56 ++
.../floorplan/toolbar/SearchBar.jsx | 40 ++
.../floorplan/toolbar/ViewModeSwitch.jsx | 52 ++
.../floorplan/toolbar/ZoomControls.jsx | 127 +++++
.../src/components/floorplan/toolbar/index.js | 5 +
frontend/src/context/FloorPlanContext.jsx | 144 +++++
.../hooks/floorplan/useFloorPlanContext.js | 12 +
.../src/hooks/floorplan/useFloorPlanData.js | 89 +++
frontend/src/pages/RoomFloorPlan.jsx | 443 +++++++++++++++
34 files changed, 3108 insertions(+), 62 deletions(-)
create mode 100644 backend/models/index.js
create mode 100644 frontend/src/components/floorplan/canvas/CanvasConstants.js
create mode 100644 frontend/src/components/floorplan/canvas/CanvasInteraction.js
create mode 100644 frontend/src/components/floorplan/canvas/CanvasRenderer.js
create mode 100644 frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx
create mode 100644 frontend/src/components/floorplan/canvas/index.js
create mode 100644 frontend/src/components/floorplan/editor/LayoutEditor.jsx
create mode 100644 frontend/src/components/floorplan/editor/PositionValidator.js
create mode 100644 frontend/src/components/floorplan/editor/index.js
create mode 100644 frontend/src/components/floorplan/index.js
create mode 100644 frontend/src/components/floorplan/panels/FloorPlanStats.jsx
create mode 100644 frontend/src/components/floorplan/panels/HeatMapLegend.jsx
create mode 100644 frontend/src/components/floorplan/panels/MiniMap.jsx
create mode 100644 frontend/src/components/floorplan/panels/RackDetailPanel.jsx
create mode 100644 frontend/src/components/floorplan/panels/index.js
create mode 100644 frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx
create mode 100644 frontend/src/components/floorplan/toolbar/RoomSelector.jsx
create mode 100644 frontend/src/components/floorplan/toolbar/SearchBar.jsx
create mode 100644 frontend/src/components/floorplan/toolbar/ViewModeSwitch.jsx
create mode 100644 frontend/src/components/floorplan/toolbar/ZoomControls.jsx
create mode 100644 frontend/src/components/floorplan/toolbar/index.js
create mode 100644 frontend/src/context/FloorPlanContext.jsx
create mode 100644 frontend/src/hooks/floorplan/useFloorPlanContext.js
create mode 100644 frontend/src/hooks/floorplan/useFloorPlanData.js
create mode 100644 frontend/src/pages/RoomFloorPlan.jsx
diff --git a/backend/models/Rack.js b/backend/models/Rack.js
index 8242738..b4a48fd 100644
--- a/backend/models/Rack.js
+++ b/backend/models/Rack.js
@@ -19,7 +19,7 @@ const Rack = sequelize.define(
height: {
type: DataTypes.INTEGER,
allowNull: false,
- defaultValue: 45, // 标准机柜高度(U数)
+ defaultValue: 45,
},
maxPower: {
type: DataTypes.FLOAT,
@@ -41,6 +41,18 @@ const Rack = sequelize.define(
key: 'roomId',
},
},
+ rowPos: {
+ type: DataTypes.INTEGER,
+ defaultValue: null,
+ },
+ colPos: {
+ type: DataTypes.INTEGER,
+ defaultValue: null,
+ },
+ facing: {
+ type: DataTypes.STRING,
+ defaultValue: 'front',
+ },
},
{
tableName: 'racks',
diff --git a/backend/models/Room.js b/backend/models/Room.js
index 9c37825..5d1d9b0 100644
--- a/backend/models/Room.js
+++ b/backend/models/Room.js
@@ -65,6 +65,18 @@ const Room = sequelize.define(
description: {
type: DataTypes.TEXT,
},
+ gridRows: {
+ type: DataTypes.INTEGER,
+ defaultValue: 10,
+ },
+ gridCols: {
+ type: DataTypes.INTEGER,
+ defaultValue: 10,
+ },
+ layoutConfig: {
+ type: DataTypes.JSON,
+ defaultValue: null,
+ },
},
{
tableName: 'rooms',
diff --git a/backend/models/index.js b/backend/models/index.js
new file mode 100644
index 0000000..8cc83e1
--- /dev/null
+++ b/backend/models/index.js
@@ -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,
+};
diff --git a/backend/routes/devices.js b/backend/routes/devices.js
index 0516221..3f003fc 100644
--- a/backend/routes/devices.js
+++ b/backend/routes/devices.js
@@ -8,15 +8,7 @@ const path = require('path');
const csv = require('csv-parser');
const { createObjectCsvWriter } = require('csv-writer');
const iconv = require('iconv-lite');
-const Device = require('../models/Device');
-const Rack = require('../models/Rack');
-const Room = require('../models/Room');
-const DeviceField = require('../models/DeviceField');
-const Ticket = require('../models/Ticket');
-const DevicePort = require('../models/DevicePort');
-const Cable = require('../models/Cable');
-const NetworkCard = require('../models/NetworkCard');
-const InventoryRecord = require('../models/InventoryRecord');
+const { Room, Rack, Device, DeviceField, Ticket, DevicePort, Cable, NetworkCard, InventoryRecord } = require('../models');
const {
logDeviceOperation,
generateDeviceDescription,
@@ -32,11 +24,6 @@ const {
queryDeviceSchema,
} = require('../validation/deviceSchema');
-Device.belongsTo(Rack, { foreignKey: 'rackId' });
-Rack.hasMany(Device, { foreignKey: 'rackId' });
-Rack.belongsTo(Room, { foreignKey: 'roomId' });
-Room.hasMany(Rack, { foreignKey: 'roomId' });
-
const PREVIEW_COUNT = 20;
async function checkPositionAvailable(
@@ -577,11 +564,13 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
// 使用 sequelize.literal 构建原始SQL条件
if (dbDialect === 'mysql') {
return sequelize.literal(
- `JSON_EXTRACT(customFields, '$."${safeFieldName}"') LIKE '%${escapedKeyword}%' ESCAPE '\\\\'`
+ `JSON_UNQUOTE(JSON_EXTRACT(customFields, '$."${safeFieldName}"')) LIKE '%${escapedKeyword}%' ESCAPE '\\\\'`
);
} else {
+ // SQLite: json_extract 返回 JSON 格式值,需要转换为文本进行比较
+ // 使用 ->> 操作符或 CAST 获取纯文本值
return sequelize.literal(
- `json_extract(customFields, '$.${safeFieldName}') LIKE '%${escapedKeyword}%' ESCAPE '\\'`
+ `CAST(json_extract(customFields, '$.${safeFieldName}') AS TEXT) LIKE '%${escapedKeyword}%' ESCAPE '\\'`
);
}
});
diff --git a/backend/routes/racks.js b/backend/routes/racks.js
index 9f27cd6..0711592 100644
--- a/backend/routes/racks.js
+++ b/backend/routes/racks.js
@@ -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;
diff --git a/backend/routes/rooms.js b/backend/routes/rooms.js
index 1a27cb5..9c68f31 100644
--- a/backend/routes/rooms.js
+++ b/backend/routes/rooms.js
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
-const Room = require('../models/Room');
-const Rack = require('../models/Rack');
+const { Room, Rack, Device } = require('../models');
+const { sequelize } = require('../db');
const { validateBody } = require('../middleware/validation');
const { createRoomSchema, updateRoomSchema } = require('../validation/roomSchema');
@@ -72,7 +72,6 @@ router.put('/:roomId', validateBody(updateRoomSchema), async (req, res) => {
// 删除机房
router.delete('/:roomId', async (req, res) => {
try {
- // 检查是否有机柜关联
const racks = await Rack.findAll({ where: { roomId: req.params.roomId } });
if (racks.length > 0) {
return res.status(400).json({ error: '该机房下有机柜,无法删除' });
@@ -91,4 +90,171 @@ router.delete('/:roomId', async (req, res) => {
}
});
+// 获取机房平面图布局数据
+router.get('/:roomId/layout', async (req, res) => {
+ try {
+ const room = await Room.findByPk(req.params.roomId);
+ if (!room) {
+ return res.status(404).json({ error: '机房不存在' });
+ }
+
+ const racks = await Rack.findAll({
+ where: { roomId: req.params.roomId },
+ include: [{
+ model: Device,
+ as: 'Devices',
+ attributes: ['deviceId', 'name', 'type', 'status', 'position', 'height', 'powerConsumption', 'ipAddress', 'model'],
+ }],
+ });
+
+ const rackData = racks.map(rack => {
+ const rackJson = rack.toJSON();
+ const devices = rackJson.Devices || [];
+ const usedU = devices.reduce((sum, d) => sum + (d.height || 1), 0);
+ const totalPower = devices.reduce((sum, d) => sum + (d.powerConsumption || 0), 0);
+ return {
+ rackId: rackJson.rackId,
+ name: rackJson.name,
+ rowPos: rackJson.rowPos,
+ colPos: rackJson.colPos,
+ facing: rackJson.facing,
+ status: rackJson.status,
+ height: rackJson.height,
+ maxPower: rackJson.maxPower,
+ currentPower: rackJson.currentPower,
+ deviceCount: devices.length,
+ usedU,
+ totalU: rackJson.height,
+ utilization: rackJson.height > 0 ? usedU / rackJson.height : 0,
+ totalPower,
+ Devices: devices,
+ };
+ });
+
+ const activeRacks = rackData.filter(r => r.status === 'active').length;
+ const maintenanceRacks = rackData.filter(r => r.status === 'maintenance').length;
+ const inactiveRacks = rackData.filter(r => r.status === 'inactive').length;
+ const totalMaxPower = rackData.reduce((sum, r) => sum + (r.maxPower || 0), 0);
+ const totalCurrentPower = rackData.reduce((sum, r) => sum + (r.currentPower || 0), 0);
+ const avgUtilization = rackData.length > 0
+ ? rackData.reduce((sum, r) => sum + r.utilization, 0) / rackData.length
+ : 0;
+
+ res.json({
+ room: {
+ roomId: room.roomId,
+ name: room.name,
+ location: room.location,
+ area: room.area,
+ capacity: room.capacity,
+ gridRows: room.gridRows,
+ gridCols: room.gridCols,
+ layoutConfig: room.layoutConfig,
+ },
+ racks: rackData,
+ stats: {
+ totalRacks: rackData.length,
+ activeRacks,
+ maintenanceRacks,
+ inactiveRacks,
+ avgUtilization: Math.round(avgUtilization * 100) / 100,
+ totalCurrentPower,
+ totalMaxPower,
+ },
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// 更新机房布局参数
+router.put('/:roomId/layout', async (req, res) => {
+ try {
+ const { gridRows, gridCols, layoutConfig } = req.body;
+ const room = await Room.findByPk(req.params.roomId);
+ if (!room) {
+ return res.status(404).json({ error: '机房不存在' });
+ }
+
+ const updateData = {};
+ if (gridRows !== undefined) updateData.gridRows = gridRows;
+ if (gridCols !== undefined) updateData.gridCols = gridCols;
+ if (layoutConfig !== undefined) updateData.layoutConfig = layoutConfig;
+
+ await Room.update(updateData, { where: { roomId: req.params.roomId } });
+ const updatedRoom = await Room.findByPk(req.params.roomId);
+ res.json(updatedRoom);
+ } catch (error) {
+ res.status(400).json({ error: error.message });
+ }
+});
+
+// 批量更新机房内机柜位置
+router.put('/:roomId/racks-positions', async (req, res) => {
+ try {
+ const { positions } = req.body;
+ if (!Array.isArray(positions)) {
+ return res.status(400).json({ error: 'positions 必须是数组' });
+ }
+
+ const results = [];
+ for (const pos of positions) {
+ const { rackId, rowPos, colPos, facing } = pos;
+ if (!rackId) continue;
+
+ const rack = await Rack.findByPk(rackId);
+ if (!rack || rack.roomId !== req.params.roomId) continue;
+
+ await Rack.update(
+ { rowPos, colPos, facing },
+ { where: { rackId } }
+ );
+ results.push(rackId);
+ }
+
+ res.json({ success: true, updated: results.length });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// 初始化机房布局(自动分配所有机柜位置)
+router.post('/:roomId/init-layout', async (req, res) => {
+ try {
+ const { gridRows, gridCols } = req.body;
+ const rows = gridRows || 10;
+ const cols = gridCols || 10;
+
+ const racks = await Rack.findAll({
+ where: { roomId: req.params.roomId },
+ order: [['name', 'ASC']],
+ });
+
+ const positions = [];
+ let index = 0;
+ for (let row = 0; row < rows && index < racks.length; row++) {
+ for (let col = 0; col < cols && index < racks.length; col++) {
+ positions.push({
+ rackId: racks[index].rackId,
+ rowPos: row,
+ colPos: col,
+ facing: 'front',
+ });
+ index++;
+ }
+ }
+
+ for (const pos of positions) {
+ await Rack.update(
+ { rowPos: pos.rowPos, colPos: pos.colPos, facing: pos.facing },
+ { where: { rackId: pos.rackId } }
+ );
+ }
+
+ res.json({ success: true, assigned: positions.length });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
module.exports = router;
diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js
index 3d8abf1..aa51746 100644
--- a/backend/scripts/migrate-all.js
+++ b/backend/scripts/migrate-all.js
@@ -118,6 +118,16 @@ const migrations = [
description: '为 consumable_logs 表添加 lastNameSyncAt 字段,支持名称同步',
migrate: migrateConsumableLogNameSync,
},
+ {
+ name: '机房布局字段',
+ description: '为 rooms 表添加 gridRows、gridCols、layoutConfig 字段,支持平面图布局',
+ migrate: migrateRoomLayoutFields,
+ },
+ {
+ name: '机柜位置字段',
+ description: '为 racks 表添加 rowPos、colPos、facing 字段,支持机柜位置定位',
+ migrate: migrateRackPositionFields,
+ },
];
async function runMigrations() {
@@ -862,6 +872,34 @@ async function migrateConsumableLogNameSync() {
console.log(' 耗材日志名称同步迁移完成');
}
+async function migrateRoomLayoutFields() {
+ const tableName = 'rooms';
+ const columns = [
+ { name: 'gridRows', def: 'INTEGER DEFAULT 10' },
+ { name: 'gridCols', def: 'INTEGER DEFAULT 10' },
+ { name: 'layoutConfig', def: dbDialect === 'sqlite' ? 'TEXT' : 'JSON' },
+ ];
+
+ for (const col of columns) {
+ await addColumnIfNotExists(tableName, col.name, col.def);
+ }
+ console.log(' 机房布局字段迁移完成');
+}
+
+async function migrateRackPositionFields() {
+ const tableName = 'racks';
+ const columns = [
+ { name: 'rowPos', def: 'INTEGER' },
+ { name: 'colPos', def: 'INTEGER' },
+ { name: 'facing', def: "VARCHAR(20) DEFAULT 'front'" },
+ ];
+
+ for (const col of columns) {
+ await addColumnIfNotExists(tableName, col.name, col.def);
+ }
+ console.log(' 机柜位置字段迁移完成');
+}
+
// 执行迁移
runMigrations().catch(error => {
console.error('迁移执行失败:', error);
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 73e6f63..68ae4bc 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -35,6 +35,7 @@ import {
PartitionOutlined,
CodepenOutlined,
CloudUploadOutlined,
+ LayoutOutlined,
} from '@ant-design/icons';
import {
BrowserRouter as Router,
@@ -83,6 +84,7 @@ const RemoteBackupSettings = lazy(() => import('./pages/RemoteBackupSettings'));
const OperationLogs = lazy(() => import('./pages/OperationLogs'));
const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest'));
const IdleDeviceManagement = lazy(() => import('./pages/IdleDeviceManagement'));
+const RoomFloorPlan = lazy(() => import('./pages/RoomFloorPlan'));
const { Header, Content, Sider } = Layout;
@@ -242,6 +244,11 @@ const AppLayout = ({ children }) => {
icon: ,
label: 3D机柜可视化,
},
+ {
+ key: 'floor-plan',
+ icon: ,
+ label: 机房平面图,
+ },
],
},
{
@@ -608,6 +615,7 @@ const routeConfig = [
{ path: '/operation-logs', component: OperationLogs },
{ path: '/error-boundary-test', component: ErrorBoundaryTest },
{ path: '/idle-devices', component: IdleDeviceManagement },
+ { path: '/floor-plan', component: RoomFloorPlan },
];
const ThemeConfig = () => {
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 4703da6..7a3c2de 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -170,6 +170,22 @@ export const deviceAPI = {
delete: deviceId => api.delete(`/devices/${deviceId}`),
getTickets: (deviceId, params) => api.get(`/devices/${deviceId}/tickets`, { params }),
checkPosition: (rackId, params) => api.get(`/devices/check-position/${rackId}`, { params }),
+ 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 = {
diff --git a/frontend/src/components/device/ImportModal.jsx b/frontend/src/components/device/ImportModal.jsx
index 83102f8..19ae725 100644
--- a/frontend/src/components/device/ImportModal.jsx
+++ b/frontend/src/components/device/ImportModal.jsx
@@ -7,7 +7,7 @@ import {
WarningOutlined,
FileTextOutlined,
} from '@ant-design/icons';
-import axios from 'axios';
+import { deviceAPI } from '../../api';
import { designTokens } from '../../config/theme';
const modalHeaderStyle = {
@@ -29,18 +29,6 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
const [importPhase, setImportPhase] = useState('');
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 = () => {
setStep('upload');
setIsPreviewing(false);
@@ -65,25 +53,18 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
setIsPreviewing(true);
try {
- const formData = new FormData();
- formData.append('csvFile', actualFile);
+ const response = await deviceAPI.importPreview(actualFile);
- const response = await api.post('/devices/import-preview', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
- });
-
- if (response.data.success) {
- setPreviewData(response.data.data);
+ if (response.success) {
+ setPreviewData(response.data);
setStep('preview');
} else {
- message.error(response.data.error || '预览失败');
+ message.error(response.error || '预览失败');
resetState();
}
} catch (error) {
console.error('预览失败:', error);
- message.error(error.response?.data?.error || '预览失败,请检查文件格式');
+ message.error(error || '预览失败,请检查文件格式');
resetState();
} finally {
setPreviewLoading(false);
@@ -173,12 +154,8 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
const handleDownloadTemplate = async () => {
try {
- const token = localStorage.getItem('token');
- const response = await axios.get('/api/devices/import-template', {
- responseType: 'blob',
- headers: token ? { Authorization: `Bearer ${token}` } : {},
- });
- const blob = new Blob([response.data], { type: 'text/csv; charset=gbk' });
+ const response = await deviceAPI.getImportTemplate();
+ const blob = new Blob([response], { type: 'text/csv; charset=gbk' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -189,17 +166,7 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('下载模板失败:', error);
- if (error.response?.data) {
- const text = await error.response.data.text();
- try {
- const json = JSON.parse(text);
- message.error(json.message || '下载模板失败');
- } catch {
- message.error('下载模板失败');
- }
- } else {
- message.error('下载模板失败');
- }
+ message.error(error || '下载模板失败');
}
};
diff --git a/frontend/src/components/floorplan/canvas/CanvasConstants.js b/frontend/src/components/floorplan/canvas/CanvasConstants.js
new file mode 100644
index 0000000..f51197f
--- /dev/null
+++ b/frontend/src/components/floorplan/canvas/CanvasConstants.js
@@ -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',
+};
diff --git a/frontend/src/components/floorplan/canvas/CanvasInteraction.js b/frontend/src/components/floorplan/canvas/CanvasInteraction.js
new file mode 100644
index 0000000..6f57a9e
--- /dev/null
+++ b/frontend/src/components/floorplan/canvas/CanvasInteraction.js
@@ -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;
diff --git a/frontend/src/components/floorplan/canvas/CanvasRenderer.js b/frontend/src/components/floorplan/canvas/CanvasRenderer.js
new file mode 100644
index 0000000..63ca519
--- /dev/null
+++ b/frontend/src/components/floorplan/canvas/CanvasRenderer.js
@@ -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;
diff --git a/frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx b/frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx
new file mode 100644
index 0000000..9e8e7e1
--- /dev/null
+++ b/frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx
@@ -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 (
+
+
+
+ );
+});
+
+FloorPlanCanvas.displayName = 'FloorPlanCanvas';
+
+export default FloorPlanCanvas;
diff --git a/frontend/src/components/floorplan/canvas/index.js b/frontend/src/components/floorplan/canvas/index.js
new file mode 100644
index 0000000..f4b040e
--- /dev/null
+++ b/frontend/src/components/floorplan/canvas/index.js
@@ -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';
diff --git a/frontend/src/components/floorplan/editor/LayoutEditor.jsx b/frontend/src/components/floorplan/editor/LayoutEditor.jsx
new file mode 100644
index 0000000..6ccd536
--- /dev/null
+++ b/frontend/src/components/floorplan/editor/LayoutEditor.jsx
@@ -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 (
+ <>
+
+ }
+ onClick={() => {
+ initForm.setFieldsValue({
+ gridRows: room?.gridRows || 10,
+ gridCols: room?.gridCols || 10,
+ });
+ setInitModalVisible(true);
+ }}
+ >
+ 初始化布局
+
+
+ {pendingPositions.length > 0 && (
+
+ {pendingPositions.length} 处待保存
+
+ )}
+
+
+ }>取消
+
+
+ }
+ onClick={handleSave}
+ disabled={pendingPositions.length === 0}
+ >
+ 保存布局
+
+
+
+ setInitModalVisible(false)}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+
+
+ 初始化将按行列顺序自动排列所有机柜,已有位置信息将被覆盖。
+
+
+ >
+ );
+};
+
+export default LayoutEditor;
diff --git a/frontend/src/components/floorplan/editor/PositionValidator.js b/frontend/src/components/floorplan/editor/PositionValidator.js
new file mode 100644
index 0000000..4cf3dee
--- /dev/null
+++ b/frontend/src/components/floorplan/editor/PositionValidator.js
@@ -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;
diff --git a/frontend/src/components/floorplan/editor/index.js b/frontend/src/components/floorplan/editor/index.js
new file mode 100644
index 0000000..aa8ce9b
--- /dev/null
+++ b/frontend/src/components/floorplan/editor/index.js
@@ -0,0 +1,2 @@
+export { default as LayoutEditor } from './LayoutEditor';
+export { default as PositionValidator } from './PositionValidator';
diff --git a/frontend/src/components/floorplan/index.js b/frontend/src/components/floorplan/index.js
new file mode 100644
index 0000000..fef82e8
--- /dev/null
+++ b/frontend/src/components/floorplan/index.js
@@ -0,0 +1,3 @@
+export { FloorPlanCanvas } from './canvas';
+export { FloorPlanToolbar } from './toolbar';
+export { RackDetailPanel, FloorPlanStats } from './panels';
diff --git a/frontend/src/components/floorplan/panels/FloorPlanStats.jsx b/frontend/src/components/floorplan/panels/FloorPlanStats.jsx
new file mode 100644
index 0000000..c07c034
--- /dev/null
+++ b/frontend/src/components/floorplan/panels/FloorPlanStats.jsx
@@ -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 (
+
+
+
+
+
+ 概览
+
+
+
+ {stats.totalRacks} 台
+
+
+
+
+
+
+ {stats.activeRacks}
+
+
+ 在用
+
+
+
+
+ {stats.maintenanceRacks}
+
+
+ 维护
+
+
+
+
+ {stats.inactiveRacks}
+
+
+ 停用
+
+
+
+
+
+
+
+
+ {Math.round((stats.avgUtilization || 0) * 100)}%
+
+
+
+
+
+ {stats.totalCurrentPower || 0}W
+
+
+
+
+ );
+};
+
+export default FloorPlanStats;
diff --git a/frontend/src/components/floorplan/panels/HeatMapLegend.jsx b/frontend/src/components/floorplan/panels/HeatMapLegend.jsx
new file mode 100644
index 0000000..7daa015
--- /dev/null
+++ b/frontend/src/components/floorplan/panels/HeatMapLegend.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { HEAT_MAP } from '../canvas/CanvasConstants';
+
+const HeatMapLegend = ({ dimension }) => {
+ const labels = {
+ utilization: 'U位使用率',
+ power: '功率负载',
+ density: '设备密度',
+ };
+
+ return (
+
+
+ {labels[dimension] || '热力图'}
+
+
+
+ );
+};
+
+export default HeatMapLegend;
diff --git a/frontend/src/components/floorplan/panels/MiniMap.jsx b/frontend/src/components/floorplan/panels/MiniMap.jsx
new file mode 100644
index 0000000..222b5ae
--- /dev/null
+++ b/frontend/src/components/floorplan/panels/MiniMap.jsx
@@ -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 (
+
+ );
+};
+
+export default MiniMap;
diff --git a/frontend/src/components/floorplan/panels/RackDetailPanel.jsx b/frontend/src/components/floorplan/panels/RackDetailPanel.jsx
new file mode 100644
index 0000000..07d31d4
--- /dev/null
+++ b/frontend/src/components/floorplan/panels/RackDetailPanel.jsx
@@ -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 (
+
+
+ {rack.name}
+
+ }
+ placement="right"
+ width={360}
+ open={visible}
+ onClose={onClose}
+ extra={
+
+ }
+ onClick={handleView3D}
+ >
+ 3D视图
+
+ }
+ onClick={handleViewDevices}
+ >
+ 设备
+
+
+ }
+ >
+
+ {rack.rackId}
+
+ {statusInfo.text}
+
+
+ {rack.rowPos != null && rack.colPos != null
+ ? `第${rack.rowPos + 1}排 第${rack.colPos + 1}列`
+ : '未分配'}
+
+ {rack.facing || 'front'}
+ {rack.height}U
+ {rack.deviceCount || 0} 台
+
+
+
+
U位使用率
+
+
+
+
+ 功率
+
+
+
+ );
+};
+
+export default RackDetailPanel;
diff --git a/frontend/src/components/floorplan/panels/index.js b/frontend/src/components/floorplan/panels/index.js
new file mode 100644
index 0000000..87a0255
--- /dev/null
+++ b/frontend/src/components/floorplan/panels/index.js
@@ -0,0 +1,3 @@
+export { default as RackDetailPanel } from './RackDetailPanel';
+export { default as FloorPlanStats } from './FloorPlanStats';
+export { default as HeatMapLegend } from './HeatMapLegend';
diff --git a/frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx b/frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx
new file mode 100644
index 0000000..8e1cdfa
--- /dev/null
+++ b/frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ {onRefresh && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default FloorPlanToolbar;
diff --git a/frontend/src/components/floorplan/toolbar/RoomSelector.jsx b/frontend/src/components/floorplan/toolbar/RoomSelector.jsx
new file mode 100644
index 0000000..dbcb8f2
--- /dev/null
+++ b/frontend/src/components/floorplan/toolbar/RoomSelector.jsx
@@ -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 (
+ : }
+ options={rooms.map(r => ({
+ value: r.roomId,
+ label: (
+
+ ),
+ }))}
+ notFoundContent={loading ? : '暂无机房'}
+ size="middle"
+ />
+ );
+};
+
+export default RoomSelector;
diff --git a/frontend/src/components/floorplan/toolbar/SearchBar.jsx b/frontend/src/components/floorplan/toolbar/SearchBar.jsx
new file mode 100644
index 0000000..b9254d9
--- /dev/null
+++ b/frontend/src/components/floorplan/toolbar/SearchBar.jsx
@@ -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 (
+ }
+ value={keyword}
+ onChange={(e) => setKeyword(e.target.value)}
+ onSearch={handleSearch}
+ style={{ width: 180 }}
+ allowClear
+ />
+ );
+};
+
+export default SearchBar;
diff --git a/frontend/src/components/floorplan/toolbar/ViewModeSwitch.jsx b/frontend/src/components/floorplan/toolbar/ViewModeSwitch.jsx
new file mode 100644
index 0000000..b0d076e
--- /dev/null
+++ b/frontend/src/components/floorplan/toolbar/ViewModeSwitch.jsx
@@ -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 (
+
+ {
+ const val = e.target.value;
+ if (val === 'edit') {
+ onEditModeChange(true);
+ onViewModeChange('standard');
+ } else {
+ onEditModeChange(false);
+ onViewModeChange(val);
+ }
+ }}
+ optionType="button"
+ buttonStyle="solid"
+ size="small"
+ >
+
+ 标准
+
+
+ 热力图
+
+
+ 编辑
+
+
+
+ {viewMode === 'heatmap' && !editMode && (
+
+ );
+};
+
+export default ViewModeSwitch;
diff --git a/frontend/src/components/floorplan/toolbar/ZoomControls.jsx b/frontend/src/components/floorplan/toolbar/ZoomControls.jsx
new file mode 100644
index 0000000..7610167
--- /dev/null
+++ b/frontend/src/components/floorplan/toolbar/ZoomControls.jsx
@@ -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 (
+
+
+
+
+
+
+ {percent}%
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ZoomControls;
diff --git a/frontend/src/components/floorplan/toolbar/index.js b/frontend/src/components/floorplan/toolbar/index.js
new file mode 100644
index 0000000..420d74e
--- /dev/null
+++ b/frontend/src/components/floorplan/toolbar/index.js
@@ -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';
diff --git a/frontend/src/context/FloorPlanContext.jsx b/frontend/src/context/FloorPlanContext.jsx
new file mode 100644
index 0000000..4d2c92e
--- /dev/null
+++ b/frontend/src/context/FloorPlanContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+export default FloorPlanContext;
diff --git a/frontend/src/hooks/floorplan/useFloorPlanContext.js b/frontend/src/hooks/floorplan/useFloorPlanContext.js
new file mode 100644
index 0000000..745565d
--- /dev/null
+++ b/frontend/src/hooks/floorplan/useFloorPlanContext.js
@@ -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;
diff --git a/frontend/src/hooks/floorplan/useFloorPlanData.js b/frontend/src/hooks/floorplan/useFloorPlanData.js
new file mode 100644
index 0000000..42a4915
--- /dev/null
+++ b/frontend/src/hooks/floorplan/useFloorPlanData.js
@@ -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;
diff --git a/frontend/src/pages/RoomFloorPlan.jsx b/frontend/src/pages/RoomFloorPlan.jsx
new file mode 100644
index 0000000..ebd992d
--- /dev/null
+++ b/frontend/src/pages/RoomFloorPlan.jsx
@@ -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 (
+
+
+
+
+ 类型:
+ {DEVICE_TYPE_NAMES[device.type] || device.type}
+
+
+ 状态:
+
+ {DEVICE_STATUS_NAMES[device.status] || device.status}
+
+
+
+ 位置:
+ {rack?.name} - {device.position}U
+
+ {device.ipAddress && (
+
+ IP:
+
+ {device.ipAddress}
+
+
+ )}
+ {device.model && (
+
+ 型号:
+ {device.model}
+
+ )}
+ {device.height > 1 && (
+
+ 高度:
+ {device.height}U
+
+ )}
+
+
+ );
+};
+
+const UnassignedRacksPanel = ({ racks, onAutoAssign }) => {
+ const unassigned = (racks || []).filter(r => r.rowPos == null || r.colPos == null);
+
+ if (unassigned.length === 0) return null;
+
+ return (
+
+
+
+
+
+ 未分配
+
+
+ {unassigned.length}台
+
+
+
+
+
+ {unassigned.slice(0, 6).map((r, i) => (
+
+ {r.name}
+
+ ))}
+ {unassigned.length > 6 && (
+
+ +{unassigned.length - 6}
+
+ )}
+
+
+
+
+ );
+};
+
+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 (
+
+
canvasRef.current?.zoomIn()}
+ onZoomOut={() => canvasRef.current?.zoomOut()}
+ onZoomReset={() => canvasRef.current?.zoomReset()}
+ isFullscreen={isFullscreen}
+ onToggleFullscreen={handleToggleFullscreen}
+ onRefresh={refetch}
+ />
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!selectedRoomId && (
+
+
+
+ )}
+
+ {selectedRoomId && layoutData && (
+
{}}
+ onViewChange={handleViewChange}
+ />
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+const RoomFloorPlan = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default RoomFloorPlan;