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 ( + <> + + + + {pendingPositions.length > 0 && ( + + {pendingPositions.length} 处待保存 + + )} + + + + + + + + + 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={ + + + + + } + > + + {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位使用率
+ `${rack.usedU || 0}/${rack.totalU || rack.height || 0}U`} + /> +
+ +
+
+ 功率 +
+ `${rack.currentPower || 0}/${rack.maxPower || 0}W`} + /> +
+
+ ); +}; + +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 ( + onViewModeChange('heatmap', val)} + size="small" + style={{ width: 120 }} + options={[ + { value: 'utilization', label: 'U位使用率' }, + { value: 'power', label: '功率负载' }, + { value: 'density', label: '设备密度' }, + ]} + /> + )} +
+ ); +}; + +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.name} +
+
+
+ 类型: + {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;