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;
+103
View File
@@ -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;
+126
View File
@@ -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;
+58 -335
View File
@@ -1,15 +1,8 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Layout,
Select,
Card,
Spin,
message,
Typography,
Descriptions,
Tag,
Button,
Space,
Empty,
Modal,
Form,
@@ -17,23 +10,19 @@ import {
InputNumber,
DatePicker,
Checkbox,
Switch,
Tooltip,
Badge,
Select,
Space,
Button,
} from 'antd';
import {
CloudServerOutlined,
ReloadOutlined,
ArrowLeftOutlined,
InfoCircleOutlined,
UpOutlined,
DownOutlined,
UpOutlined,
InfoCircleOutlined,
EditOutlined,
SettingOutlined,
FullscreenOutlined,
EyeOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
@@ -44,11 +33,12 @@ import PortCreateModal from '../components/PortCreateModal';
import CableCreateModal from '../components/CableCreateModal';
import DeviceDetailDrawer from '../components/DeviceDetailDrawer';
import CloseButton from '../components/CloseButton';
import RackSelectorHeader from '../components/3d/RackSelectorHeader';
import { Layout } from 'antd';
import { useScene3D } from '../context/Scene3DContext';
import { useSortedRacks } from '../hooks/useSortedRacks';
const { Header, Content, Sider } = Layout;
const { Title, Text } = Typography;
const { Option } = Select;
const { Content } = Layout;
const Rack3DVisualization = () => {
const navigate = useNavigate();
@@ -351,7 +341,10 @@ const Rack3DVisualization = () => {
}
}, [selectedRack, fetchDevices]);
// Group racks by room
// 使用排序 Hook
const sortedRooms = useSortedRacks(racks);
// Group racks by room (保留用于导航)
const rooms = useMemo(() => {
const roomMap = new Map();
racks.forEach(rack => {
@@ -496,321 +489,51 @@ const Rack3DVisualization = () => {
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 (
<Layout
style={{ height: '100vh', overflow: 'hidden', background: '#000', position: 'relative' }}
>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
background: 'rgba(15, 23, 42, 0.6)', // Deep blue-grey, semi-transparent
backdropFilter: 'blur(20px)',
position: 'absolute',
width: '100%',
zIndex: 100,
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
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>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<RackSelectorHeader
rooms={sortedRooms}
selectedRoomKey={selectedRoom}
selectedRack={selectedRack}
onRackSelect={handleRackSelect}
onPrevRack={handlePrevRack}
onNextRack={handleNextRack}
racksInSelectedRoom={racksInSelectedRoom}
deviceSlideEnabled={deviceSlideEnabled}
onDeviceSlideToggle={setDeviceSlideEnabled}
onRefresh={handleRefresh}
onResetView={handleResetView}
onOpenConfig={handleOpenConfig}
onBack={handleBack}
/>
{/* Inject custom styles for glass selects */}
<style>{`
.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' }}>
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<Content style={{ position: 'absolute', inset: 0, background: '#ffffff' }}>
{loading ? (
<div
style={{
@@ -1382,8 +1105,8 @@ const Rack3DVisualization = () => {
refreshTrigger={refreshTrigger}
/>
</Content>
</Layout>
</Layout>
</div>
</div>
);
};