From 54b0a8e7bbd28271727ebd9be5fe7e72ff42dbe6 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Thu, 7 May 2026 15:14:40 +0800 Subject: [PATCH] =?UTF-8?q?refactor(floorplan):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B9=B3=E9=9D=A2=E5=9B=BE=E7=BB=84=E4=BB=B6=E4=B8=8E=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除未使用的编辑模式、热力图、搜索等功能 - 将样式代码抽离到独立样式文件 - 优化工具栏和缩放控制组件实现 - 添加导出图片功能 - 简化上下文状态管理 - 优化设备提示框和空状态显示 --- .../floorplan/canvas/CanvasConstants.js | 56 +-- .../floorplan/canvas/CanvasInteraction.js | 60 +-- .../floorplan/canvas/CanvasRenderer.js | 291 +++++++++----- .../floorplan/canvas/FloorPlanCanvas.jsx | 19 +- .../floorplan/editor/LayoutEditor.jsx | 149 ------- .../floorplan/editor/PositionValidator.js | 56 --- .../src/components/floorplan/editor/index.js | 2 - frontend/src/components/floorplan/index.js | 2 +- .../floorplan/panels/FloorPlanStats.jsx | 104 ----- .../floorplan/panels/HeatMapLegend.jsx | 43 -- .../components/floorplan/panels/MiniMap.jsx | 145 ------- .../floorplan/panels/RackDetailPanel.jsx | 15 +- .../src/components/floorplan/panels/index.js | 2 - frontend/src/components/floorplan/styles.js | 165 ++++++++ .../floorplan/toolbar/FloorPlanToolbar.jsx | 105 ++--- .../floorplan/toolbar/RoomSelector.jsx | 50 +-- .../floorplan/toolbar/SearchBar.jsx | 40 -- .../floorplan/toolbar/ViewModeSwitch.jsx | 52 --- .../floorplan/toolbar/ZoomControls.jsx | 123 ++---- .../src/components/floorplan/toolbar/index.js | 2 - frontend/src/context/FloorPlanContext.jsx | 33 -- .../src/hooks/floorplan/useFloorPlanData.js | 48 --- frontend/src/pages/RoomFloorPlan.jsx | 373 +++++------------- 23 files changed, 564 insertions(+), 1371 deletions(-) delete mode 100644 frontend/src/components/floorplan/editor/LayoutEditor.jsx delete mode 100644 frontend/src/components/floorplan/editor/PositionValidator.js delete mode 100644 frontend/src/components/floorplan/editor/index.js delete mode 100644 frontend/src/components/floorplan/panels/FloorPlanStats.jsx delete mode 100644 frontend/src/components/floorplan/panels/HeatMapLegend.jsx delete mode 100644 frontend/src/components/floorplan/panels/MiniMap.jsx create mode 100644 frontend/src/components/floorplan/styles.js delete mode 100644 frontend/src/components/floorplan/toolbar/SearchBar.jsx delete mode 100644 frontend/src/components/floorplan/toolbar/ViewModeSwitch.jsx diff --git a/frontend/src/components/floorplan/canvas/CanvasConstants.js b/frontend/src/components/floorplan/canvas/CanvasConstants.js index f51197f..0c7d600 100644 --- a/frontend/src/components/floorplan/canvas/CanvasConstants.js +++ b/frontend/src/components/floorplan/canvas/CanvasConstants.js @@ -1,35 +1,16 @@ -// 机柜卡片基础尺寸(会根据屏幕自适应调整) 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, @@ -38,23 +19,18 @@ export const ZOOM = { 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 RACK_STATUS_NAMES = { + active: '在用', + maintenance: '维护中', + inactive: '停用', +}; + export const DEVICE_TYPE_COLORS = { server: '#1677ff', switch: '#52c41a', @@ -64,6 +40,15 @@ export const DEVICE_TYPE_COLORS = { other: '#8c8c8c', }; +export const DEVICE_TYPE_NAMES = { + server: '服务器', + switch: '交换机', + router: '路由器', + firewall: '防火墙', + storage: '存储设备', + other: '其他', +}; + export const DEVICE_STATUS_COLORS = { running: '#52c41a', maintenance: '#faad14', @@ -72,13 +57,14 @@ export const DEVICE_STATUS_COLORS = { idle: '#d9d9d9', }; -export const HEAT_MAP = { - LOW: '#52c41a', - MEDIUM: '#faad14', - HIGH: '#ff4d4f', +export const DEVICE_STATUS_NAMES = { + running: '运行中', + maintenance: '维护中', + offline: '离线', + fault: '故障', + idle: '空闲', }; -// 字体定义 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', diff --git a/frontend/src/components/floorplan/canvas/CanvasInteraction.js b/frontend/src/components/floorplan/canvas/CanvasInteraction.js index 6f57a9e..cbc4e96 100644 --- a/frontend/src/components/floorplan/canvas/CanvasInteraction.js +++ b/frontend/src/components/floorplan/canvas/CanvasInteraction.js @@ -1,4 +1,4 @@ -import { ZOOM, CELL_WIDTH, CELL_GAP } from './CanvasConstants'; +import { ZOOM } from './CanvasConstants'; class CanvasInteraction { constructor(canvas, renderer, callbacks) { @@ -19,7 +19,6 @@ class CanvasInteraction { 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); @@ -34,7 +33,6 @@ class CanvasInteraction { 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); @@ -47,7 +45,6 @@ class CanvasInteraction { 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); @@ -182,19 +179,6 @@ class CanvasInteraction { } } - 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(); @@ -259,7 +243,6 @@ class CanvasInteraction { return; } - // 计算所有机柜的边界框 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; racks.forEach(rack => { const bounds = this.renderer.getRackBounds(rack); @@ -269,7 +252,6 @@ class CanvasInteraction { maxY = Math.max(maxY, bounds.y + bounds.height); }); - // 添加padding const padding = 40; minX -= padding; minY -= padding; @@ -279,14 +261,12 @@ class CanvasInteraction { 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; @@ -300,40 +280,6 @@ class CanvasInteraction { }); } - 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; @@ -346,10 +292,6 @@ class CanvasInteraction { 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 index 7f72768..03dbe86 100644 --- a/frontend/src/components/floorplan/canvas/CanvasRenderer.js +++ b/frontend/src/components/floorplan/canvas/CanvasRenderer.js @@ -9,8 +9,6 @@ import { U_LABEL_WIDTH_LEFT, U_LABEL_WIDTH_RIGHT, U_BODY_WIDTH, - DEVICE_INDICATOR_WIDTH, - COLORS, RACK_STATUS_COLORS, DEVICE_TYPE_COLORS, DEVICE_STATUS_COLORS, @@ -31,10 +29,61 @@ class CanvasRenderer { this.hoveredRack = null; this.hoveredDevice = null; this.selectedRack = null; - this.viewMode = 'standard'; - this.dragPreview = null; - this.dropTarget = null; - this.searchHighlight = null; + + this.pendingRender = false; + this.bgCanvas = null; + this.bgCtx = null; + this.lastBgWidth = 0; + this.lastBgHeight = 0; + + this._initBackgroundCanvas(); + } + + _initBackgroundCanvas() { + this.bgCanvas = document.createElement('canvas'); + this.bgCtx = this.bgCanvas.getContext('2d'); + } + + _updateBackgroundCache(width, height) { + if (this.lastBgWidth === width && this.lastBgHeight === height) { + return; + } + + this.lastBgWidth = width; + this.lastBgHeight = height; + + const dpr = this.dpr; + this.bgCanvas.width = width * dpr; + this.bgCanvas.height = height * dpr; + this.bgCanvas.style.width = `${width}px`; + this.bgCanvas.style.height = `${height}px`; + + const ctx = this.bgCtx; + ctx.save(); + ctx.scale(dpr, dpr); + + const bgGradient = ctx.createLinearGradient(0, 0, 0, height); + bgGradient.addColorStop(0, '#f8fafc'); + bgGradient.addColorStop(0.5, '#f1f5f9'); + bgGradient.addColorStop(1, '#e2e8f0'); + ctx.fillStyle = bgGradient; + ctx.fillRect(0, 0, width, height); + + ctx.beginPath(); + ctx.strokeStyle = 'rgba(148,163,184,0.15)'; + ctx.lineWidth = 1; + const gridSize = 40; + for (let x = 0; x < width; x += gridSize) { + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + } + for (let y = 0; y < height; y += gridSize) { + ctx.moveTo(0, y); + ctx.lineTo(width, y); + } + ctx.stroke(); + + ctx.restore(); } setDPR() { @@ -47,7 +96,8 @@ class CanvasRenderer { this.canvas.height = height * this.dpr; this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; - this.render(); + this._updateBackgroundCache(width, height); + this.requestRender(); } setData(room, racks) { @@ -61,55 +111,44 @@ class CanvasRenderer { this.racks.forEach(r => { if (r.rackId) this.rackMap.set(r.rackId, r); }); - this.render(); + this.requestRender(); } setView(zoom, offsetX, offsetY) { this.zoom = zoom; this.offsetX = offsetX; this.offsetY = offsetY; - this.render(); + this.requestRender(); } setHoveredRack(rack) { if (this.hoveredRack !== rack) { this.hoveredRack = rack; - this.render(); + this.requestRender(); } } setHoveredDevice(device) { if (this.hoveredDevice !== device) { this.hoveredDevice = device; - this.render(); + this.requestRender(); } } setSelectedRack(rack) { if (this.selectedRack !== rack) { this.selectedRack = rack; - this.render(); + this.requestRender(); } } - 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(); + requestRender() { + if (this.pendingRender) return; + this.pendingRender = true; + requestAnimationFrame(() => { + this.pendingRender = false; + this.render(); + }); } getRackBounds(rack) { @@ -151,8 +190,7 @@ class CanvasRenderer { const bodyX = rackBounds.x + RACK_PADDING + leftLabelW; const bodyW = U_BODY_WIDTH; - // 从底部开始计算坐标(和渲染逻辑一致,设备向上扩展) - const bodyStartY = bodyY + 4 + (totalU * uHeight); // 底部Y坐标 + const bodyStartY = bodyY + 4 + (totalU * uHeight); const deviceHeight = device.height || 1; const deviceStartY = bodyStartY - (device.position + deviceHeight - 1) * uHeight; const deviceH = uHeight * deviceHeight; @@ -160,6 +198,30 @@ class CanvasRenderer { return { x: bodyX, y: deviceStartY, width: bodyW, height: deviceH }; } + _getVisibleRacks(displayWidth, displayHeight) { + const visibleRacks = []; + const viewLeft = -this.offsetX / this.zoom; + const viewTop = -this.offsetY / this.zoom; + const viewRight = viewLeft + displayWidth / this.zoom; + const viewBottom = viewTop + displayHeight / this.zoom; + + const padding = CELL_WIDTH + CELL_GAP; + + for (let i = 0; i < this.racks.length; i++) { + const rack = this.racks[i]; + const bounds = this.getRackBounds(rack); + + if (bounds.x + bounds.width + padding >= viewLeft && + bounds.x - padding <= viewRight && + bounds.y + bounds.height + padding >= viewTop && + bounds.y - padding <= viewBottom) { + visibleRacks.push({ rack, bounds }); + } + } + + return visibleRacks; + } + hitTest(canvasX, canvasY) { const viewX = (canvasX - this.offsetX) / this.zoom; const viewY = (canvasY - this.offsetY) / this.zoom; @@ -168,7 +230,6 @@ class CanvasRenderer { 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) { @@ -191,44 +252,24 @@ class CanvasRenderer { 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.save(); + ctx.scale(this.dpr, 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); + if (this.bgCanvas.width > 0 && this.bgCanvas.height > 0) { + ctx.drawImage(this.bgCanvas, 0, 0, displayWidth, 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); + const visibleRacks = this._getVisibleRacks(displayWidth, displayHeight); + for (const { rack, bounds } of visibleRacks) { this.drawRack(ctx, rack, bounds); } @@ -240,14 +281,12 @@ class CanvasRenderer { 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; @@ -265,7 +304,6 @@ class CanvasRenderer { ctx.shadowOffsetY = 3; } - // 机柜主体渐变背景 const gradient = ctx.createLinearGradient(x, y, x, y + height); gradient.addColorStop(0, '#ffffff'); gradient.addColorStop(1, '#f9fafb'); @@ -277,15 +315,6 @@ class CanvasRenderer { 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); @@ -297,14 +326,12 @@ class CanvasRenderer { 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); @@ -312,14 +339,12 @@ class CanvasRenderer { 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'; @@ -328,7 +353,6 @@ class CanvasRenderer { 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); @@ -342,38 +366,30 @@ class CanvasRenderer { 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分组(注意:设备从position向上扩展) const devices = (rack.Devices || []).filter(d => d.position != null); const deviceByU = new Map(); devices.forEach(d => { const deviceHeight = d.height || 1; const startU = d.position; - const endU = startU + deviceHeight - 1; // 向上扩展 + const endU = startU + deviceHeight - 1; for (let u = startU; u <= endU; u++) { deviceByU.set(u, d); } }); - // 渲染左侧U位标签(从下到上:1U在底部,totalU在顶部) this.drawULabels(ctx, leftLabelX, y + 4, leftLabelW, totalU, uHeight, 'left'); - // 渲染设备区域和空闲U位(从下到上:1U在底部,totalU在顶部) - // 从底部开始渲染 - const bodyStartY = y + 4 + (totalU * uHeight); // 底部Y坐标 + const bodyStartY = y + 4 + (totalU * uHeight); for (let u = 1; u <= totalU; u++) { - // 计算当前U位的Y坐标(从底部向上,u越大Y越小) const currentY = bodyStartY - u * uHeight; const d = deviceByU.get(u); if (d && d.position === u) { - // 设备从position向上显示,所以起始Y坐标要计算正确 const deviceHeight = d.height || 1; const deviceStartY = bodyStartY - (u + deviceHeight - 1) * uHeight; const isHovered = this.hoveredDevice?.deviceId === d.deviceId; @@ -383,7 +399,6 @@ class CanvasRenderer { } } - // 渲染右侧U位标签(从下到上:1U在底部) this.drawULabels(ctx, rightLabelX, y + 4, rightLabelW, totalU, uHeight, 'right'); } @@ -392,13 +407,10 @@ class CanvasRenderer { 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 { @@ -410,11 +422,9 @@ class CanvasRenderer { } 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(); @@ -429,11 +439,9 @@ class CanvasRenderer { 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(); @@ -441,7 +449,6 @@ class CanvasRenderer { ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; } else { - // 设备背景 const bgGradient = ctx.createLinearGradient(x, y, x, y + h); bgGradient.addColorStop(0, '#ffffff'); bgGradient.addColorStop(1, '#f9fafb'); @@ -451,27 +458,22 @@ class CanvasRenderer { 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'; @@ -507,6 +509,93 @@ class CanvasRenderer { } return tr + '…'; } + + exportImage(roomName) { + if (!this.room || this.racks.length === 0) return null; + + const padding = 60; + const titleHeight = 50; + const footerHeight = 30; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.racks.forEach(rack => { + const bounds = this.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); + }); + + const contentWidth = maxX - minX + padding * 2; + const contentHeight = maxY - minY + padding * 2; + const totalWidth = contentWidth; + const totalHeight = contentHeight + titleHeight + footerHeight; + + const exportCanvas = document.createElement('canvas'); + const exportCtx = exportCanvas.getContext('2d'); + const exportDpr = 2; + exportCanvas.width = totalWidth * exportDpr; + exportCanvas.height = totalHeight * exportDpr; + exportCtx.scale(exportDpr, exportDpr); + + const bgGradient = exportCtx.createLinearGradient(0, 0, 0, totalHeight); + bgGradient.addColorStop(0, '#f8fafc'); + bgGradient.addColorStop(0.5, '#f1f5f9'); + bgGradient.addColorStop(1, '#e2e8f0'); + exportCtx.fillStyle = bgGradient; + exportCtx.fillRect(0, 0, totalWidth, totalHeight); + + exportCtx.beginPath(); + exportCtx.strokeStyle = 'rgba(148,163,184,0.15)'; + exportCtx.lineWidth = 1; + const gridSize = 40; + for (let x = 0; x < totalWidth; x += gridSize) { + exportCtx.moveTo(x, 0); + exportCtx.lineTo(x, totalHeight); + } + for (let y = 0; y < totalHeight; y += gridSize) { + exportCtx.moveTo(0, y); + exportCtx.lineTo(totalWidth, y); + } + exportCtx.stroke(); + + exportCtx.fillStyle = '#1f2937'; + exportCtx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; + exportCtx.textAlign = 'center'; + exportCtx.textBaseline = 'middle'; + const title = roomName || this.room?.name || '机房平面图'; + exportCtx.fillText(`${title} - 机房平面图`, totalWidth / 2, titleHeight / 2); + + exportCtx.save(); + exportCtx.translate(padding - minX, titleHeight + padding - minY); + + const originalHoveredRack = this.hoveredRack; + const originalHoveredDevice = this.hoveredDevice; + const originalSelectedRack = this.selectedRack; + this.hoveredRack = null; + this.hoveredDevice = null; + this.selectedRack = null; + + for (const rack of this.racks) { + const bounds = this.getRackBounds(rack); + this.drawRack(exportCtx, rack, bounds); + } + + this.hoveredRack = originalHoveredRack; + this.hoveredDevice = originalHoveredDevice; + this.selectedRack = originalSelectedRack; + + exportCtx.restore(); + + exportCtx.fillStyle = '#6b7280'; + exportCtx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; + exportCtx.textAlign = 'right'; + exportCtx.textBaseline = 'bottom'; + const date = new Date().toLocaleString('zh-CN'); + exportCtx.fillText(`导出时间: ${date}`, totalWidth - 10, totalHeight - 10); + + return exportCanvas.toDataURL('image/png'); + } } export default CanvasRenderer; diff --git a/frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx b/frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx index 9e8e7e1..588972e 100644 --- a/frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx +++ b/frontend/src/components/floorplan/canvas/FloorPlanCanvas.jsx @@ -2,7 +2,7 @@ import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle import CanvasRenderer from './CanvasRenderer'; import CanvasInteraction from './CanvasInteraction'; -const FloorPlanCanvas = forwardRef(({ room, racks, viewMode, heatMapDimension, editMode, onRackClick, onRackDoubleClick, onRackHover, onDeviceHover, onRackDragEnd, onViewChange }, ref) => { +const FloorPlanCanvas = forwardRef(({ room, racks, onRackClick, onRackDoubleClick, onRackHover, onDeviceHover, onViewChange }, ref) => { const canvasRef = useRef(null); const containerRef = useRef(null); const rendererRef = useRef(null); @@ -15,8 +15,7 @@ const FloorPlanCanvas = forwardRef(({ room, racks, viewMode, heatMapDimension, e zoomOut: () => interactionRef.current?.zoomOut(), zoomReset: () => interactionRef.current?.zoomReset(), fitToView: () => interactionRef.current?.fitToView(), - animateToRack: (rack) => interactionRef.current?.animateToRack(rack), - setSearchHighlight: (rackId) => rendererRef.current?.setSearchHighlight(rackId), + exportImage: (roomName) => rendererRef.current?.exportImage(roomName), })); const handleResize = useCallback(() => { @@ -36,7 +35,6 @@ const FloorPlanCanvas = forwardRef(({ room, racks, viewMode, heatMapDimension, e onRackDoubleClick, onRackHover, onDeviceHover, - onRackDragEnd, onViewChange, }); interactionRef.current = interaction; @@ -61,7 +59,6 @@ const FloorPlanCanvas = forwardRef(({ room, racks, viewMode, heatMapDimension, e useEffect(() => { if (rendererRef.current) { rendererRef.current.setData(room, racks || []); - // 数据更新后自动居中显示 if (room) { setTimeout(() => { interactionRef.current?.fitToView(); @@ -70,18 +67,6 @@ const FloorPlanCanvas = forwardRef(({ room, racks, viewMode, heatMapDimension, e } }, [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(() => { diff --git a/frontend/src/components/floorplan/editor/LayoutEditor.jsx b/frontend/src/components/floorplan/editor/LayoutEditor.jsx deleted file mode 100644 index 6ccd536..0000000 --- a/frontend/src/components/floorplan/editor/LayoutEditor.jsx +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index 4cf3dee..0000000 --- a/frontend/src/components/floorplan/editor/PositionValidator.js +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index aa8ce9b..0000000 --- a/frontend/src/components/floorplan/editor/index.js +++ /dev/null @@ -1,2 +0,0 @@ -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 index fef82e8..f6fc97c 100644 --- a/frontend/src/components/floorplan/index.js +++ b/frontend/src/components/floorplan/index.js @@ -1,3 +1,3 @@ export { FloorPlanCanvas } from './canvas'; export { FloorPlanToolbar } from './toolbar'; -export { RackDetailPanel, FloorPlanStats } from './panels'; +export { RackDetailPanel } from './panels'; diff --git a/frontend/src/components/floorplan/panels/FloorPlanStats.jsx b/frontend/src/components/floorplan/panels/FloorPlanStats.jsx deleted file mode 100644 index c07c034..0000000 --- a/frontend/src/components/floorplan/panels/FloorPlanStats.jsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 7daa015..0000000 --- a/frontend/src/components/floorplan/panels/HeatMapLegend.jsx +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 222b5ae..0000000 --- a/frontend/src/components/floorplan/panels/MiniMap.jsx +++ /dev/null @@ -1,145 +0,0 @@ -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 index 07d31d4..fd34996 100644 --- a/frontend/src/components/floorplan/panels/RackDetailPanel.jsx +++ b/frontend/src/components/floorplan/panels/RackDetailPanel.jsx @@ -8,6 +8,7 @@ import { } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import { RACK_STATUS_COLORS } from '../canvas/CanvasConstants'; +import { DetailSection, DetailSectionTitle } from '../styles'; const statusMap = { active: { text: '在用', color: 'blue' }, @@ -80,8 +81,8 @@ const RackDetailPanel = ({ rack, visible, onClose }) => { {rack.deviceCount || 0} 台 -
-
U位使用率
+ + U位使用率 { } format={() => `${rack.usedU || 0}/${rack.totalU || rack.height || 0}U`} /> -
+ -
-
+ + 功率 -
+ { } format={() => `${rack.currentPower || 0}/${rack.maxPower || 0}W`} /> -
+ ); }; diff --git a/frontend/src/components/floorplan/panels/index.js b/frontend/src/components/floorplan/panels/index.js index 87a0255..d4b56cb 100644 --- a/frontend/src/components/floorplan/panels/index.js +++ b/frontend/src/components/floorplan/panels/index.js @@ -1,3 +1 @@ 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/styles.js b/frontend/src/components/floorplan/styles.js new file mode 100644 index 0000000..064a209 --- /dev/null +++ b/frontend/src/components/floorplan/styles.js @@ -0,0 +1,165 @@ +import styled from 'styled-components'; + +export const PageContainer = styled.div` + height: calc(100vh - 64px); +`; + +export const ContentWrapper = styled.div` + height: 100%; + display: flex; + flex-direction: column; +`; + +export const CanvasContainer = styled.div` + flex: 1; + position: relative; + overflow: hidden; +`; + +export const LoadingOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.7); + z-index: 5; +`; + +export const EmptyStateContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; +`; + +export const EmptyStateTitle = styled.div` + font-size: 16px; + color: rgba(0, 0, 0, 0.65); + margin-bottom: 8px; +`; + +export const EmptyStateSubtitle = styled.div` + font-size: 13px; + color: rgba(0, 0, 0, 0.45); + margin-bottom: 16px; +`; + +export const DeviceTooltipContainer = styled.div` + position: fixed; + left: ${props => props.$x + 18}px; + top: ${props => props.$y + 18}px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + padding: 14px 18px; + z-index: 1000; + min-width: 220px; + pointer-events: none; +`; + +export const DeviceTooltipHeader = styled.div` + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +`; + +export const DeviceTypeIndicator = styled.div` + width: 5px; + height: 20px; + background: ${props => props.$color}; + border-radius: 3px; +`; + +export const DeviceName = styled.strong` + font-size: 15px; + color: #111827; +`; + +export const DeviceInfoRow = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 4px; +`; + +export const DeviceInfoLabel = styled.span` + color: #6b7280; +`; + +export const DeviceInfoValue = styled.span` + font-weight: 500; + color: ${props => props.$color || '#4b5563'}; + font-family: ${props => props.$mono ? 'SFMono-Regular, Monaco, Consolas, monospace' : 'inherit'}; +`; + +export const DeviceInfoContent = styled.div` + font-size: 13px; + color: #4b5563; + line-height: 22px; +`; + +export const ToolbarWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + background: #ffffff; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); + flex-wrap: wrap; + gap: 12px; +`; + +export const ToolbarDivider = styled.div` + width: 1px; + height: 28px; + background: #e8e8e8; +`; + +export const ZoomControlsWrapper = styled.div` + display: flex; + align-items: center; + gap: 6px; + background: #f8f9fa; + padding: 6px 10px; + border-radius: 8px; +`; + +export const ZoomPercent = styled.div` + min-width: 52px; + text-align: center; + font-size: 11px; + color: #595959; + user-select: none; + font-weight: 500; + padding: 0 4px; +`; + +export const RoomOptionContent = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const RoomIndicator = styled.div` + width: 6px; + height: 6px; + border-radius: 50%; + background: #1677ff; +`; + +export const DetailSection = styled.div` + margin-top: 16px; +`; + +export const DetailSectionTitle = styled.div` + margin-bottom: 8px; + font-weight: 500; +`; diff --git a/frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx b/frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx index 8e1cdfa..8ebd08a 100644 --- a/frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx +++ b/frontend/src/components/floorplan/toolbar/FloorPlanToolbar.jsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Space, Tooltip } from 'antd'; -import { ReloadOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons'; +import { Button, Space, Tooltip } from 'antd'; +import { ReloadOutlined, FullscreenOutlined, FullscreenExitOutlined, DownloadOutlined } from '@ant-design/icons'; import RoomSelector from './RoomSelector'; import ZoomControls from './ZoomControls'; +import { ToolbarWrapper, ToolbarDivider } from '../styles'; const FloorPlanToolbar = ({ selectedRoomId, @@ -14,31 +15,16 @@ const FloorPlanToolbar = ({ onRefresh, isFullscreen, onToggleFullscreen, + onExport, }) => { return ( -
+ -
+ + {onExport && ( + + + style={{ fontSize: 16 }} + /> {onRefresh && ( - + style={{ fontSize: 16 }} + /> )} -
+
); }; diff --git a/frontend/src/components/floorplan/toolbar/RoomSelector.jsx b/frontend/src/components/floorplan/toolbar/RoomSelector.jsx index dbcb8f2..079e6d6 100644 --- a/frontend/src/components/floorplan/toolbar/RoomSelector.jsx +++ b/frontend/src/components/floorplan/toolbar/RoomSelector.jsx @@ -1,28 +1,33 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Select, Spin } from 'antd'; +import React, { useState, useEffect, useRef } from 'react'; +import { Select, Spin, message } from 'antd'; import { HomeOutlined } from '@ant-design/icons'; import axios from 'axios'; +import { RoomOptionContent, RoomIndicator } from '../styles'; 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]); + const hasAutoSelected = useRef(false); useEffect(() => { + const fetchRooms = async () => { + setLoading(true); + try { + const response = await axios.get('/api/rooms', { params: { pageSize: 1000 } }); + const roomList = response.data.rooms || []; + setRooms(roomList); + + if (!hasAutoSelected.current && roomList.length > 0 && !selectedRoomId) { + hasAutoSelected.current = true; + onRoomChange(roomList[0].roomId); + } + } catch (err) { + message.error('获取机房列表失败'); + } finally { + setLoading(false); + } + }; + fetchRooms(); }, []); @@ -36,15 +41,10 @@ const RoomSelector = ({ selectedRoomId, onRoomChange }) => { options={rooms.map(r => ({ value: r.roomId, label: ( -
-
+ + {r.name} -
+ ), }))} notFoundContent={loading ? : '暂无机房'} diff --git a/frontend/src/components/floorplan/toolbar/SearchBar.jsx b/frontend/src/components/floorplan/toolbar/SearchBar.jsx deleted file mode 100644 index b9254d9..0000000 --- a/frontend/src/components/floorplan/toolbar/SearchBar.jsx +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index b0d076e..0000000 --- a/frontend/src/components/floorplan/toolbar/ViewModeSwitch.jsx +++ /dev/null @@ -1,52 +0,0 @@ -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 && ( -