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;
|
||||
Reference in New Issue
Block a user