refactor(floorplan): 重构平面图组件与样式

- 移除未使用的编辑模式、热力图、搜索等功能
- 将样式代码抽离到独立样式文件
- 优化工具栏和缩放控制组件实现
- 添加导出图片功能
- 简化上下文状态管理
- 优化设备提示框和空状态显示
This commit is contained in:
zhang1106
2026-05-07 15:14:40 +08:00
parent c717ce30d6
commit 54b0a8e7bb
23 changed files with 564 additions and 1371 deletions
@@ -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',
@@ -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;
@@ -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;
@@ -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(() => {