refactor(floorplan): 重构平面图组件与样式
- 移除未使用的编辑模式、热力图、搜索等功能 - 将样式代码抽离到独立样式文件 - 优化工具栏和缩放控制组件实现 - 添加导出图片功能 - 简化上下文状态管理 - 优化设备提示框和空状态显示
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Space
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: '#fff',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<ApartmentOutlined />}
|
||||
onClick={() => {
|
||||
initForm.setFieldsValue({
|
||||
gridRows: room?.gridRows || 10,
|
||||
gridCols: room?.gridCols || 10,
|
||||
});
|
||||
setInitModalVisible(true);
|
||||
}}
|
||||
>
|
||||
初始化布局
|
||||
</Button>
|
||||
|
||||
{pendingPositions.length > 0 && (
|
||||
<span style={{ color: '#faad14', fontSize: 12 }}>
|
||||
{pendingPositions.length} 处待保存
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Popconfirm
|
||||
title="确定取消编辑?未保存的更改将丢失"
|
||||
onConfirm={handleCancel}
|
||||
okText="确定"
|
||||
cancelText="继续编辑"
|
||||
>
|
||||
<Button icon={<CloseOutlined />}>取消</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
disabled={pendingPositions.length === 0}
|
||||
>
|
||||
保存布局
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title="初始化机房布局"
|
||||
open={initModalVisible}
|
||||
onOk={handleInitLayout}
|
||||
onCancel={() => setInitModalVisible(false)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={initForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="gridRows"
|
||||
label="行数(排数)"
|
||||
rules={[{ required: true, message: '请输入行数' }]}
|
||||
>
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="gridCols"
|
||||
label="列数"
|
||||
rules={[{ required: true, message: '请输入列数' }]}
|
||||
>
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<p style={{ color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
|
||||
初始化将按行列顺序自动排列所有机柜,已有位置信息将被覆盖。
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutEditor;
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as LayoutEditor } from './LayoutEditor';
|
||||
export { default as PositionValidator } from './PositionValidator';
|
||||
@@ -1,3 +1,3 @@
|
||||
export { FloorPlanCanvas } from './canvas';
|
||||
export { FloorPlanToolbar } from './toolbar';
|
||||
export { RackDetailPanel, FloorPlanStats } from './panels';
|
||||
export { RackDetailPanel } from './panels';
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
border: '1px solid #e8e8e8',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<DatabaseOutlined style={{ color: '#1677ff', fontSize: 14 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'rgba(0,0,0,0.85)' }}>
|
||||
概览
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'rgba(0,0,0,0.45)' }}>
|
||||
{stats.totalRacks} 台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, marginBottom: 10 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#1677ff', lineHeight: 1.2 }}>
|
||||
{stats.activeRacks}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.65)', marginTop: 2 }}>
|
||||
在用
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#faad14', lineHeight: 1.2 }}>
|
||||
{stats.maintenanceRacks}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.65)', marginTop: 2 }}>
|
||||
维护
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#bfbfbf', lineHeight: 1.2 }}>
|
||||
{stats.inactiveRacks}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(0,0,0,0.65)', marginTop: 2 }}>
|
||||
停用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
}}>
|
||||
<BulbOutlined style={{ fontSize: 12, color: '#1677ff' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||
{Math.round((stats.avgUtilization || 0) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
background: '#fff7e6',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 12, color: '#faad14' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||
{stats.totalCurrentPower || 0}W
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloorPlanStats;
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HEAT_MAP } from '../canvas/CanvasConstants';
|
||||
|
||||
const HeatMapLegend = ({ dimension }) => {
|
||||
const labels = {
|
||||
utilization: 'U位使用率',
|
||||
power: '功率负载',
|
||||
density: '设备密度',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
background: 'rgba(255,255,255,0.92)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 4, fontWeight: 500, color: 'rgba(0,0,0,0.65)' }}>
|
||||
{labels[dimension] || '热力图'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ color: 'rgba(0,0,0,0.45)' }}>低</span>
|
||||
<div
|
||||
style={{
|
||||
width: 120,
|
||||
height: 12,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(to right, ${HEAT_MAP.LOW}, ${HEAT_MAP.MEDIUM}, ${HEAT_MAP.HIGH})`,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'rgba(0,0,0,0.45)' }}>高</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeatMapLegend;
|
||||
@@ -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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
width: MINI_MAP_SIZE,
|
||||
height: MINI_MAP_SIZE,
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniMap;
|
||||
@@ -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 }) => {
|
||||
<Descriptions.Item label="设备数量">{rack.deviceCount || 0} 台</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>U位使用率</div>
|
||||
<DetailSection>
|
||||
<DetailSectionTitle>U位使用率</DetailSectionTitle>
|
||||
<Progress
|
||||
percent={utilization}
|
||||
strokeColor={
|
||||
@@ -90,12 +91,12 @@ const RackDetailPanel = ({ rack, visible, onClose }) => {
|
||||
}
|
||||
format={() => `${rack.usedU || 0}/${rack.totalU || rack.height || 0}U`}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>
|
||||
<DetailSection>
|
||||
<DetailSectionTitle>
|
||||
<ThunderboltOutlined /> 功率
|
||||
</div>
|
||||
</DetailSectionTitle>
|
||||
<Progress
|
||||
percent={powerPercent}
|
||||
strokeColor={
|
||||
@@ -104,7 +105,7 @@ const RackDetailPanel = ({ rack, visible, onClose }) => {
|
||||
}
|
||||
format={() => `${rack.currentPower || 0}/${rack.maxPower || 0}W`}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export { default as RackDetailPanel } from './RackDetailPanel';
|
||||
export { default as FloorPlanStats } from './FloorPlanStats';
|
||||
export { default as HeatMapLegend } from './HeatMapLegend';
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 20px',
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.05)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.03)',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<ToolbarWrapper>
|
||||
<Space size={14} align="center">
|
||||
<RoomSelector
|
||||
selectedRoomId={selectedRoomId}
|
||||
onRoomChange={onRoomChange}
|
||||
/>
|
||||
<div style={{
|
||||
width: 1,
|
||||
height: 28,
|
||||
background: '#e8e8e8',
|
||||
}} />
|
||||
<ToolbarDivider />
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomIn={onZoomIn}
|
||||
@@ -48,73 +34,38 @@ const FloorPlanToolbar = ({
|
||||
</Space>
|
||||
|
||||
<Space size={10} align="center">
|
||||
{onExport && (
|
||||
<Tooltip title="导出图片" placement="bottom">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onExport}
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={isFullscreen ? '退出全屏' : '全屏查看'} placement="bottom">
|
||||
<button
|
||||
<Button
|
||||
type="text"
|
||||
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
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 ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
</button>
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{onRefresh && (
|
||||
<Tooltip title="刷新数据" placement="bottom">
|
||||
<button
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</ToolbarWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#1677ff',
|
||||
}} />
|
||||
<RoomOptionContent>
|
||||
<RoomIndicator />
|
||||
{r.name}
|
||||
</div>
|
||||
</RoomOptionContent>
|
||||
),
|
||||
}))}
|
||||
notFoundContent={loading ? <Spin size="small" /> : '暂无机房'}
|
||||
|
||||
@@ -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 (
|
||||
<Input.Search
|
||||
size="small"
|
||||
placeholder="搜索机柜..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -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 (
|
||||
<Space size={8}>
|
||||
<Radio.Group
|
||||
value={editMode ? 'edit' : viewMode}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === 'edit') {
|
||||
onEditModeChange(true);
|
||||
onViewModeChange('standard');
|
||||
} else {
|
||||
onEditModeChange(false);
|
||||
onViewModeChange(val);
|
||||
}
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="standard">
|
||||
<AppstoreOutlined /> 标准
|
||||
</Radio.Button>
|
||||
<Radio.Button value="heatmap">
|
||||
<FireOutlined /> 热力图
|
||||
</Radio.Button>
|
||||
<Radio.Button value="edit">
|
||||
<EditOutlined /> 编辑
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{viewMode === 'heatmap' && !editMode && (
|
||||
<Select
|
||||
value={heatMapDimension}
|
||||
onChange={(val) => onViewModeChange('heatmap', val)}
|
||||
size="small"
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'utilization', label: 'U位使用率' },
|
||||
{ value: 'power', label: '功率负载' },
|
||||
{ value: 'density', label: '设备密度' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewModeSwitch;
|
||||
@@ -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 (
|
||||
<Space size={6} align="center" style={{ background: '#f8f9fa', padding: '6px 10px', borderRadius: 8 }}>
|
||||
<ZoomControlsWrapper>
|
||||
<Tooltip title="缩小" placement="bottom">
|
||||
<button
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomOutOutlined />}
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<ZoomOutOutlined />
|
||||
</button>
|
||||
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{
|
||||
minWidth: 52,
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
color: '#595959',
|
||||
userSelect: 'none',
|
||||
fontWeight: 500,
|
||||
padding: '0 4px',
|
||||
}}>
|
||||
{percent}%
|
||||
</div>
|
||||
<ZoomPercent>{percent}%</ZoomPercent>
|
||||
|
||||
<Tooltip title="放大" placement="bottom">
|
||||
<button
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomInOutlined />}
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<ZoomInOutlined />
|
||||
</button>
|
||||
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="重置缩放" placement="bottom">
|
||||
<button
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<OneToOneOutlined />}
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<OneToOneOutlined />
|
||||
</button>
|
||||
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</ZoomControlsWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
left: x + 18,
|
||||
top: y + 18,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
padding: '14px 18px',
|
||||
zIndex: 1000,
|
||||
minWidth: 220,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div style={{ width: 5, height: 20, background: typeColor, borderRadius: 3 }} />
|
||||
<strong style={{ fontSize: 15, color: '#111827' }}>{device.name}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#4b5563', lineHeight: '22px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#6b7280' }}>类型:</span>
|
||||
<span style={{ fontWeight: 500 }}>{DEVICE_TYPE_NAMES[device.type] || device.type}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#6b7280' }}>状态:</span>
|
||||
<span style={{ color: statusColor, fontWeight: 500 }}>
|
||||
{DEVICE_STATUS_NAMES[device.status] || device.status}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#6b7280' }}>位置:</span>
|
||||
<span style={{ fontWeight: 500 }}>{rack?.name} - {device.position}U</span>
|
||||
</div>
|
||||
<DeviceTooltipContainer $x={x} $y={y}>
|
||||
<DeviceTooltipHeader>
|
||||
<DeviceTypeIndicator $color={typeColor} />
|
||||
<DeviceName>{device.name}</DeviceName>
|
||||
</DeviceTooltipHeader>
|
||||
<DeviceInfoContent>
|
||||
<DeviceInfoRow>
|
||||
<DeviceInfoLabel>类型:</DeviceInfoLabel>
|
||||
<DeviceInfoValue>{DEVICE_TYPE_NAMES[device.type] || device.type}</DeviceInfoValue>
|
||||
</DeviceInfoRow>
|
||||
<DeviceInfoRow>
|
||||
<DeviceInfoLabel>状态:</DeviceInfoLabel>
|
||||
<DeviceInfoValue $color={statusColor}>{DEVICE_STATUS_NAMES[device.status] || device.status}</DeviceInfoValue>
|
||||
</DeviceInfoRow>
|
||||
<DeviceInfoRow>
|
||||
<DeviceInfoLabel>位置:</DeviceInfoLabel>
|
||||
<DeviceInfoValue>{rack?.name} - {device.position}U</DeviceInfoValue>
|
||||
</DeviceInfoRow>
|
||||
{device.ipAddress && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#6b7280' }}>IP:</span>
|
||||
<span style={{ fontFamily: 'SFMono-Regular,Monaco,Consolas', fontWeight: 500 }}>
|
||||
{device.ipAddress}
|
||||
</span>
|
||||
</div>
|
||||
<DeviceInfoRow>
|
||||
<DeviceInfoLabel>IP:</DeviceInfoLabel>
|
||||
<DeviceInfoValue $mono>{device.ipAddress}</DeviceInfoValue>
|
||||
</DeviceInfoRow>
|
||||
)}
|
||||
{device.model && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#6b7280' }}>型号:</span>
|
||||
<span style={{ fontWeight: 500 }}>{device.model}</span>
|
||||
</div>
|
||||
<DeviceInfoRow>
|
||||
<DeviceInfoLabel>型号:</DeviceInfoLabel>
|
||||
<DeviceInfoValue>{device.model}</DeviceInfoValue>
|
||||
</DeviceInfoRow>
|
||||
)}
|
||||
{device.height > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#6b7280' }}>高度:</span>
|
||||
<span style={{ fontWeight: 500 }}>{device.height}U</span>
|
||||
</div>
|
||||
<DeviceInfoRow>
|
||||
<DeviceInfoLabel>高度:</DeviceInfoLabel>
|
||||
<DeviceInfoValue>{device.height}U</DeviceInfoValue>
|
||||
</DeviceInfoRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DeviceInfoContent>
|
||||
</DeviceTooltipContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const UnassignedRacksPanel = ({ racks, onAutoAssign }) => {
|
||||
const unassigned = (racks || []).filter(r => r.rowPos == null || r.colPos == null);
|
||||
|
||||
if (unassigned.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
border: '1px solid #ffe58f',
|
||||
borderRadius: 8,
|
||||
background: '#fffbe6',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WarningOutlined style={{ color: '#faad14', fontSize: 14 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#ad6800' }}>
|
||||
未分配
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
background: '#ffd666',
|
||||
color: '#874d00',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 10,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{unassigned.length}台
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
{unassigned.slice(0, 6).map((r, i) => (
|
||||
<span
|
||||
key={r.rackId || i}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: '#fff',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #ffe58f',
|
||||
color: 'rgba(0,0,0,0.65)',
|
||||
}}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
{unassigned.length > 6 && (
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
color: 'rgba(0,0,0,0.45)',
|
||||
padding: '3px 4px',
|
||||
}}>
|
||||
+{unassigned.length - 6}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAutoAssign}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: 12,
|
||||
padding: '6px 0',
|
||||
background: '#faad14',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#d48806'}
|
||||
onMouseLeave={(e) => e.target.style.background = '#faad14'}
|
||||
>
|
||||
<SwapOutlined />
|
||||
一键分配
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const EmptyState = ({ onRefresh }) => (
|
||||
<EmptyStateContainer>
|
||||
<Empty
|
||||
image={<HomeOutlined style={{ fontSize: 64, color: '#d9d9d9' }} />}
|
||||
description={
|
||||
<>
|
||||
<EmptyStateTitle>暂无机房数据</EmptyStateTitle>
|
||||
<EmptyStateSubtitle>请先在机房管理中创建机房,或检查网络连接</EmptyStateSubtitle>
|
||||
{onRefresh && (
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</EmptyStateContainer>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<ContentWrapper>
|
||||
<FloorPlanToolbar
|
||||
selectedRoomId={selectedRoomId}
|
||||
onRoomChange={setSelectedRoom}
|
||||
@@ -356,57 +218,26 @@ const FloorPlanContent = () => {
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={handleToggleFullscreen}
|
||||
onRefresh={refetch}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CanvasContainer ref={containerRef}>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
zIndex: 5,
|
||||
}}>
|
||||
<LoadingOverlay>
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
</LoadingOverlay>
|
||||
)}
|
||||
|
||||
{!selectedRoomId && (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Empty description="请选择一个机房查看平面图" />
|
||||
</div>
|
||||
)}
|
||||
{!selectedRoomId && <EmptyState onRefresh={refetch} />}
|
||||
|
||||
{selectedRoomId && layoutData && (
|
||||
<FloorPlanCanvas
|
||||
ref={canvasRef}
|
||||
room={layoutData.room}
|
||||
racks={layoutData.racks}
|
||||
viewMode="standard"
|
||||
heatMapDimension="utilization"
|
||||
editMode={false}
|
||||
onRackClick={handleRackClick}
|
||||
onRackDoubleClick={handleRackDoubleClick}
|
||||
onRackHover={handleRackHover}
|
||||
onDeviceHover={handleDeviceHover}
|
||||
onRackDragEnd={() => {}}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
@@ -417,25 +248,23 @@ const FloorPlanContent = () => {
|
||||
x={tooltipPosition.x}
|
||||
y={tooltipPosition.y}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
</CanvasContainer>
|
||||
|
||||
<RackDetailPanel
|
||||
rack={detailRack}
|
||||
visible={detailVisible}
|
||||
onClose={hideDetail}
|
||||
/>
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomFloorPlan = () => {
|
||||
return (
|
||||
<FloorPlanProvider>
|
||||
<div style={{ height: 'calc(100vh - 64px)' }}>
|
||||
<PageContainer>
|
||||
<FloorPlanContent />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</FloorPlanProvider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user