feat: 添加响应式3D机柜可视化组件和钩子

重构3D机柜可视化页面,添加以下功能:
1. 新增 useResponsiveLayout 钩子处理响应式布局
2. 新增 useSortedRacks 钩子实现机柜排序和筛选
3. 添加 CascadingRackPanel 级联选择面板组件
4. 实现 RackSelectorHeader 响应式头部导航组件
5. 优化页面结构和交互逻辑
This commit is contained in:
zhang1106
2026-03-25 12:14:00 +08:00
parent 6ebcb3f870
commit 0b7732e427
5 changed files with 1186 additions and 335 deletions
@@ -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;