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 (
- <>
-
- }
- onClick={() => {
- initForm.setFieldsValue({
- gridRows: room?.gridRows || 10,
- gridCols: room?.gridCols || 10,
- });
- setInitModalVisible(true);
- }}
- >
- 初始化布局
-
-
- {pendingPositions.length > 0 && (
-
- {pendingPositions.length} 处待保存
-
- )}
-
-
- }>取消
-
-
- }
- onClick={handleSave}
- disabled={pendingPositions.length === 0}
- >
- 保存布局
-
-
-
- setInitModalVisible(false)}
- okText="确定"
- cancelText="取消"
- >
-
-
-
-
-
-
-
-
- 初始化将按行列顺序自动排列所有机柜,已有位置信息将被覆盖。
-
-
- >
- );
-};
-
-export default LayoutEditor;
diff --git a/frontend/src/components/floorplan/editor/PositionValidator.js b/frontend/src/components/floorplan/editor/PositionValidator.js
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位使用率
+
-
-
+
+
功率
-
+
+
);
};
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 && (
+
+ }
+ onClick={onExport}
+ style={{ fontSize: 16 }}
+ />
+
+ )}
+
- : }
onClick={onToggleFullscreen}
- style={{
- width: 36,
- height: 36,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- background: '#fff',
- border: '1px solid #e8e8e8',
- borderRadius: 8,
- cursor: 'pointer',
- color: '#595959',
- transition: 'all 0.2s',
- fontSize: 16,
- }}
- onMouseEnter={(e) => {
- e.target.style.background = '#f8f9fa';
- e.target.style.borderColor = '#d9d9d9';
- e.target.style.color = '#262626';
- }}
- onMouseLeave={(e) => {
- e.target.style.background = '#fff';
- e.target.style.borderColor = '#e8e8e8';
- e.target.style.color = '#595959';
- }}
- >
- {isFullscreen ? : }
-
+ style={{ fontSize: 16 }}
+ />
{onRefresh && (
- }
onClick={onRefresh}
- style={{
- width: 36,
- height: 36,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- background: '#fff',
- border: '1px solid #e8e8e8',
- borderRadius: 8,
- cursor: 'pointer',
- color: '#595959',
- transition: 'all 0.2s',
- fontSize: 16,
- }}
- onMouseEnter={(e) => {
- e.target.style.background = '#f8f9fa';
- e.target.style.borderColor = '#d9d9d9';
- e.target.style.color = '#262626';
- }}
- onMouseLeave={(e) => {
- e.target.style.background = '#fff';
- e.target.style.borderColor = '#e8e8e8';
- e.target.style.color = '#595959';
- }}
- >
-
-
+ 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: (
-
+
),
}))}
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 && (
-
- );
-};
-
-export default ViewModeSwitch;
diff --git a/frontend/src/components/floorplan/toolbar/ZoomControls.jsx b/frontend/src/components/floorplan/toolbar/ZoomControls.jsx
index 7610167..eef61b1 100644
--- a/frontend/src/components/floorplan/toolbar/ZoomControls.jsx
+++ b/frontend/src/components/floorplan/toolbar/ZoomControls.jsx
@@ -1,126 +1,47 @@
import React from 'react';
import { Button, Space, Tooltip } from 'antd';
import { ZoomInOutlined, ZoomOutOutlined, OneToOneOutlined } from '@ant-design/icons';
+import { ZoomControlsWrapper, ZoomPercent } from '../styles';
const ZoomControls = ({ zoom, onZoomIn, onZoomOut, onZoomReset }) => {
const percent = Math.round(zoom * 100);
return (
-
+
- }
onClick={onZoomOut}
disabled={zoom <= 0.3}
- style={{
- width: 28,
- height: 28,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- background: zoom <= 0.3 ? '#f5f5f5' : '#fff',
- border: '1px solid #e8e8e8',
- borderRadius: 6,
- cursor: zoom <= 0.3 ? 'not-allowed' : 'pointer',
- color: zoom <= 0.3 ? '#d9d9d9' : '#595959',
- transition: 'all 0.2s',
- fontSize: 14,
- }}
- onMouseEnter={(e) => {
- if (zoom > 0.3) {
- e.target.style.background = '#e6f7ff';
- e.target.style.borderColor = '#91d5ff';
- e.target.style.color = '#1890ff';
- }
- }}
- onMouseLeave={(e) => {
- e.target.style.background = zoom <= 0.3 ? '#f5f5f5' : '#fff';
- e.target.style.borderColor = '#e8e8e8';
- e.target.style.color = zoom <= 0.3 ? '#d9d9d9' : '#595959';
- }}
- >
-
-
+ style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
+ />
-
- {percent}%
-
+ {percent}%
- }
onClick={onZoomIn}
disabled={zoom >= 2}
- style={{
- width: 28,
- height: 28,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- background: zoom >= 2 ? '#f5f5f5' : '#fff',
- border: '1px solid #e8e8e8',
- borderRadius: 6,
- cursor: zoom >= 2 ? 'not-allowed' : 'pointer',
- color: zoom >= 2 ? '#d9d9d9' : '#595959',
- transition: 'all 0.2s',
- fontSize: 14,
- }}
- onMouseEnter={(e) => {
- if (zoom < 2) {
- e.target.style.background = '#e6f7ff';
- e.target.style.borderColor = '#91d5ff';
- e.target.style.color = '#1890ff';
- }
- }}
- onMouseLeave={(e) => {
- e.target.style.background = zoom >= 2 ? '#f5f5f5' : '#fff';
- e.target.style.borderColor = '#e8e8e8';
- e.target.style.color = zoom >= 2 ? '#d9d9d9' : '#595959';
- }}
- >
-
-
+ style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
+ />
- }
onClick={onZoomReset}
- style={{
- width: 28,
- height: 28,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- background: '#fff',
- border: '1px solid #e8e8e8',
- borderRadius: 6,
- cursor: 'pointer',
- color: '#595959',
- transition: 'all 0.2s',
- fontSize: 14,
- }}
- onMouseEnter={(e) => {
- e.target.style.background = '#e6f7ff';
- e.target.style.borderColor = '#91d5ff';
- e.target.style.color = '#1890ff';
- }}
- onMouseLeave={(e) => {
- e.target.style.background = '#fff';
- e.target.style.borderColor = '#e8e8e8';
- e.target.style.color = '#595959';
- }}
- >
-
-
+ style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
+ />
-
+
);
};
diff --git a/frontend/src/components/floorplan/toolbar/index.js b/frontend/src/components/floorplan/toolbar/index.js
index 420d74e..59a9b87 100644
--- a/frontend/src/components/floorplan/toolbar/index.js
+++ b/frontend/src/components/floorplan/toolbar/index.js
@@ -1,5 +1,3 @@
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
index 4d2c92e..0efe8f2 100644
--- a/frontend/src/context/FloorPlanContext.jsx
+++ b/frontend/src/context/FloorPlanContext.jsx
@@ -4,13 +4,9 @@ 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,
};
@@ -19,10 +15,7 @@ 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',
@@ -36,7 +29,6 @@ function floorPlanReducer(state, action) {
selectedRoomId: action.payload,
selectedRack: null,
hoveredRack: null,
- searchRackId: null,
detailRack: null,
detailVisible: false,
};
@@ -44,14 +36,6 @@ function floorPlanReducer(state, action) {
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,
@@ -59,8 +43,6 @@ function floorPlanReducer(state, action) {
offsetX: action.payload.offsetX,
offsetY: action.payload.offsetY,
};
- case actionTypes.SET_SEARCH_RACK:
- return { ...state, searchRackId: action.payload };
case actionTypes.SHOW_DETAIL:
return {
...state,
@@ -97,22 +79,10 @@ export const FloorPlanProvider = ({ children }) => {
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 });
}, []);
@@ -126,10 +96,7 @@ export const FloorPlanProvider = ({ children }) => {
setSelectedRoom,
setSelectedRack,
setHoveredRack,
- setViewMode,
- setEditMode,
setViewChange,
- setSearchRack,
showDetail,
hideDetail,
};
diff --git a/frontend/src/hooks/floorplan/useFloorPlanData.js b/frontend/src/hooks/floorplan/useFloorPlanData.js
index 42a4915..f7467db 100644
--- a/frontend/src/hooks/floorplan/useFloorPlanData.js
+++ b/frontend/src/hooks/floorplan/useFloorPlanData.js
@@ -30,59 +30,11 @@ const useFloorPlanData = (roomId) => {
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,
};
};
diff --git a/frontend/src/pages/RoomFloorPlan.jsx b/frontend/src/pages/RoomFloorPlan.jsx
index ebd992d..6f747c5 100644
--- a/frontend/src/pages/RoomFloorPlan.jsx
+++ b/frontend/src/pages/RoomFloorPlan.jsx
@@ -1,214 +1,112 @@
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 { Spin, Empty, message, Button } from 'antd';
+import { HomeOutlined, ReloadOutlined } 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: '空闲',
-};
+import { FloorPlanCanvas, FloorPlanToolbar, RackDetailPanel } from '../components/floorplan';
+import {
+ DEVICE_TYPE_COLORS,
+ DEVICE_TYPE_NAMES,
+ DEVICE_STATUS_COLORS,
+ DEVICE_STATUS_NAMES,
+} from '../components/floorplan/canvas/CanvasConstants';
+import {
+ PageContainer,
+ ContentWrapper,
+ CanvasContainer,
+ LoadingOverlay,
+ EmptyStateContainer,
+ EmptyStateTitle,
+ EmptyStateSubtitle,
+ DeviceTooltipContainer,
+ DeviceTooltipHeader,
+ DeviceTypeIndicator,
+ DeviceName,
+ DeviceInfoContent,
+ DeviceInfoRow,
+ DeviceInfoLabel,
+ DeviceInfoValue,
+} from '../components/floorplan/styles';
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';
+ const typeColor = DEVICE_TYPE_COLORS[device.type] || '#8c8c8c';
+ const statusColor = DEVICE_STATUS_COLORS[device.status] || '#8c8c8c';
return (
-
-
-
-
- 类型:
- {DEVICE_TYPE_NAMES[device.type] || device.type}
-
-
- 状态:
-
- {DEVICE_STATUS_NAMES[device.status] || device.status}
-
-
-
- 位置:
- {rack?.name} - {device.position}U
-
+
+
+
+ {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}
-
-
+
+ IP:
+ {device.ipAddress}
+
)}
{device.model && (
-
- 型号:
- {device.model}
-
+
+ 型号:
+ {device.model}
+
)}
{device.height > 1 && (
-
- 高度:
- {device.height}U
-
+
+ 高度:
+ {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 EmptyState = ({ onRefresh }) => (
+
+ }
+ description={
+ <>
+ 暂无机房数据
+ 请先在机房管理中创建机房,或检查网络连接
+ {onRefresh && (
+ } onClick={onRefresh}>
+ 刷新
+
+ )}
+ >
+ }
+ />
+
+);
const FloorPlanContent = () => {
const {
selectedRoomId,
setSelectedRoom,
- zoom,
- editMode,
detailRack,
detailVisible,
showDetail,
hideDetail,
} = useFloorPlanContext();
- const { layoutData, loading, error, refetch, updateRackPosition, batchUpdatePositions, initLayout } = useFloorPlanData(selectedRoomId);
+ const { layoutData, loading, error, refetch } = useFloorPlanData(selectedRoomId);
const canvasRef = useRef(null);
const containerRef = useRef(null);
const [currentZoom, setCurrentZoom] = useState(1);
@@ -223,7 +121,6 @@ const FloorPlanContent = () => {
}
}, [error]);
- // 全屏变化监听
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
@@ -275,8 +172,6 @@ const FloorPlanContent = () => {
}
}, [showDetail]);
- const handleRackHover = useCallback(() => {}, []);
-
const handleDeviceHover = useCallback((device, rack, x, y) => {
if (device) {
setHoveredDevice(device);
@@ -292,60 +187,27 @@ const FloorPlanContent = () => {
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('没有需要分配的机柜');
+ const handleExport = useCallback(() => {
+ if (!canvasRef.current || !layoutData?.room) {
+ message.warning('请先选择机房');
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('没有足够的空位');
+ const dataUrl = canvasRef.current.exportImage(layoutData.room.name);
+ if (!dataUrl) {
+ message.error('导出失败,请稍后重试');
return;
}
- const success = await batchUpdatePositions(positions);
- if (success) {
- message.success(`已分配 ${positions.length} 台机柜`);
- }
- }, [layoutData, batchUpdatePositions]);
+ const link = document.createElement('a');
+ link.download = `${layoutData.room.name || '机房平面图'}_${new Date().toISOString().slice(0, 10)}.png`;
+ link.href = dataUrl;
+ link.click();
+ message.success('导出成功');
+ }, [layoutData]);
return (
-
+
{
isFullscreen={isFullscreen}
onToggleFullscreen={handleToggleFullscreen}
onRefresh={refetch}
+ onExport={handleExport}
/>
-
+
{loading && (
-
+
-
+
)}
- {!selectedRoomId && (
-
-
-
- )}
+ {!selectedRoomId && }
{selectedRoomId && layoutData && (
{}}
onViewChange={handleViewChange}
/>
)}
@@ -417,25 +248,23 @@ const FloorPlanContent = () => {
x={tooltipPosition.x}
y={tooltipPosition.y}
/>
-
-
-
+
-
+
);
};
const RoomFloorPlan = () => {
return (
-
+
);
};