refactor(floorplan): 重构平面图组件与样式
- 移除未使用的编辑模式、热力图、搜索等功能 - 将样式代码抽离到独立样式文件 - 优化工具栏和缩放控制组件实现 - 添加导出图片功能 - 简化上下文状态管理 - 优化设备提示框和空状态显示
This commit is contained in:
@@ -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