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
@@ -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';