feat: 添加响应式3D机柜可视化组件和钩子
重构3D机柜可视化页面,添加以下功能: 1. 新增 useResponsiveLayout 钩子处理响应式布局 2. 新增 useSortedRacks 钩子实现机柜排序和筛选 3. 添加 CascadingRackPanel 级联选择面板组件 4. 实现 RackSelectorHeader 响应式头部导航组件 5. 优化页面结构和交互逻辑
This commit is contained in:
@@ -0,0 +1,409 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Input, Badge } from 'antd';
|
||||||
|
import { SearchOutlined, CloseOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const CascadingRackPanel = ({
|
||||||
|
rooms,
|
||||||
|
selectedRoomKey,
|
||||||
|
selectedRackId,
|
||||||
|
onSelect,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
triggerRef,
|
||||||
|
}) => {
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [activeRoomKey, setActiveRoomKey] = useState(null);
|
||||||
|
const [hoveredRackId, setHoveredRackId] = useState(null);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
const searchInputRef = useRef(null);
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && searchInputRef.current) {
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
if (!visible) {
|
||||||
|
setSearchText('');
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
hasInitializedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && !hasInitializedRef.current) {
|
||||||
|
hasInitializedRef.current = true;
|
||||||
|
if (selectedRoomKey) {
|
||||||
|
setActiveRoomKey(selectedRoomKey);
|
||||||
|
} else if (rooms && rooms.length > 0) {
|
||||||
|
setActiveRoomKey(rooms[0].key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [visible, selectedRoomKey, rooms]);
|
||||||
|
|
||||||
|
const filteredRooms = useMemo(() => {
|
||||||
|
if (!searchText.trim()) return rooms;
|
||||||
|
|
||||||
|
const lowerSearch = searchText.toLowerCase().trim();
|
||||||
|
|
||||||
|
return rooms
|
||||||
|
.map((room) => {
|
||||||
|
const roomNameMatch = room.name?.toLowerCase().includes(lowerSearch);
|
||||||
|
const roomIdMatch = room.roomId?.toLowerCase().includes(lowerSearch);
|
||||||
|
|
||||||
|
if (roomNameMatch || roomIdMatch) {
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRacks = room.racks.filter(
|
||||||
|
(rack) =>
|
||||||
|
rack.name?.toLowerCase().includes(lowerSearch) ||
|
||||||
|
rack.rackId?.toLowerCase().includes(lowerSearch)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredRacks.length > 0) {
|
||||||
|
return { ...room, racks: filteredRacks };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((room) => room !== null);
|
||||||
|
}, [rooms, searchText]);
|
||||||
|
|
||||||
|
const flatRackList = useMemo(() => {
|
||||||
|
const list = [];
|
||||||
|
filteredRooms.forEach((room) => {
|
||||||
|
room.racks.forEach((rack) => {
|
||||||
|
list.push({ ...rack, roomKey: room.key, roomName: room.name });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [filteredRooms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) => Math.min(prev + 1, flatRackList.length - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusedIndex >= 0 && focusedIndex < flatRackList.length) {
|
||||||
|
const rack = flatRackList[focusedIndex];
|
||||||
|
onSelect(rack);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [visible, focusedIndex, flatRackList, onSelect, onClose]);
|
||||||
|
|
||||||
|
const getUsageColor = (percent) => {
|
||||||
|
if (percent >= 90) return '#ef4444';
|
||||||
|
if (percent >= 70) return '#f59e0b';
|
||||||
|
return '#22c55e';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsageBadgeStatus = (percent) => {
|
||||||
|
if (percent >= 90) return 'error';
|
||||||
|
if (percent >= 70) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRackSelect = useCallback(
|
||||||
|
(rack, room) => {
|
||||||
|
onSelect(rack, room);
|
||||||
|
},
|
||||||
|
[onSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePanelClick = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
onClick={handlePanelClick}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
marginTop: 8,
|
||||||
|
width: 520,
|
||||||
|
maxHeight: 480,
|
||||||
|
background: 'rgba(15, 23, 42, 0.98)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: 1000,
|
||||||
|
animation: 'slideDown 0.2s ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rack-panel-item:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.15) !important;
|
||||||
|
}
|
||||||
|
.rack-panel-item.focused {
|
||||||
|
background: rgba(59, 130, 246, 0.25) !important;
|
||||||
|
}
|
||||||
|
.rack-panel-item.selected {
|
||||||
|
background: rgba(59, 130, 246, 0.2) !important;
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
}}
|
||||||
|
placeholder="搜索机房或机柜..."
|
||||||
|
prefix={<SearchOutlined style={{ color: 'rgba(255, 255, 255, 0.4)' }} />}
|
||||||
|
suffix={
|
||||||
|
searchText && (
|
||||||
|
<CloseOutlined
|
||||||
|
style={{ color: 'rgba(255, 255, 255, 0.4)', cursor: 'pointer' }}
|
||||||
|
onClick={() => setSearchText('')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'white',
|
||||||
|
height: 40,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: 380,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '8px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredRooms.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 40,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
未找到匹配的机房或机柜
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRooms.map((room, roomIndex) => (
|
||||||
|
<div key={room.key} style={{ marginBottom: 4 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background:
|
||||||
|
activeRoomKey === room.key
|
||||||
|
? 'rgba(59, 130, 246, 0.1)'
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
setActiveRoomKey(activeRoomKey === room.key ? null : room.key)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DatabaseOutlined style={{ color: '#60a5fa', fontSize: 14 }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: '#f8fafc',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{room.racks.length} 机柜
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
fontSize: 10,
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
transform: activeRoomKey === room.key ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeRoomKey === room.key && (
|
||||||
|
<div style={{ paddingLeft: 16 }}>
|
||||||
|
{room.racks.map((rack, rackIndex) => {
|
||||||
|
const globalIndex = flatRackList.findIndex(
|
||||||
|
(r) => r.rackId === rack.rackId
|
||||||
|
);
|
||||||
|
const deviceCount = rack.Devices?.length || rack.deviceCount || 0;
|
||||||
|
const height = rack.height || 45;
|
||||||
|
const usedU = deviceCount * 2;
|
||||||
|
const usagePercent = Math.min(100, Math.round((usedU / height) * 100));
|
||||||
|
|
||||||
|
const isSelected = rack.rackId === selectedRackId;
|
||||||
|
const isFocused = globalIndex === focusedIndex;
|
||||||
|
const isHovered = rack.rackId === hoveredRackId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rack.rackId}
|
||||||
|
className={`rack-panel-item ${
|
||||||
|
isSelected ? 'selected' : isFocused ? 'focused' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 8,
|
||||||
|
margin: '2px 8px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
borderLeft: isSelected ? '3px solid #3b82f6' : '3px solid transparent',
|
||||||
|
background: isHovered
|
||||||
|
? 'rgba(59, 130, 246, 0.15)'
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => handleRackSelect(rack, room)}
|
||||||
|
onMouseEnter={() => setHoveredRackId(rack.rackId)}
|
||||||
|
onMouseLeave={() => setHoveredRackId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: getUsageColor(usagePercent),
|
||||||
|
boxShadow: `0 0 6px ${getUsageColor(usagePercent)}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#f8fafc',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rack.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
fontSize: 11,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{height}U</span>
|
||||||
|
<span>{deviceCount} 设备</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
status={getUsageBadgeStatus(usagePercent)}
|
||||||
|
text={
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
usagePercent >= 70
|
||||||
|
? usagePercent >= 90
|
||||||
|
? '#ef4444'
|
||||||
|
: '#f59e0b'
|
||||||
|
: '#22c55e',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{usagePercent}%
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.4)', fontSize: 11 }}>
|
||||||
|
使用 ↑↓ 键导航,Enter 确认,Esc 关闭
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.3)', fontSize: 11 }}>
|
||||||
|
{flatRackList.length} 个机柜
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CascadingRackPanel;
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { Button, Tooltip, Dropdown } from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FullscreenOutlined,
|
||||||
|
MenuOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import CascadingRackPanel from './CascadingRackPanel';
|
||||||
|
import { useResponsiveLayout } from '../../hooks/useResponsiveLayout';
|
||||||
|
|
||||||
|
const ACTION_BUTTONS_CONFIG = [
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
icon: <ReloadOutlined />,
|
||||||
|
label: '刷新',
|
||||||
|
tooltip: '刷新数据',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'resetView',
|
||||||
|
icon: <EyeOutlined />,
|
||||||
|
label: '重置视角',
|
||||||
|
tooltip: '重置3D视图',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'config',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '显示配置',
|
||||||
|
tooltip: '配置显示字段',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const RackSelectorHeader = ({
|
||||||
|
rooms,
|
||||||
|
selectedRoomKey,
|
||||||
|
selectedRack,
|
||||||
|
onRackSelect,
|
||||||
|
onPrevRack,
|
||||||
|
onNextRack,
|
||||||
|
racksInSelectedRoom,
|
||||||
|
deviceSlideEnabled,
|
||||||
|
onDeviceSlideToggle,
|
||||||
|
onRefresh,
|
||||||
|
onResetView,
|
||||||
|
onOpenConfig,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const [selectorVisible, setSelectorVisible] = useState(false);
|
||||||
|
const selectorRef = useRef(null);
|
||||||
|
const { screenSize, config, isMobile } = useResponsiveLayout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (selectorRef.current && !selectorRef.current.contains(event.target)) {
|
||||||
|
setSelectorVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectorVisible) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [selectorVisible]);
|
||||||
|
|
||||||
|
const handleRackSelect = useCallback(
|
||||||
|
(rack, room) => {
|
||||||
|
onRackSelect(rack, room);
|
||||||
|
setSelectorVisible(false);
|
||||||
|
},
|
||||||
|
[onRackSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setSelectorVisible(false);
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectorVisible((prev) => !prev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
const selectedRoom = rooms.find((r) => r.key === selectedRoomKey);
|
||||||
|
const displayText = selectedRack
|
||||||
|
? `${selectedRoom?.name || ''} / ${selectedRack.name}`
|
||||||
|
: '选择机房 / 机柜';
|
||||||
|
|
||||||
|
const canNavigatePrev =
|
||||||
|
racksInSelectedRoom && racksInSelectedRoom.length > 1 && selectedRack;
|
||||||
|
const canNavigateNext =
|
||||||
|
racksInSelectedRoom && racksInSelectedRoom.length > 1 && selectedRack;
|
||||||
|
|
||||||
|
const getCurrentRackIndex = () => {
|
||||||
|
if (!selectedRack || !racksInSelectedRoom) return -1;
|
||||||
|
return racksInSelectedRoom.findIndex((r) => r.rackId === selectedRack.rackId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownMenuItems = ACTION_BUTTONS_CONFIG.map((btn) => ({
|
||||||
|
key: btn.key,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{btn.icon}
|
||||||
|
{btn.label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
switch (btn.key) {
|
||||||
|
case 'refresh':
|
||||||
|
onRefresh();
|
||||||
|
break;
|
||||||
|
case 'resetView':
|
||||||
|
onResetView();
|
||||||
|
break;
|
||||||
|
case 'config':
|
||||||
|
onOpenConfig();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderLeftSection = () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined style={{ color: 'rgba(255,255,255,0.8)' }} />}
|
||||||
|
onClick={onBack}
|
||||||
|
className="header-icon-btn"
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloudServerOutlined style={{ fontSize: 18, color: '#3b82f6', marginRight: 10 }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#f8fafc',
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
3D 机柜可视化
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSelector = () => {
|
||||||
|
const panelWidth = isMobile ? '100%' : 520;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={selectorRef}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
flex: config.panelFullWidth ? 1 : '0 1 auto',
|
||||||
|
maxWidth: config.panelFullWidth ? 'none' : 560,
|
||||||
|
margin: '0 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectorVisible((prev) => !prev)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: selectorVisible
|
||||||
|
? 'rgba(59, 130, 246, 0.15)'
|
||||||
|
: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
border: selectorVisible
|
||||||
|
? '1px solid rgba(59, 130, 246, 0.5)'
|
||||||
|
: '1px solid rgba(255, 255, 255, 0.12)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
minHeight: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SwapOutlined
|
||||||
|
style={{
|
||||||
|
color: selectorVisible ? '#60a5fa' : 'rgba(255,255,255,0.5)',
|
||||||
|
marginRight: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: selectedRack ? '#f8fafc' : 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: selectedRack ? 500 : 400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginLeft: 12,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{canNavigatePrev && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPrevRack();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontSize: 12,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.15)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canNavigateNext && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNextRack();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontSize: 12,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.15)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 10,
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
transform: selectorVisible ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CascadingRackPanel
|
||||||
|
rooms={rooms}
|
||||||
|
selectedRoomKey={selectedRoomKey}
|
||||||
|
selectedRackId={selectedRack?.rackId}
|
||||||
|
onSelect={handleRackSelect}
|
||||||
|
visible={selectorVisible}
|
||||||
|
onClose={() => setSelectorVisible(false)}
|
||||||
|
triggerRef={selectorRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActionButton = (btn, index) => {
|
||||||
|
if (isMobile && btn.key === 'config') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={btn.key} title={btn.tooltip} placement="bottom">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
icon={btn.icon}
|
||||||
|
onClick={btn.onClick}
|
||||||
|
className="header-action-btn"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
borderColor: 'rgba(255,255,255,0.25)',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
height: 36,
|
||||||
|
padding: '0 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isMobile && !config.buttonIconOnly && btn.label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDeviceSlideToggle = () => {
|
||||||
|
if (!config.showDeviceSlide) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
gap: 6,
|
||||||
|
height: 36,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FullscreenOutlined
|
||||||
|
style={{
|
||||||
|
color: deviceSlideEnabled ? '#22c55e' : 'rgba(255,255,255,0.4)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!isMobile && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: deviceSlideEnabled ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.5)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
设备弹出
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
onClick={() => onDeviceSlideToggle(!deviceSlideEnabled)}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
background: deviceSlideEnabled ? '#22c55e' : 'rgba(255,255,255,0.2)',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'white',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
left: deviceSlideEnabled ? 16 : 2,
|
||||||
|
transition: 'left 0.2s',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRightSection = () => {
|
||||||
|
if (config.collapseActions) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
|
{renderDeviceSlideToggle()}
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: dropdownMenuItems }}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MenuOutlined style={{ color: 'rgba(255,255,255,0.8)' }} />}
|
||||||
|
className="header-icon-btn"
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderDeviceSlideToggle()}
|
||||||
|
{ACTION_BUTTONS_CONFIG.map(renderActionButton)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: 64,
|
||||||
|
padding: '0 20px 0 12px',
|
||||||
|
marginLeft: -24,
|
||||||
|
background: 'rgba(15, 23, 42, 0.6)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
flexShrink: 0,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.header-icon-btn:hover {
|
||||||
|
color: white !important;
|
||||||
|
background: rgba(255,255,255,0.1) !important;
|
||||||
|
}
|
||||||
|
.header-action-btn:hover {
|
||||||
|
color: white !important;
|
||||||
|
border-color: rgba(255,255,255,0.5) !important;
|
||||||
|
background: rgba(255,255,255,0.1) !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{renderLeftSection()}
|
||||||
|
{renderSelector()}
|
||||||
|
{renderRightSection()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RackSelectorHeader;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export const BREAKPOINTS = {
|
||||||
|
xs: 0,
|
||||||
|
sm: 768,
|
||||||
|
md: 992,
|
||||||
|
lg: 1200,
|
||||||
|
xl: 1440,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SCREEN_SIZES = {
|
||||||
|
xs: 'xs',
|
||||||
|
sm: 'sm',
|
||||||
|
md: 'md',
|
||||||
|
lg: 'lg',
|
||||||
|
xl: 'xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScreenSize = (width) => {
|
||||||
|
if (width >= BREAKPOINTS.xl) return SCREEN_SIZES.xl;
|
||||||
|
if (width >= BREAKPOINTS.lg) return SCREEN_SIZES.lg;
|
||||||
|
if (width >= BREAKPOINTS.md) return SCREEN_SIZES.md;
|
||||||
|
if (width >= BREAKPOINTS.sm) return SCREEN_SIZES.sm;
|
||||||
|
return SCREEN_SIZES.xs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScreenSizeConfig = (screenSize) => {
|
||||||
|
const configs = {
|
||||||
|
xs: {
|
||||||
|
showFullButtonLabels: false,
|
||||||
|
buttonIconOnly: true,
|
||||||
|
showDeviceSlide: false,
|
||||||
|
panelFullWidth: true,
|
||||||
|
collapseActions: true,
|
||||||
|
},
|
||||||
|
sm: {
|
||||||
|
showFullButtonLabels: false,
|
||||||
|
buttonIconOnly: true,
|
||||||
|
showDeviceSlide: false,
|
||||||
|
panelFullWidth: true,
|
||||||
|
collapseActions: true,
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
showFullButtonLabels: false,
|
||||||
|
buttonIconOnly: true,
|
||||||
|
showDeviceSlide: true,
|
||||||
|
panelFullWidth: false,
|
||||||
|
collapseActions: true,
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
showFullButtonLabels: true,
|
||||||
|
buttonIconOnly: false,
|
||||||
|
showDeviceSlide: true,
|
||||||
|
panelFullWidth: false,
|
||||||
|
collapseActions: false,
|
||||||
|
},
|
||||||
|
xl: {
|
||||||
|
showFullButtonLabels: true,
|
||||||
|
buttonIconOnly: false,
|
||||||
|
showDeviceSlide: true,
|
||||||
|
panelFullWidth: false,
|
||||||
|
collapseActions: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return configs[screenSize] || configs.lg;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useResponsiveLayout = () => {
|
||||||
|
const [screenSize, setScreenSize] = useState(() => getScreenSize(window.innerWidth));
|
||||||
|
const [config, setConfig] = useState(() => getScreenSizeConfig(screenSize));
|
||||||
|
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const newScreenSize = getScreenSize(width);
|
||||||
|
if (newScreenSize !== screenSize) {
|
||||||
|
setScreenSize(newScreenSize);
|
||||||
|
setConfig(getScreenSizeConfig(newScreenSize));
|
||||||
|
}
|
||||||
|
}, [screenSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
handleResize();
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [handleResize]);
|
||||||
|
|
||||||
|
const isMobile = screenSize === SCREEN_SIZES.xs || screenSize === SCREEN_SIZES.sm;
|
||||||
|
const isTablet = screenSize === SCREEN_SIZES.md;
|
||||||
|
const isDesktop = screenSize === SCREEN_SIZES.lg || screenSize === SCREEN_SIZES.xl;
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenSize,
|
||||||
|
config,
|
||||||
|
breakpoints: BREAKPOINTS,
|
||||||
|
isMobile,
|
||||||
|
isTablet,
|
||||||
|
isDesktop,
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useResponsiveLayout;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const extractNumberFromString = (str) => {
|
||||||
|
if (!str) return 0;
|
||||||
|
const match = str.match(/\d+/);
|
||||||
|
return match ? parseInt(match[0], 10) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const naturalSort = (a, b) => {
|
||||||
|
const numA = extractNumberFromString(a);
|
||||||
|
const numB = extractNumberFromString(b);
|
||||||
|
if (numA !== numB) return numA - numB;
|
||||||
|
return String(a).localeCompare(String(b), 'zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortRooms = (rooms) => {
|
||||||
|
return [...rooms].sort((a, b) => {
|
||||||
|
const sortOrderA = a.sortOrder ?? a.sort_order ?? 0;
|
||||||
|
const sortOrderB = b.sortOrder ?? b.sort_order ?? 0;
|
||||||
|
if (sortOrderA !== sortOrderB) {
|
||||||
|
return sortOrderA - sortOrderB;
|
||||||
|
}
|
||||||
|
return (a.name || '').localeCompare(b.name || '', 'zh-CN');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortRacksInRoom = (racks) => {
|
||||||
|
return [...racks].sort((a, b) => {
|
||||||
|
const sortOrderA = a.sortOrder ?? a.sort_order ?? 0;
|
||||||
|
const sortOrderB = b.sortOrder ?? b.sort_order ?? 0;
|
||||||
|
if (sortOrderA !== sortOrderB) {
|
||||||
|
return sortOrderA - sortOrderB;
|
||||||
|
}
|
||||||
|
const numA = extractNumberFromString(a.name);
|
||||||
|
const numB = extractNumberFromString(b.name);
|
||||||
|
if (numA !== numB) return numA - numB;
|
||||||
|
return (a.name || '').localeCompare(b.name || '', 'zh-CN');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSortedRacks = (racks) => {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!racks || !Array.isArray(racks) || racks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomMap = new Map();
|
||||||
|
|
||||||
|
racks.forEach((rack) => {
|
||||||
|
if (!rack || !rack.Room) return;
|
||||||
|
|
||||||
|
const roomKey = rack.Room.roomId || rack.Room.id || rack.Room.name;
|
||||||
|
if (!roomKey) return;
|
||||||
|
|
||||||
|
if (!roomMap.has(roomKey)) {
|
||||||
|
roomMap.set(roomKey, {
|
||||||
|
...rack.Room,
|
||||||
|
key: roomKey,
|
||||||
|
roomId: roomKey,
|
||||||
|
racks: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
roomMap.get(roomKey).racks.push(rack);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedRooms = sortRooms(Array.from(roomMap.values()));
|
||||||
|
|
||||||
|
sortedRooms.forEach((room) => {
|
||||||
|
room.racks = sortRacksInRoom(room.racks);
|
||||||
|
room.rackCount = room.racks.length;
|
||||||
|
room.totalDevices = room.racks.reduce((sum, r) => sum + (r._count?.Devices || r.deviceCount || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedRooms;
|
||||||
|
}, [racks]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterRoomsBySearch = (rooms, searchText) => {
|
||||||
|
if (!searchText || searchText.trim() === '') {
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerSearch = searchText.toLowerCase().trim();
|
||||||
|
|
||||||
|
return rooms
|
||||||
|
.map((room) => {
|
||||||
|
const roomNameMatch = room.name?.toLowerCase().includes(lowerSearch);
|
||||||
|
const roomIdMatch = room.roomId?.toLowerCase().includes(lowerSearch);
|
||||||
|
|
||||||
|
const filteredRacks = room.racks.filter((rack) => {
|
||||||
|
const rackNameMatch = rack.name?.toLowerCase().includes(lowerSearch);
|
||||||
|
const rackIdMatch = rack.rackId?.toLowerCase().includes(lowerSearch);
|
||||||
|
return roomNameMatch || roomIdMatch || rackNameMatch || rackIdMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roomNameMatch || roomIdMatch) {
|
||||||
|
return { ...room, racks: room.racks };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredRacks.length > 0) {
|
||||||
|
return { ...room, racks: filteredRacks };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((room) => room !== null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRackStats = (rack, devices = []) => {
|
||||||
|
const usedU = devices.reduce((sum, d) => sum + (d.height || d.u_height || 1), 0);
|
||||||
|
const totalPower = devices.reduce((sum, d) => sum + (parseFloat(d.powerConsumption) || 0), 0);
|
||||||
|
const totalHeight = rack.height || 45;
|
||||||
|
const deviceCount = devices.length;
|
||||||
|
const usagePercent = totalHeight > 0 ? Math.round((usedU / totalHeight) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
usedU,
|
||||||
|
availableU: totalHeight - usedU,
|
||||||
|
usagePercent,
|
||||||
|
totalPower,
|
||||||
|
totalHeight,
|
||||||
|
deviceCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSortedRacks;
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Layout,
|
|
||||||
Select,
|
|
||||||
Card,
|
|
||||||
Spin,
|
Spin,
|
||||||
message,
|
message,
|
||||||
Typography,
|
Typography,
|
||||||
Descriptions,
|
|
||||||
Tag,
|
|
||||||
Button,
|
|
||||||
Space,
|
|
||||||
Empty,
|
Empty,
|
||||||
Modal,
|
Modal,
|
||||||
Form,
|
Form,
|
||||||
@@ -17,23 +10,19 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Switch,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Badge,
|
Badge,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Button,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ReloadOutlined,
|
|
||||||
ArrowLeftOutlined,
|
|
||||||
InfoCircleOutlined,
|
|
||||||
UpOutlined,
|
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
|
UpOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
FullscreenOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
LeftOutlined,
|
|
||||||
RightOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -44,11 +33,12 @@ import PortCreateModal from '../components/PortCreateModal';
|
|||||||
import CableCreateModal from '../components/CableCreateModal';
|
import CableCreateModal from '../components/CableCreateModal';
|
||||||
import DeviceDetailDrawer from '../components/DeviceDetailDrawer';
|
import DeviceDetailDrawer from '../components/DeviceDetailDrawer';
|
||||||
import CloseButton from '../components/CloseButton';
|
import CloseButton from '../components/CloseButton';
|
||||||
|
import RackSelectorHeader from '../components/3d/RackSelectorHeader';
|
||||||
|
import { Layout } from 'antd';
|
||||||
import { useScene3D } from '../context/Scene3DContext';
|
import { useScene3D } from '../context/Scene3DContext';
|
||||||
|
import { useSortedRacks } from '../hooks/useSortedRacks';
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Content } = Layout;
|
||||||
const { Title, Text } = Typography;
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
const Rack3DVisualization = () => {
|
const Rack3DVisualization = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -351,7 +341,10 @@ const Rack3DVisualization = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedRack, fetchDevices]);
|
}, [selectedRack, fetchDevices]);
|
||||||
|
|
||||||
// Group racks by room
|
// 使用排序 Hook
|
||||||
|
const sortedRooms = useSortedRacks(racks);
|
||||||
|
|
||||||
|
// Group racks by room (保留用于导航)
|
||||||
const rooms = useMemo(() => {
|
const rooms = useMemo(() => {
|
||||||
const roomMap = new Map();
|
const roomMap = new Map();
|
||||||
racks.forEach(rack => {
|
racks.forEach(rack => {
|
||||||
@@ -496,321 +489,51 @@ const Rack3DVisualization = () => {
|
|||||||
setSelectedRack(racksInSelectedRoom[nextIndex]);
|
setSelectedRack(racksInSelectedRoom[nextIndex]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRackSelect = useCallback((rack, room) => {
|
||||||
|
setSelectedRack(rack);
|
||||||
|
if (room) {
|
||||||
|
const roomKey = room.key || room.roomId || room.id || room.name;
|
||||||
|
setSelectedRoom(roomKey);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchRacks();
|
||||||
|
if (selectedRack) fetchDevices(selectedRack.rackId);
|
||||||
|
}, [fetchRacks, selectedRack, fetchDevices]);
|
||||||
|
|
||||||
|
const handleResetView = useCallback(() => {
|
||||||
|
if (sceneRef.current) sceneRef.current.resetView();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenConfig = useCallback(() => {
|
||||||
|
setShowTooltipConfig(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
navigate('/');
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
style={{ height: '100vh', overflow: 'hidden', background: '#000', position: 'relative' }}
|
<RackSelectorHeader
|
||||||
>
|
rooms={sortedRooms}
|
||||||
<Header
|
selectedRoomKey={selectedRoom}
|
||||||
style={{
|
selectedRack={selectedRack}
|
||||||
display: 'flex',
|
onRackSelect={handleRackSelect}
|
||||||
alignItems: 'center',
|
onPrevRack={handlePrevRack}
|
||||||
justifyContent: 'space-between',
|
onNextRack={handleNextRack}
|
||||||
padding: '0 24px',
|
racksInSelectedRoom={racksInSelectedRoom}
|
||||||
background: 'rgba(15, 23, 42, 0.6)', // Deep blue-grey, semi-transparent
|
deviceSlideEnabled={deviceSlideEnabled}
|
||||||
backdropFilter: 'blur(20px)',
|
onDeviceSlideToggle={setDeviceSlideEnabled}
|
||||||
position: 'absolute',
|
onRefresh={handleRefresh}
|
||||||
width: '100%',
|
onResetView={handleResetView}
|
||||||
zIndex: 100,
|
onOpenConfig={handleOpenConfig}
|
||||||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
onBack={handleBack}
|
||||||
height: '64px',
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<ArrowLeftOutlined style={{ color: 'rgba(255,255,255,0.8)' }} />}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
style={{ marginRight: 8 }}
|
|
||||||
className="hover-bright"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid rgba(255,255,255,0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloudServerOutlined
|
|
||||||
style={{ fontSize: '20px', color: '#3b82f6', marginRight: '10px' }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#f8fafc',
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
textShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
3D 机柜可视化
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Space size="middle">
|
|
||||||
<Select
|
|
||||||
placeholder="搜索机房"
|
|
||||||
style={{ width: 180 }}
|
|
||||||
value={selectedRoom}
|
|
||||||
onChange={val => {
|
|
||||||
setSelectedRoom(val);
|
|
||||||
const roomRacks = racks.filter(
|
|
||||||
r => (r.Room?.roomId || r.Room?.id || r.Room?.name) === val
|
|
||||||
);
|
|
||||||
if (roomRacks.length > 0) setSelectedRack(roomRacks[0]);
|
|
||||||
else setSelectedRack(null);
|
|
||||||
}}
|
|
||||||
variant="borderless"
|
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
className="glass-select"
|
|
||||||
showSearch
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.children?.toString() || '').toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{rooms.map(room => (
|
|
||||||
<Option key={room.key} value={room.key}>
|
|
||||||
{room.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<LeftOutlined />}
|
|
||||||
onClick={handlePrevRack}
|
|
||||||
disabled={!selectedRoom || racksInSelectedRoom.length <= 1}
|
|
||||||
className="hover-bright"
|
|
||||||
style={{
|
|
||||||
color: 'rgba(255,255,255,0.9)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="搜索机柜"
|
|
||||||
style={{ width: 180 }}
|
|
||||||
value={selectedRack?.rackId}
|
|
||||||
onChange={val => setSelectedRack(racks.find(r => r.rackId === val))}
|
|
||||||
disabled={!selectedRoom}
|
|
||||||
variant="borderless"
|
|
||||||
className="glass-select"
|
|
||||||
showSearch
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.children?.toString() || '').toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{racksInSelectedRoom.map(rack => (
|
|
||||||
<Option key={rack.rackId} value={rack.rackId}>
|
|
||||||
{rack.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<RightOutlined />}
|
|
||||||
onClick={handleNextRack}
|
|
||||||
disabled={!selectedRoom || racksInSelectedRoom.length <= 1}
|
|
||||||
className="hover-bright"
|
|
||||||
style={{
|
|
||||||
color: 'rgba(255,255,255,0.9)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
ghost
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
fetchRacks();
|
|
||||||
if (selectedRack) fetchDevices(selectedRack.rackId);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
borderRadius: '6px',
|
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
|
||||||
color: 'rgba(255,255,255,0.9)',
|
|
||||||
}}
|
|
||||||
className="hover-bright"
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
ghost
|
|
||||||
icon={<EyeOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
if (sceneRef.current) sceneRef.current.resetView();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
borderRadius: '6px',
|
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
|
||||||
color: 'rgba(255,255,255,0.9)',
|
|
||||||
}}
|
|
||||||
className="hover-bright"
|
|
||||||
>
|
|
||||||
重置视角
|
|
||||||
</Button>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
padding: '4px 12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: '1px solid rgba(255,255,255,0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FullscreenOutlined
|
|
||||||
style={{
|
|
||||||
color: deviceSlideEnabled ? '#22c55e' : 'rgba(255,255,255,0.4)',
|
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: deviceSlideEnabled ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.5)',
|
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
设备弹出
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={deviceSlideEnabled}
|
|
||||||
onChange={setDeviceSlideEnabled}
|
|
||||||
checkedChildren="开"
|
|
||||||
unCheckedChildren="关"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
ghost
|
|
||||||
icon={<SettingOutlined />}
|
|
||||||
onClick={() => setShowTooltipConfig(true)}
|
|
||||||
style={{
|
|
||||||
borderRadius: '6px',
|
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
|
||||||
color: 'rgba(255,255,255,0.9)',
|
|
||||||
}}
|
|
||||||
className="hover-bright"
|
|
||||||
>
|
|
||||||
显示配置
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
{/* Inject custom styles for glass selects */}
|
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||||
<style>{`
|
<Content style={{ position: 'absolute', inset: 0, background: '#ffffff' }}>
|
||||||
.glass-select .ant-select-selector {
|
|
||||||
background: rgba(255, 255, 255, 0.08) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
|
||||||
color: white !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
padding: 4px 12px !important;
|
|
||||||
height: auto !important;
|
|
||||||
min-height: 32px !important;
|
|
||||||
align-items: center !important;
|
|
||||||
}
|
|
||||||
.glass-select:hover .ant-select-selector {
|
|
||||||
background: rgba(255, 255, 255, 0.15) !important;
|
|
||||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
|
||||||
}
|
|
||||||
.glass-select.ant-select-focused .ant-select-selector {
|
|
||||||
background: rgba(255, 255, 255, 0.12) !important;
|
|
||||||
border-color: #3b82f6 !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15), 0 0 12px rgba(59, 130, 246, 0.1) !important;
|
|
||||||
}
|
|
||||||
.glass-select .ant-select-selection-item,
|
|
||||||
.glass-select .ant-select-selection-placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
line-height: 1.5 !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
text-overflow: ellipsis !important;
|
|
||||||
position: relative !important;
|
|
||||||
z-index: 1 !important;
|
|
||||||
flex: 0 1 auto !important;
|
|
||||||
}
|
|
||||||
.glass-select.ant-select-show-search .ant-select-selection-item,
|
|
||||||
.glass-select.ant-select-show-search .ant-select-selection-placeholder {
|
|
||||||
position: absolute !important;
|
|
||||||
left: 0 !important;
|
|
||||||
top: 50% !important;
|
|
||||||
transform: translateY(-50%) !important;
|
|
||||||
width: 100% !important;
|
|
||||||
padding-right: 24px !important;
|
|
||||||
}
|
|
||||||
.glass-select.ant-select-show-search.ant-select-focused .ant-select-selection-item,
|
|
||||||
.glass-select.ant-select-show-search.ant-select-focused .ant-select-selection-placeholder {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.glass-select .ant-select-selection-wrap {
|
|
||||||
display: flex !important;
|
|
||||||
flex-wrap: nowrap !important;
|
|
||||||
align-items: center !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
max-width: calc(100% - 24px) !important;
|
|
||||||
}
|
|
||||||
.glass-select .ant-select-selection-search {
|
|
||||||
position: absolute !important;
|
|
||||||
left: 0 !important;
|
|
||||||
top: 50% !important;
|
|
||||||
transform: translateY(-50%) !important;
|
|
||||||
width: 100% !important;
|
|
||||||
z-index: 2 !important;
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center !important;
|
|
||||||
}
|
|
||||||
.glass-select .ant-select-selection-search-input {
|
|
||||||
width: 100% !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
color: white !important;
|
|
||||||
caret-color: #3b82f6 !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
.glass-select .ant-select-selection-search-input::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.5) !important;
|
|
||||||
}
|
|
||||||
.glass-select .ant-select-arrow {
|
|
||||||
color: rgba(255, 255, 255, 0.6) !important;
|
|
||||||
transition: all 0.3s;
|
|
||||||
position: relative !important;
|
|
||||||
z-index: 1 !important;
|
|
||||||
}
|
|
||||||
.glass-select.ant-select-focused .ant-select-arrow {
|
|
||||||
color: #60a5fa !important;
|
|
||||||
transform: translateY(-50%) scale(1.1);
|
|
||||||
}
|
|
||||||
.glass-select input {
|
|
||||||
color: white !important;
|
|
||||||
caret-color: #3b82f6 !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
width: 100% !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
}
|
|
||||||
.glass-select input::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.5) !important;
|
|
||||||
}
|
|
||||||
.glass-select.ant-select-open input {
|
|
||||||
caret-color: #60a5fa !important;
|
|
||||||
}
|
|
||||||
.hover-bright:hover {
|
|
||||||
color: white !important;
|
|
||||||
background: rgba(255,255,255,0.1) !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<Layout style={{ marginTop: 64 }}>
|
|
||||||
|
|
||||||
<Content style={{ position: 'relative', background: '#ffffff' }}>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -1382,8 +1105,8 @@ const Rack3DVisualization = () => {
|
|||||||
refreshTrigger={refreshTrigger}
|
refreshTrigger={refreshTrigger}
|
||||||
/>
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</div>
|
||||||
</Layout>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user