feat(floorplan): 新增机房平面图功能模块

refactor(models): 为Room和Rack模型添加布局相关字段
feat(routes): 实现机房平面图相关API接口
feat(components): 添加平面图编辑器、画布渲染及交互组件
feat(hooks): 实现平面图数据管理和状态管理
feat(pages): 新增机房平面图页面入口
style(components): 优化平面图组件样式和交互体验
This commit is contained in:
zhang1106
2026-04-30 16:26:39 +08:00
parent 4ab5265882
commit 979c28fdcd
34 changed files with 3108 additions and 62 deletions
+9 -42
View File
@@ -7,7 +7,7 @@ import {
WarningOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { deviceAPI } from '../../api';
import { designTokens } from '../../config/theme';
const modalHeaderStyle = {
@@ -29,18 +29,6 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
const resetState = () => {
setStep('upload');
setIsPreviewing(false);
@@ -65,25 +53,18 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
setIsPreviewing(true);
try {
const formData = new FormData();
formData.append('csvFile', actualFile);
const response = await deviceAPI.importPreview(actualFile);
const response = await api.post('/devices/import-preview', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.data.success) {
setPreviewData(response.data.data);
if (response.success) {
setPreviewData(response.data);
setStep('preview');
} else {
message.error(response.data.error || '预览失败');
message.error(response.error || '预览失败');
resetState();
}
} catch (error) {
console.error('预览失败:', error);
message.error(error.response?.data?.error || '预览失败,请检查文件格式');
message.error(error || '预览失败,请检查文件格式');
resetState();
} finally {
setPreviewLoading(false);
@@ -173,12 +154,8 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
const handleDownloadTemplate = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/devices/import-template', {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const blob = new Blob([response.data], { type: 'text/csv; charset=gbk' });
const response = await deviceAPI.getImportTemplate();
const blob = new Blob([response], { type: 'text/csv; charset=gbk' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -189,17 +166,7 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('下载模板失败:', error);
if (error.response?.data) {
const text = await error.response.data.text();
try {
const json = JSON.parse(text);
message.error(json.message || '下载模板失败');
} catch {
message.error('下载模板失败');
}
} else {
message.error('下载模板失败');
}
message.error(error || '下载模板失败');
}
};
@@ -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';
@@ -0,0 +1,149 @@
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;
@@ -0,0 +1,56 @@
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;
@@ -0,0 +1,2 @@
export { default as LayoutEditor } from './LayoutEditor';
export { default as PositionValidator } from './PositionValidator';
@@ -0,0 +1,3 @@
export { FloorPlanCanvas } from './canvas';
export { FloorPlanToolbar } from './toolbar';
export { RackDetailPanel, FloorPlanStats } from './panels';
@@ -0,0 +1,104 @@
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;
@@ -0,0 +1,43 @@
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;
@@ -0,0 +1,145 @@
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;
@@ -0,0 +1,112 @@
import React from 'react';
import { Drawer, Descriptions, Tag, Progress, Button, Space } from 'antd';
import {
CloudServerOutlined,
ThunderboltOutlined,
RightOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { RACK_STATUS_COLORS } from '../canvas/CanvasConstants';
const statusMap = {
active: { text: '在用', color: 'blue' },
maintenance: { text: '维护中', color: 'orange' },
inactive: { text: '停用', color: 'default' },
};
const RackDetailPanel = ({ rack, visible, onClose }) => {
const navigate = useNavigate();
if (!rack) return null;
const statusInfo = statusMap[rack.status] || { text: rack.status, color: 'default' };
const utilization = rack.utilization != null ? Math.round(rack.utilization * 100) : 0;
const powerPercent = rack.maxPower > 0
? Math.round(((rack.currentPower || 0) / rack.maxPower) * 100)
: 0;
const handleView3D = () => {
navigate('/visualization-3d', { state: { rackId: rack.rackId } });
};
const handleViewDevices = () => {
navigate('/devices', { state: { rackId: rack.rackId } });
};
return (
<Drawer
title={
<Space>
<CloudServerOutlined style={{ color: RACK_STATUS_COLORS[rack.status] || '#1677ff' }} />
{rack.name}
</Space>
}
placement="right"
width={360}
open={visible}
onClose={onClose}
extra={
<Space>
<Button
size="small"
icon={<EyeOutlined />}
onClick={handleView3D}
>
3D视图
</Button>
<Button
size="small"
icon={<RightOutlined />}
onClick={handleViewDevices}
>
设备
</Button>
</Space>
}
>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="机柜ID">{rack.rackId}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={statusInfo.color}>{statusInfo.text}</Tag>
</Descriptions.Item>
<Descriptions.Item label="位置">
{rack.rowPos != null && rack.colPos != null
? `${rack.rowPos + 1}排 第${rack.colPos + 1}`
: '未分配'}
</Descriptions.Item>
<Descriptions.Item label="朝向">{rack.facing || 'front'}</Descriptions.Item>
<Descriptions.Item label="高度">{rack.height}U</Descriptions.Item>
<Descriptions.Item label="设备数量">{rack.deviceCount || 0} </Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>U位使用率</div>
<Progress
percent={utilization}
strokeColor={
utilization < 30 ? '#52c41a' :
utilization < 70 ? '#faad14' : '#ff4d4f'
}
format={() => `${rack.usedU || 0}/${rack.totalU || rack.height || 0}U`}
/>
</div>
<div style={{ marginTop: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
<ThunderboltOutlined /> 功率
</div>
<Progress
percent={powerPercent}
strokeColor={
powerPercent < 50 ? '#52c41a' :
powerPercent < 80 ? '#faad14' : '#ff4d4f'
}
format={() => `${rack.currentPower || 0}/${rack.maxPower || 0}W`}
/>
</div>
</Drawer>
);
};
export default RackDetailPanel;
@@ -0,0 +1,3 @@
export { default as RackDetailPanel } from './RackDetailPanel';
export { default as FloorPlanStats } from './FloorPlanStats';
export { default as HeatMapLegend } from './HeatMapLegend';
@@ -0,0 +1,121 @@
import React from 'react';
import { Space, Tooltip } from 'antd';
import { ReloadOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
import RoomSelector from './RoomSelector';
import ZoomControls from './ZoomControls';
const FloorPlanToolbar = ({
selectedRoomId,
onRoomChange,
zoom,
onZoomIn,
onZoomOut,
onZoomReset,
onRefresh,
isFullscreen,
onToggleFullscreen,
}) => {
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,
}}
>
<Space size={14} align="center">
<RoomSelector
selectedRoomId={selectedRoomId}
onRoomChange={onRoomChange}
/>
<div style={{
width: 1,
height: 28,
background: '#e8e8e8',
}} />
<ZoomControls
zoom={zoom}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
onZoomReset={onZoomReset}
/>
</Space>
<Space size={10} align="center">
<Tooltip title={isFullscreen ? '退出全屏' : '全屏查看'} placement="bottom">
<button
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>
</Tooltip>
{onRefresh && (
<Tooltip title="刷新数据" placement="bottom">
<button
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>
</Tooltip>
)}
</Space>
</div>
);
};
export default FloorPlanToolbar;
@@ -0,0 +1,56 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Select, Spin } from 'antd';
import { HomeOutlined } from '@ant-design/icons';
import axios from 'axios';
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]);
useEffect(() => {
fetchRooms();
}, []);
return (
<Select
value={selectedRoomId}
onChange={onRoomChange}
style={{ minWidth: 180 }}
placeholder="选择机房"
suffixIcon={loading ? <Spin size="small" /> : <HomeOutlined />}
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',
}} />
{r.name}
</div>
),
}))}
notFoundContent={loading ? <Spin size="small" /> : '暂无机房'}
size="middle"
/>
);
};
export default RoomSelector;
@@ -0,0 +1,40 @@
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;
@@ -0,0 +1,52 @@
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;
@@ -0,0 +1,127 @@
import React from 'react';
import { Button, Space, Tooltip } from 'antd';
import { ZoomInOutlined, ZoomOutOutlined, OneToOneOutlined } from '@ant-design/icons';
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 }}>
<Tooltip title="缩小" placement="bottom">
<button
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>
</Tooltip>
<div style={{
minWidth: 52,
textAlign: 'center',
fontSize: 11,
color: '#595959',
userSelect: 'none',
fontWeight: 500,
padding: '0 4px',
}}>
{percent}%
</div>
<Tooltip title="放大" placement="bottom">
<button
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>
</Tooltip>
<Tooltip title="重置缩放" placement="bottom">
<button
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>
</Tooltip>
</Space>
);
};
export default ZoomControls;
@@ -0,0 +1,5 @@
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';