feat(floorplan): 新增机房平面图功能模块
refactor(models): 为Room和Rack模型添加布局相关字段 feat(routes): 实现机房平面图相关API接口 feat(components): 添加平面图编辑器、画布渲染及交互组件 feat(hooks): 实现平面图数据管理和状态管理 feat(pages): 新增机房平面图页面入口 style(components): 优化平面图组件样式和交互体验
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// 机柜卡片基础尺寸(会根据屏幕自适应调整)
|
||||
export const CELL_WIDTH = 220;
|
||||
export const CELL_HEIGHT = 480;
|
||||
export const CELL_GAP = 20;
|
||||
|
||||
// 兼容旧引用
|
||||
export const CELL_SIZE = 220;
|
||||
export const ROW_LABEL_WIDTH = 50;
|
||||
export const COL_LABEL_HEIGHT = 30;
|
||||
|
||||
// 机柜内部尺寸
|
||||
export const RACK_PADDING = 10;
|
||||
export const RACK_HEADER_HEIGHT = 36;
|
||||
export const RACK_STATUS_BAR_HEIGHT = 4;
|
||||
|
||||
// U位显示尺寸(双边)
|
||||
export const U_HEIGHT = 10;
|
||||
export const U_LABEL_WIDTH_LEFT = 28;
|
||||
export const U_LABEL_WIDTH_RIGHT = 28;
|
||||
export const U_BODY_WIDTH = 154;
|
||||
export const U_BODY_PADDING = 6;
|
||||
|
||||
// 设备显示尺寸
|
||||
export const DEVICE_INDICATOR_WIDTH = 3;
|
||||
export const DEVICE_STATUS_DOT_SIZE = 2.5;
|
||||
export const DEVICE_NAME_MAX_WIDTH = 130;
|
||||
|
||||
// 小地图
|
||||
export const MINI_MAP_SIZE = 160;
|
||||
export const MINI_MAP_PADDING = 10;
|
||||
|
||||
// 缩放参数
|
||||
export const ZOOM = {
|
||||
MIN: 0.3,
|
||||
MAX: 2,
|
||||
STEP: 0.1,
|
||||
DEFAULT: 1,
|
||||
WHEEL_FACTOR: 0.001,
|
||||
};
|
||||
|
||||
// 颜色定义
|
||||
export const COLORS = {
|
||||
CANVAS_BG: '#f0f2f5',
|
||||
RACK_BG: '#ffffff',
|
||||
RACK_BORDER: '#d9d9d9',
|
||||
RACK_SHADOW: 'rgba(0,0,0,0.08)',
|
||||
RACK_HEADER_BG: '#fafafa',
|
||||
EMPTY_U_BG: '#f5f5f5',
|
||||
GRID_LINE: '#e8e8e8',
|
||||
};
|
||||
|
||||
export const RACK_STATUS_COLORS = {
|
||||
active: '#1677ff',
|
||||
maintenance: '#faad14',
|
||||
inactive: '#bfbfbf',
|
||||
};
|
||||
|
||||
export const DEVICE_TYPE_COLORS = {
|
||||
server: '#1677ff',
|
||||
switch: '#52c41a',
|
||||
router: '#faad14',
|
||||
firewall: '#ff4d4f',
|
||||
storage: '#722ed1',
|
||||
other: '#8c8c8c',
|
||||
};
|
||||
|
||||
export const DEVICE_STATUS_COLORS = {
|
||||
running: '#52c41a',
|
||||
maintenance: '#faad14',
|
||||
offline: '#8c8c8c',
|
||||
fault: '#ff4d4f',
|
||||
idle: '#d9d9d9',
|
||||
};
|
||||
|
||||
export const HEAT_MAP = {
|
||||
LOW: '#52c41a',
|
||||
MEDIUM: '#faad14',
|
||||
HIGH: '#ff4d4f',
|
||||
};
|
||||
|
||||
// 字体定义
|
||||
export const FONT = {
|
||||
RACK_NAME: 'bold 13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
RACK_INFO: '10px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
DEVICE_NAME: '10px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
U_LABEL: '8px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
import { ZOOM, CELL_WIDTH, CELL_GAP } from './CanvasConstants';
|
||||
|
||||
class CanvasInteraction {
|
||||
constructor(canvas, renderer, callbacks) {
|
||||
this.canvas = canvas;
|
||||
this.renderer = renderer;
|
||||
this.callbacks = callbacks || {};
|
||||
|
||||
this.isPanning = false;
|
||||
this.panStartX = 0;
|
||||
this.panStartY = 0;
|
||||
this.panStartOffsetX = 0;
|
||||
this.panStartOffsetY = 0;
|
||||
this.lastMouseX = 0;
|
||||
this.lastMouseY = 0;
|
||||
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
this.onWheel = this.onWheel.bind(this);
|
||||
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||
this.onContextMenu = this.onContextMenu.bind(this);
|
||||
this.onTouchStart = this.onTouchStart.bind(this);
|
||||
this.onTouchMove = this.onTouchMove.bind(this);
|
||||
this.onTouchEnd = this.onTouchEnd.bind(this);
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.addEventListener('mouseup', this.onMouseUp);
|
||||
this.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||
this.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
||||
this.canvas.addEventListener('dblclick', this.onDoubleClick);
|
||||
this.canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||
this.canvas.addEventListener('touchstart', this.onTouchStart, { passive: false });
|
||||
this.canvas.addEventListener('touchmove', this.onTouchMove, { passive: false });
|
||||
this.canvas.addEventListener('touchend', this.onTouchEnd);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||
this.canvas.removeEventListener('mouseleave', this.onMouseLeave);
|
||||
this.canvas.removeEventListener('wheel', this.onWheel);
|
||||
this.canvas.removeEventListener('dblclick', this.onDoubleClick);
|
||||
this.canvas.removeEventListener('contextmenu', this.onContextMenu);
|
||||
this.canvas.removeEventListener('touchstart', this.onTouchStart);
|
||||
this.canvas.removeEventListener('touchmove', this.onTouchMove);
|
||||
this.canvas.removeEventListener('touchend', this.onTouchEnd);
|
||||
}
|
||||
|
||||
getCanvasCoords(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
onMouseDown(e) {
|
||||
if (e.button === 2) return;
|
||||
|
||||
const coords = this.getCanvasCoords(e);
|
||||
this.lastMouseX = coords.x;
|
||||
this.lastMouseY = coords.y;
|
||||
this.panStartX = coords.x;
|
||||
this.panStartY = coords.y;
|
||||
this.panStartOffsetX = this.renderer.offsetX;
|
||||
this.panStartOffsetY = this.renderer.offsetY;
|
||||
this.isPanning = true;
|
||||
this.canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
const coords = this.getCanvasCoords(e);
|
||||
this.lastMouseX = coords.x;
|
||||
this.lastMouseY = coords.y;
|
||||
|
||||
if (this.isPanning) {
|
||||
const dx = coords.x - this.panStartX;
|
||||
const dy = coords.y - this.panStartY;
|
||||
this.renderer.setView(
|
||||
this.renderer.zoom,
|
||||
this.panStartOffsetX + dx,
|
||||
this.panStartOffsetY + dy
|
||||
);
|
||||
this.callbacks.onViewChange?.({
|
||||
zoom: this.renderer.zoom,
|
||||
offsetX: this.renderer.offsetX,
|
||||
offsetY: this.renderer.offsetY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||
|
||||
if (hitResult) {
|
||||
this.renderer.setHoveredRack(hitResult.rack);
|
||||
this.renderer.setHoveredDevice(hitResult.device);
|
||||
this.canvas.style.cursor = 'pointer';
|
||||
|
||||
if (hitResult.device) {
|
||||
this.callbacks.onDeviceHover?.(hitResult.device, hitResult.rack, coords.x, coords.y);
|
||||
} else {
|
||||
this.callbacks.onRackHover?.(hitResult.rack, coords.x, coords.y);
|
||||
this.callbacks.onDeviceHover?.(null);
|
||||
}
|
||||
} else {
|
||||
this.renderer.setHoveredRack(null);
|
||||
this.renderer.setHoveredDevice(null);
|
||||
this.canvas.style.cursor = 'default';
|
||||
this.callbacks.onRackHover?.(null);
|
||||
this.callbacks.onDeviceHover?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(e) {
|
||||
if (this.isPanning) {
|
||||
const dx = Math.abs((e ? this.getCanvasCoords(e).x : this.lastMouseX) - this.panStartX);
|
||||
const dy = Math.abs((e ? this.getCanvasCoords(e).y : this.lastMouseY) - this.panStartY);
|
||||
|
||||
if (dx < 5 && dy < 5) {
|
||||
const coords = e ? this.getCanvasCoords(e) : { x: this.lastMouseX, y: this.lastMouseY };
|
||||
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||
if (hitResult) {
|
||||
if (hitResult.device) {
|
||||
this.callbacks.onDeviceClick?.(hitResult.device, hitResult.rack);
|
||||
} else {
|
||||
this.callbacks.onRackClick?.(hitResult.rack);
|
||||
this.renderer.setSelectedRack(hitResult.rack);
|
||||
}
|
||||
} else {
|
||||
this.renderer.setSelectedRack(null);
|
||||
this.callbacks.onRackClick?.(null);
|
||||
}
|
||||
}
|
||||
this.isPanning = false;
|
||||
}
|
||||
this.canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
this.isPanning = false;
|
||||
this.renderer.setHoveredRack(null);
|
||||
this.renderer.setHoveredDevice(null);
|
||||
this.canvas.style.cursor = 'default';
|
||||
this.callbacks.onRackHover?.(null);
|
||||
this.callbacks.onDeviceHover?.(null);
|
||||
}
|
||||
|
||||
onWheel(e) {
|
||||
e.preventDefault();
|
||||
const coords = this.getCanvasCoords(e);
|
||||
const delta = -e.deltaY * ZOOM.WHEEL_FACTOR;
|
||||
const newZoom = Math.max(ZOOM.MIN, Math.min(ZOOM.MAX, this.renderer.zoom * (1 + delta)));
|
||||
|
||||
const scale = newZoom / this.renderer.zoom;
|
||||
const newOffsetX = coords.x - (coords.x - this.renderer.offsetX) * scale;
|
||||
const newOffsetY = coords.y - (coords.y - this.renderer.offsetY) * scale;
|
||||
|
||||
this.renderer.setView(newZoom, newOffsetX, newOffsetY);
|
||||
this.callbacks.onViewChange?.({
|
||||
zoom: newZoom,
|
||||
offsetX: newOffsetX,
|
||||
offsetY: newOffsetY,
|
||||
});
|
||||
}
|
||||
|
||||
onDoubleClick(e) {
|
||||
const coords = this.getCanvasCoords(e);
|
||||
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||
if (hitResult) {
|
||||
if (hitResult.device) {
|
||||
this.callbacks.onDeviceDoubleClick?.(hitResult.device, hitResult.rack);
|
||||
} else {
|
||||
this.callbacks.onRackDoubleClick?.(hitResult.rack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenu(e) {
|
||||
e.preventDefault();
|
||||
const coords = this.getCanvasCoords(e);
|
||||
const hitResult = this.renderer.hitTest(coords.x, coords.y);
|
||||
if (hitResult) {
|
||||
if (hitResult.device) {
|
||||
this.callbacks.onDeviceContextMenu?.(hitResult.device, hitResult.rack, coords.x, coords.y);
|
||||
} else {
|
||||
this.callbacks.onRackContextMenu?.(hitResult.rack, coords.x, coords.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTouchStart(e) {
|
||||
if (e.touches.length === 1) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.onMouseDown({
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
button: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onTouchMove(e) {
|
||||
if (e.touches.length === 1) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.onMouseMove({
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
this.onMouseUp({
|
||||
clientX: this.lastMouseX,
|
||||
clientY: this.lastMouseY,
|
||||
});
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
const center = {
|
||||
x: this.canvas.clientWidth / 2,
|
||||
y: this.canvas.clientHeight / 2,
|
||||
};
|
||||
const newZoom = Math.min(ZOOM.MAX, this.renderer.zoom + ZOOM.STEP);
|
||||
this.applyZoom(newZoom, center.x, center.y);
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
const center = {
|
||||
x: this.canvas.clientWidth / 2,
|
||||
y: this.canvas.clientHeight / 2,
|
||||
};
|
||||
const newZoom = Math.max(ZOOM.MIN, this.renderer.zoom - ZOOM.STEP);
|
||||
this.applyZoom(newZoom, center.x, center.y);
|
||||
}
|
||||
|
||||
zoomReset() {
|
||||
this.fitToView();
|
||||
}
|
||||
|
||||
fitToView() {
|
||||
const racks = this.renderer.racks;
|
||||
if (!racks || racks.length === 0) {
|
||||
this.renderer.setView(ZOOM.DEFAULT, 24, 24);
|
||||
this.callbacks.onViewChange?.({
|
||||
zoom: ZOOM.DEFAULT,
|
||||
offsetX: 24,
|
||||
offsetY: 24,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算所有机柜的边界框
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
racks.forEach(rack => {
|
||||
const bounds = this.renderer.getRackBounds(rack);
|
||||
minX = Math.min(minX, bounds.x);
|
||||
minY = Math.min(minY, bounds.y);
|
||||
maxX = Math.max(maxX, bounds.x + bounds.width);
|
||||
maxY = Math.max(maxY, bounds.y + bounds.height);
|
||||
});
|
||||
|
||||
// 添加padding
|
||||
const padding = 40;
|
||||
minX -= padding;
|
||||
minY -= padding;
|
||||
maxX += padding;
|
||||
maxY += padding;
|
||||
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
|
||||
// 计算合适的缩放比例
|
||||
const canvasWidth = this.canvas.clientWidth;
|
||||
const canvasHeight = this.canvas.clientHeight;
|
||||
const scaleX = canvasWidth / contentWidth;
|
||||
const scaleY = canvasHeight / contentHeight;
|
||||
const newZoom = Math.min(ZOOM.DEFAULT, scaleX, scaleY);
|
||||
|
||||
// 计算居中的偏移量
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
const newOffsetX = canvasWidth / 2 - centerX * newZoom;
|
||||
const newOffsetY = canvasHeight / 2 - centerY * newZoom;
|
||||
|
||||
this.renderer.setView(newZoom, newOffsetX, newOffsetY);
|
||||
this.callbacks.onViewChange?.({
|
||||
zoom: newZoom,
|
||||
offsetX: newOffsetX,
|
||||
offsetY: newOffsetY,
|
||||
});
|
||||
}
|
||||
|
||||
animateToRack(rack) {
|
||||
const bounds = this.renderer.getRackBounds(rack);
|
||||
const targetX = this.canvas.clientWidth / 2 - (bounds.x + bounds.width / 2) * this.renderer.zoom;
|
||||
const targetY = this.canvas.clientHeight / 2 - (bounds.y + bounds.height / 2) * this.renderer.zoom;
|
||||
this.animateTo(targetX, targetY);
|
||||
this.renderer.setSelectedRack(rack);
|
||||
this.renderer.setSearchHighlight(rack.rackId);
|
||||
setTimeout(() => this.renderer.setSearchHighlight(null), 3000);
|
||||
}
|
||||
|
||||
animateTo(targetX, targetY) {
|
||||
const startX = this.renderer.offsetX;
|
||||
const startY = this.renderer.offsetY;
|
||||
const duration = 400;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (time) => {
|
||||
const elapsed = time - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const currentX = startX + (targetX - startX) * eased;
|
||||
const currentY = startY + (targetY - startY) * eased;
|
||||
|
||||
this.renderer.setView(this.renderer.zoom, currentX, currentY);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
applyZoom(newZoom, centerX, centerY) {
|
||||
const scale = newZoom / this.renderer.zoom;
|
||||
const newOffsetX = centerX - (centerX - this.renderer.offsetX) * scale;
|
||||
const newOffsetY = centerY - (centerY - this.renderer.offsetY) * scale;
|
||||
|
||||
this.renderer.setView(newZoom, newOffsetX, newOffsetY);
|
||||
this.callbacks.onViewChange?.({
|
||||
zoom: newZoom,
|
||||
offsetX: newOffsetX,
|
||||
offsetY: newOffsetY,
|
||||
});
|
||||
}
|
||||
|
||||
setEditMode(editMode) {
|
||||
// 兼容旧API
|
||||
}
|
||||
}
|
||||
|
||||
export default CanvasInteraction;
|
||||
@@ -0,0 +1,507 @@
|
||||
import {
|
||||
CELL_WIDTH,
|
||||
CELL_HEIGHT,
|
||||
CELL_GAP,
|
||||
RACK_PADDING,
|
||||
RACK_HEADER_HEIGHT,
|
||||
RACK_STATUS_BAR_HEIGHT,
|
||||
U_HEIGHT,
|
||||
U_LABEL_WIDTH_LEFT,
|
||||
U_LABEL_WIDTH_RIGHT,
|
||||
U_BODY_WIDTH,
|
||||
DEVICE_INDICATOR_WIDTH,
|
||||
COLORS,
|
||||
RACK_STATUS_COLORS,
|
||||
DEVICE_TYPE_COLORS,
|
||||
DEVICE_STATUS_COLORS,
|
||||
FONT,
|
||||
} from './CanvasConstants';
|
||||
|
||||
class CanvasRenderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.dpr = window.devicePixelRatio || 1;
|
||||
this.room = null;
|
||||
this.racks = [];
|
||||
this.rackMap = new Map();
|
||||
this.zoom = 1;
|
||||
this.offsetX = 24;
|
||||
this.offsetY = 24;
|
||||
this.hoveredRack = null;
|
||||
this.hoveredDevice = null;
|
||||
this.selectedRack = null;
|
||||
this.viewMode = 'standard';
|
||||
this.dragPreview = null;
|
||||
this.dropTarget = null;
|
||||
this.searchHighlight = null;
|
||||
}
|
||||
|
||||
setDPR() {
|
||||
this.dpr = window.devicePixelRatio || 1;
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this.setDPR();
|
||||
this.canvas.width = width * this.dpr;
|
||||
this.canvas.height = height * this.dpr;
|
||||
this.canvas.style.width = `${width}px`;
|
||||
this.canvas.style.height = `${height}px`;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setData(room, racks) {
|
||||
this.room = room;
|
||||
this.racks = (racks || []).slice().sort((a, b) => {
|
||||
const nameA = a.name || '';
|
||||
const nameB = b.name || '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
this.rackMap.clear();
|
||||
this.racks.forEach(r => {
|
||||
if (r.rackId) this.rackMap.set(r.rackId, r);
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
setView(zoom, offsetX, offsetY) {
|
||||
this.zoom = zoom;
|
||||
this.offsetX = offsetX;
|
||||
this.offsetY = offsetY;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setHoveredRack(rack) {
|
||||
if (this.hoveredRack !== rack) {
|
||||
this.hoveredRack = rack;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
setHoveredDevice(device) {
|
||||
if (this.hoveredDevice !== device) {
|
||||
this.hoveredDevice = device;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedRack(rack) {
|
||||
if (this.selectedRack !== rack) {
|
||||
this.selectedRack = rack;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
setViewMode(mode, heatMapDimension) {
|
||||
this.viewMode = mode;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setDragPreview(rack, gridRow, gridCol) {
|
||||
this.dragPreview = rack ? { rack, gridRow, gridCol } : null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setDropTarget(row, col) {
|
||||
this.dropTarget = row != null ? { row, col } : null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setSearchHighlight(rackId) {
|
||||
this.searchHighlight = rackId;
|
||||
this.render();
|
||||
}
|
||||
|
||||
getRackBounds(rack) {
|
||||
const name = rack.name || '';
|
||||
const firstChar = name.charAt(0).toUpperCase();
|
||||
|
||||
let row = 0;
|
||||
if (/[A-Z]/.test(firstChar)) {
|
||||
row = firstChar.charCodeAt(0) - 65;
|
||||
}
|
||||
|
||||
const sameRowRacks = this.racks.filter(r => {
|
||||
const rName = r.name || '';
|
||||
const rFirst = rName.charAt(0).toUpperCase();
|
||||
let rRow = 0;
|
||||
if (/[A-Z]/.test(rFirst)) {
|
||||
rRow = rFirst.charCodeAt(0) - 65;
|
||||
}
|
||||
return rRow === row;
|
||||
}).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
const col = sameRowRacks.indexOf(rack);
|
||||
|
||||
const startX = 24;
|
||||
const startY = 24;
|
||||
const x = startX + col * (CELL_WIDTH + CELL_GAP);
|
||||
const y = startY + row * (CELL_HEIGHT + CELL_GAP);
|
||||
return { x, y, width: CELL_WIDTH, height: CELL_HEIGHT };
|
||||
}
|
||||
|
||||
getDeviceBounds(rack, device) {
|
||||
const rackBounds = this.getRackBounds(rack);
|
||||
const totalU = rack.height || 42;
|
||||
const bodyY = rackBounds.y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT;
|
||||
const bodyHeight = rackBounds.height - RACK_STATUS_BAR_HEIGHT - RACK_HEADER_HEIGHT;
|
||||
const uHeight = Math.min(U_HEIGHT, (bodyHeight - 8) / totalU);
|
||||
|
||||
const leftLabelW = U_LABEL_WIDTH_LEFT;
|
||||
const bodyX = rackBounds.x + RACK_PADDING + leftLabelW;
|
||||
const bodyW = U_BODY_WIDTH;
|
||||
|
||||
// 从底部开始计算坐标(和渲染逻辑一致)
|
||||
const bodyStartY = bodyY + 4 + (totalU * uHeight); // 底部Y坐标
|
||||
const deviceY = bodyStartY - device.position * uHeight;
|
||||
const deviceH = uHeight * (device.height || 1);
|
||||
|
||||
return { x: bodyX, y: deviceY, width: bodyW, height: deviceH };
|
||||
}
|
||||
|
||||
hitTest(canvasX, canvasY) {
|
||||
const viewX = (canvasX - this.offsetX) / this.zoom;
|
||||
const viewY = (canvasY - this.offsetY) / this.zoom;
|
||||
|
||||
for (let i = 0; i < this.racks.length; i++) {
|
||||
const rack = this.racks[i];
|
||||
const bounds = this.getRackBounds(rack);
|
||||
if (viewX >= bounds.x && viewX <= bounds.x + bounds.width && viewY >= bounds.y && viewY <= bounds.y + bounds.height) {
|
||||
// 先检测设备
|
||||
const devices = rack.Devices || [];
|
||||
for (const device of devices) {
|
||||
if (device.position != null) {
|
||||
const deviceBounds = this.getDeviceBounds(rack, device);
|
||||
if (viewX >= deviceBounds.x && viewX <= deviceBounds.x + deviceBounds.width &&
|
||||
viewY >= deviceBounds.y && viewY <= deviceBounds.y + deviceBounds.height) {
|
||||
return { rack, device };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rack, device: null };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.ctx || !this.room) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const { width, height } = this.canvas;
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(this.dpr, this.dpr);
|
||||
|
||||
const displayWidth = width / this.dpr;
|
||||
const displayHeight = height / this.dpr;
|
||||
|
||||
ctx.clearRect(0, 0, displayWidth, displayHeight);
|
||||
|
||||
// 优雅的渐变背景
|
||||
const bgGradient = ctx.createLinearGradient(0, 0, 0, displayHeight);
|
||||
bgGradient.addColorStop(0, '#f8fafc');
|
||||
bgGradient.addColorStop(0.5, '#f1f5f9');
|
||||
bgGradient.addColorStop(1, '#e2e8f0');
|
||||
ctx.fillStyle = bgGradient;
|
||||
ctx.fillRect(0, 0, displayWidth, displayHeight);
|
||||
|
||||
// 细微网格效果
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(148,163,184,0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
const gridSize = 40;
|
||||
for (let x = 0; x < displayWidth; x += gridSize) {
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, displayHeight);
|
||||
}
|
||||
for (let y = 0; y < displayHeight; y += gridSize) {
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(displayWidth, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.offsetX, this.offsetY);
|
||||
ctx.scale(this.zoom, this.zoom);
|
||||
|
||||
for (let i = 0; i < this.racks.length; i++) {
|
||||
const rack = this.racks[i];
|
||||
const bounds = this.getRackBounds(rack);
|
||||
this.drawRack(ctx, rack, bounds);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawRack(ctx, rack, bounds) {
|
||||
const { x, y, width, height } = bounds;
|
||||
const isSelected = this.selectedRack?.rackId === rack.rackId;
|
||||
const isHovered = this.hoveredRack?.rackId === rack.rackId;
|
||||
const isSearchHighlight = this.searchHighlight === rack.rackId;
|
||||
const statusColor = RACK_STATUS_COLORS[rack.status] || RACK_STATUS_COLORS.active;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
this.roundRect(ctx, x, y, width, height, 10);
|
||||
|
||||
// 渐变阴影
|
||||
if (isSelected) {
|
||||
ctx.shadowColor = 'rgba(22,119,255,0.25)';
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 6;
|
||||
} else if (isHovered) {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.15)';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 4;
|
||||
} else {
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.08)';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 3;
|
||||
}
|
||||
|
||||
// 机柜主体渐变背景
|
||||
const gradient = ctx.createLinearGradient(x, y, x, y + height);
|
||||
gradient.addColorStop(0, '#ffffff');
|
||||
gradient.addColorStop(1, '#f9fafb');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = isSelected ? '#1677ff' : isHovered ? '#bae7ff' : '#e5e7eb';
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
ctx.stroke();
|
||||
|
||||
if (isSearchHighlight) {
|
||||
ctx.strokeStyle = '#52c41a';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([10, 5]);
|
||||
ctx.strokeRect(x - 5, y - 5, width + 10, height + 10);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// 状态栏(带圆角)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 10, y);
|
||||
ctx.lineTo(x + width - 10, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + RACK_STATUS_BAR_HEIGHT);
|
||||
ctx.lineTo(x, y + RACK_STATUS_BAR_HEIGHT);
|
||||
ctx.lineTo(x, y + RACK_STATUS_BAR_HEIGHT);
|
||||
ctx.quadraticCurveTo(x, y, x + 10, y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = statusColor;
|
||||
ctx.fill();
|
||||
|
||||
// 标题栏背景(轻微渐变)
|
||||
const headerGradient = ctx.createLinearGradient(x, y + RACK_STATUS_BAR_HEIGHT, x, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT);
|
||||
headerGradient.addColorStop(0, '#fafafa');
|
||||
headerGradient.addColorStop(1, '#f5f5f5');
|
||||
ctx.fillStyle = headerGradient;
|
||||
ctx.fillRect(x, y + RACK_STATUS_BAR_HEIGHT, width, RACK_HEADER_HEIGHT);
|
||||
|
||||
// 标题栏底部细线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 10, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT);
|
||||
ctx.lineTo(x + width - 10, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT);
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// 机柜名称(居中)
|
||||
ctx.fillStyle = '#1f2937';
|
||||
ctx.font = FONT.RACK_NAME;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(rack.name, x + width / 2, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT / 2);
|
||||
|
||||
// U位和设备数量
|
||||
const deviceCount = rack.deviceCount || 0;
|
||||
const usageText = `${rack.height || 42}U • ${deviceCount}台`;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.font = FONT.RACK_INFO;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(usageText, x + width - RACK_PADDING, y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT / 2);
|
||||
|
||||
// U位区域
|
||||
const bodyY = y + RACK_STATUS_BAR_HEIGHT + RACK_HEADER_HEIGHT;
|
||||
const bodyHeight = height - RACK_STATUS_BAR_HEIGHT - RACK_HEADER_HEIGHT;
|
||||
this.drawRackBody(ctx, rack, x, bodyY, width, bodyHeight);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawRackBody(ctx, rack, x, y, w, h) {
|
||||
const totalU = rack.height || 42;
|
||||
const leftLabelW = U_LABEL_WIDTH_LEFT;
|
||||
const rightLabelW = U_LABEL_WIDTH_RIGHT;
|
||||
const bodyW = U_BODY_WIDTH;
|
||||
|
||||
// 计算各区域起始位置
|
||||
const leftLabelX = x + RACK_PADDING;
|
||||
const bodyX = x + RACK_PADDING + leftLabelW;
|
||||
const rightLabelX = x + w - RACK_PADDING - rightLabelW;
|
||||
|
||||
// U位高度
|
||||
const uHeight = Math.min(U_HEIGHT, (h - 8) / totalU);
|
||||
|
||||
// 设备数据 - 按position分组
|
||||
const devices = (rack.Devices || []).filter(d => d.position != null);
|
||||
const deviceByU = new Map();
|
||||
devices.forEach(d => {
|
||||
const startU = d.position;
|
||||
const endU = startU + (d.height || 1) - 1;
|
||||
for (let u = startU; u <= endU; u++) {
|
||||
deviceByU.set(u, d);
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染左侧U位标签(从下到上:1U在底部)
|
||||
this.drawULabels(ctx, leftLabelX, y + 4, leftLabelW, totalU, uHeight, 'left');
|
||||
|
||||
// 渲染设备区域和空闲U位(从下到上:1U在底部)
|
||||
// 从底部开始渲染
|
||||
const bodyStartY = y + 4 + (totalU * uHeight); // 底部Y坐标
|
||||
for (let u = 1; u <= totalU; u++) {
|
||||
// 计算当前U位的Y坐标(从底部向上)
|
||||
const currentY = bodyStartY - u * uHeight;
|
||||
const d = deviceByU.get(u);
|
||||
if (d && d.position === u) {
|
||||
const isHovered = this.hoveredDevice?.deviceId === d.deviceId;
|
||||
this.drawDevice(ctx, d, bodyX, currentY, bodyW, uHeight * (d.height || 1), isHovered);
|
||||
} else if (!d) {
|
||||
this.drawEmptyU(ctx, bodyX, currentY, bodyW, uHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染右侧U位标签(从下到上:1U在底部)
|
||||
this.drawULabels(ctx, rightLabelX, y + 4, rightLabelW, totalU, uHeight, 'right');
|
||||
}
|
||||
|
||||
drawULabels(ctx, x, y, width, totalU, uHeight, side) {
|
||||
ctx.font = FONT.U_LABEL;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// 从底部开始渲染标签(1U在底部)
|
||||
for (let u = 1; u <= totalU; u++) {
|
||||
// 从底部向上计算Y坐标
|
||||
const yPos = y + (totalU - u) * uHeight + uHeight / 2;
|
||||
const label = `${u}U`;
|
||||
|
||||
// 偶数U位颜色稍淡,创造层次感
|
||||
if (u % 2 === 0) {
|
||||
ctx.fillStyle = 'rgba(100,116,139,0.45)';
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(71,85,105,0.7)';
|
||||
}
|
||||
|
||||
ctx.fillText(label, x + width / 2, yPos);
|
||||
}
|
||||
}
|
||||
|
||||
drawEmptyU(ctx, x, y, w, h) {
|
||||
// 更淡的背景色
|
||||
ctx.fillStyle = 'rgba(148,163,184,0.08)';
|
||||
ctx.fillRect(x, y, w, h);
|
||||
|
||||
// 细微的分隔线
|
||||
ctx.strokeStyle = 'rgba(148,163,184,0.25)';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + h);
|
||||
ctx.lineTo(x + w, y + h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawDevice(ctx, device, x, y, w, h, isHovered) {
|
||||
const typeColor = DEVICE_TYPE_COLORS[device.type] || DEVICE_TYPE_COLORS.other;
|
||||
const statusColor = DEVICE_STATUS_COLORS[device.status] || DEVICE_STATUS_COLORS.offline;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// 绘制圆角
|
||||
const radius = 3;
|
||||
this.roundRect(ctx, x, y, w, h, radius);
|
||||
|
||||
// 悬停高亮
|
||||
if (isHovered) {
|
||||
ctx.fillStyle = 'rgba(22,119,255,0.15)';
|
||||
ctx.fill();
|
||||
ctx.shadowColor = 'rgba(22,119,255,0.3)';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowOffsetY = 2;
|
||||
} else {
|
||||
// 设备背景
|
||||
const bgGradient = ctx.createLinearGradient(x, y, x, y + h);
|
||||
bgGradient.addColorStop(0, '#ffffff');
|
||||
bgGradient.addColorStop(1, '#f9fafb');
|
||||
ctx.fillStyle = bgGradient;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 左侧类型色条(更宽更明显)
|
||||
ctx.fillStyle = typeColor;
|
||||
ctx.fillRect(x, y, 5, h);
|
||||
|
||||
// 设备边框
|
||||
ctx.strokeStyle = isHovered ? '#1677ff' : 'rgba(148,163,184,0.45)';
|
||||
ctx.lineWidth = isHovered ? 1.5 : 1;
|
||||
ctx.stroke();
|
||||
|
||||
// 状态圆点(更大更明显)
|
||||
ctx.fillStyle = statusColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + 12, y + h / 2, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 状态圆点外圈
|
||||
ctx.strokeStyle = isHovered ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// 设备名称
|
||||
ctx.fillStyle = isHovered ? '#0f172a' : '#374151';
|
||||
ctx.font = FONT.DEVICE_NAME;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
const dName = device.name || '未命名';
|
||||
ctx.fillText(this.truncateText(ctx, dName, w - 26), x + 22, y + h / 2);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
roundRect(ctx, x, y, w, h, r) {
|
||||
if (typeof r === 'number') {
|
||||
r = [r, r, r, r];
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r[0], y);
|
||||
ctx.lineTo(x + w - r[1], y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r[1]);
|
||||
ctx.lineTo(x + w, y + h - r[2]);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r[2], y + h);
|
||||
ctx.lineTo(x + r[3], y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r[3]);
|
||||
ctx.lineTo(x, y + r[0]);
|
||||
ctx.quadraticCurveTo(x, y, x + r[0], y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
truncateText(ctx, text, maxWidth) {
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let tr = text;
|
||||
while (tr.length > 0 && ctx.measureText(tr + '…').width > maxWidth) {
|
||||
tr = tr.slice(0, -1);
|
||||
}
|
||||
return tr + '…';
|
||||
}
|
||||
}
|
||||
|
||||
export default CanvasRenderer;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import CanvasRenderer from './CanvasRenderer';
|
||||
import CanvasInteraction from './CanvasInteraction';
|
||||
|
||||
const FloorPlanCanvas = forwardRef(({ room, racks, viewMode, heatMapDimension, editMode, onRackClick, onRackDoubleClick, onRackHover, onDeviceHover, onRackDragEnd, onViewChange }, ref) => {
|
||||
const canvasRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const rendererRef = useRef(null);
|
||||
const interactionRef = useRef(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getRenderer: () => rendererRef.current,
|
||||
getInteraction: () => interactionRef.current,
|
||||
zoomIn: () => interactionRef.current?.zoomIn(),
|
||||
zoomOut: () => interactionRef.current?.zoomOut(),
|
||||
zoomReset: () => interactionRef.current?.zoomReset(),
|
||||
fitToView: () => interactionRef.current?.fitToView(),
|
||||
animateToRack: (rack) => interactionRef.current?.animateToRack(rack),
|
||||
setSearchHighlight: (rackId) => rendererRef.current?.setSearchHighlight(rackId),
|
||||
}));
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (!containerRef.current || !rendererRef.current) return;
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
rendererRef.current.resize(width, height);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const renderer = new CanvasRenderer(canvasRef.current);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
const interaction = new CanvasInteraction(canvasRef.current, renderer, {
|
||||
onRackClick,
|
||||
onRackDoubleClick,
|
||||
onRackHover,
|
||||
onDeviceHover,
|
||||
onRackDragEnd,
|
||||
onViewChange,
|
||||
});
|
||||
interactionRef.current = interaction;
|
||||
|
||||
handleResize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
interaction.destroy();
|
||||
rendererRef.current = null;
|
||||
interactionRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.setData(room, racks || []);
|
||||
// 数据更新后自动居中显示
|
||||
if (room) {
|
||||
setTimeout(() => {
|
||||
interactionRef.current?.fitToView();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}, [room, racks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.setViewMode(viewMode, heatMapDimension);
|
||||
}
|
||||
}, [viewMode, heatMapDimension]);
|
||||
|
||||
useEffect(() => {
|
||||
if (interactionRef.current) {
|
||||
interactionRef.current.setEditMode(editMode);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rendererRef.current && room) {
|
||||
setTimeout(() => {
|
||||
interactionRef.current?.fitToView();
|
||||
}, 100);
|
||||
}
|
||||
}, [room?.roomId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FloorPlanCanvas.displayName = 'FloorPlanCanvas';
|
||||
|
||||
export default FloorPlanCanvas;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as FloorPlanCanvas } from './FloorPlanCanvas';
|
||||
export { default as CanvasRenderer } from './CanvasRenderer';
|
||||
export { default as CanvasInteraction } from './CanvasInteraction';
|
||||
export * from './CanvasConstants';
|
||||
Reference in New Issue
Block a user