From 0b7732e4270e930539f025478015c5b647b76281 Mon Sep 17 00:00:00 2001
From: zhang1106 <849185023@qq.com>
Date: Wed, 25 Mar 2026 12:14:00 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=93=8D=E5=BA=94?=
=?UTF-8?q?=E5=BC=8F3D=E6=9C=BA=E6=9F=9C=E5=8F=AF=E8=A7=86=E5=8C=96?=
=?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=92=8C=E9=92=A9=E5=AD=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
重构3D机柜可视化页面,添加以下功能:
1. 新增 useResponsiveLayout 钩子处理响应式布局
2. 新增 useSortedRacks 钩子实现机柜排序和筛选
3. 添加 CascadingRackPanel 级联选择面板组件
4. 实现 RackSelectorHeader 响应式头部导航组件
5. 优化页面结构和交互逻辑
---
.../src/components/3d/CascadingRackPanel.jsx | 409 +++++++++++++++
.../src/components/3d/RackSelectorHeader.jsx | 490 ++++++++++++++++++
frontend/src/hooks/useResponsiveLayout.js | 103 ++++
frontend/src/hooks/useSortedRacks.js | 126 +++++
frontend/src/pages/Rack3DVisualization.jsx | 393 +++-----------
5 files changed, 1186 insertions(+), 335 deletions(-)
create mode 100644 frontend/src/components/3d/CascadingRackPanel.jsx
create mode 100644 frontend/src/components/3d/RackSelectorHeader.jsx
create mode 100644 frontend/src/hooks/useResponsiveLayout.js
create mode 100644 frontend/src/hooks/useSortedRacks.js
diff --git a/frontend/src/components/3d/CascadingRackPanel.jsx b/frontend/src/components/3d/CascadingRackPanel.jsx
new file mode 100644
index 0000000..8a1c03a
--- /dev/null
+++ b/frontend/src/components/3d/CascadingRackPanel.jsx
@@ -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 (
+
+
+
+
+ {
+ setSearchText(e.target.value);
+ setFocusedIndex(-1);
+ }}
+ placeholder="搜索机房或机柜..."
+ prefix={}
+ suffix={
+ searchText && (
+ setSearchText('')}
+ />
+ )
+ }
+ style={{
+ background: 'rgba(255, 255, 255, 0.05)',
+ border: '1px solid rgba(255, 255, 255, 0.1)',
+ borderRadius: 8,
+ color: 'white',
+ height: 40,
+ }}
+ />
+
+
+
+ {filteredRooms.length === 0 ? (
+
+ 未找到匹配的机房或机柜
+
+ ) : (
+ filteredRooms.map((room, roomIndex) => (
+
+
+ setActiveRoomKey(activeRoomKey === room.key ? null : room.key)
+ }
+ >
+
+
+ {room.name}
+
+
+ {room.racks.length} 机柜
+
+
+ ▼
+
+
+
+ {activeRoomKey === room.key && (
+
+ {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 (
+
handleRackSelect(rack, room)}
+ onMouseEnter={() => setHoveredRackId(rack.rackId)}
+ onMouseLeave={() => setHoveredRackId(null)}
+ >
+
+
+
+ {rack.name}
+
+
+ {height}U
+ {deviceCount} 设备
+
+
+
= 70
+ ? usagePercent >= 90
+ ? '#ef4444'
+ : '#f59e0b'
+ : '#22c55e',
+ fontSize: 12,
+ fontWeight: 600,
+ }}
+ >
+ {usagePercent}%
+
+ }
+ />
+
+ );
+ })}
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ 使用 ↑↓ 键导航,Enter 确认,Esc 关闭
+
+
+ {flatRackList.length} 个机柜
+
+
+
+ );
+};
+
+export default CascadingRackPanel;
\ No newline at end of file
diff --git a/frontend/src/components/3d/RackSelectorHeader.jsx b/frontend/src/components/3d/RackSelectorHeader.jsx
new file mode 100644
index 0000000..8aec517
--- /dev/null
+++ b/frontend/src/components/3d/RackSelectorHeader.jsx
@@ -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: ,
+ label: '刷新',
+ tooltip: '刷新数据',
+ },
+ {
+ key: 'resetView',
+ icon: ,
+ label: '重置视角',
+ tooltip: '重置3D视图',
+ },
+ {
+ key: 'config',
+ icon: ,
+ 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: (
+
+ {btn.icon}
+ {btn.label}
+
+ ),
+ onClick: () => {
+ switch (btn.key) {
+ case 'refresh':
+ onRefresh();
+ break;
+ case 'resetView':
+ onResetView();
+ break;
+ case 'config':
+ onOpenConfig();
+ break;
+ default:
+ break;
+ }
+ },
+ }));
+
+ const renderLeftSection = () => (
+
+
}
+ onClick={onBack}
+ className="header-icon-btn"
+ style={{ marginRight: 8 }}
+ />
+
+
+
+ 3D 机柜可视化
+
+
+
+ );
+
+ const renderSelector = () => {
+ const panelWidth = isMobile ? '100%' : 520;
+
+ return (
+
+
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,
+ }}
+ >
+
+
+
+ {displayText}
+
+
+
+ {canNavigatePrev && (
+
{
+ 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)';
+ }}
+ >
+ ←
+
+ )}
+ {canNavigateNext && (
+
{
+ 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)';
+ }}
+ >
+ →
+
+ )}
+
+ ▼
+
+
+
+
+
setSelectorVisible(false)}
+ triggerRef={selectorRef}
+ />
+
+ );
+ };
+
+ const renderActionButton = (btn, index) => {
+ if (isMobile && btn.key === 'config') {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ };
+
+ const renderDeviceSlideToggle = () => {
+ if (!config.showDeviceSlide) return null;
+
+ return (
+
+
+ {!isMobile && (
+
+ 设备弹出
+
+ )}
+
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',
+ }}
+ >
+
+
+
+ );
+ };
+
+ const renderRightSection = () => {
+ if (config.collapseActions) {
+ return (
+
+ {renderDeviceSlideToggle()}
+
+ }
+ className="header-icon-btn"
+ style={{ borderRadius: 8 }}
+ />
+
+
+ );
+ }
+
+ return (
+
+ {renderDeviceSlideToggle()}
+ {ACTION_BUTTONS_CONFIG.map(renderActionButton)}
+
+ );
+ };
+
+ return (
+
+
+
+ {renderLeftSection()}
+ {renderSelector()}
+ {renderRightSection()}
+
+ );
+};
+
+export default RackSelectorHeader;
\ No newline at end of file
diff --git a/frontend/src/hooks/useResponsiveLayout.js b/frontend/src/hooks/useResponsiveLayout.js
new file mode 100644
index 0000000..c030060
--- /dev/null
+++ b/frontend/src/hooks/useResponsiveLayout.js
@@ -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;
\ No newline at end of file
diff --git a/frontend/src/hooks/useSortedRacks.js b/frontend/src/hooks/useSortedRacks.js
new file mode 100644
index 0000000..834c6eb
--- /dev/null
+++ b/frontend/src/hooks/useSortedRacks.js
@@ -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;
\ No newline at end of file
diff --git a/frontend/src/pages/Rack3DVisualization.jsx b/frontend/src/pages/Rack3DVisualization.jsx
index 9eebed3..2508530 100644
--- a/frontend/src/pages/Rack3DVisualization.jsx
+++ b/frontend/src/pages/Rack3DVisualization.jsx
@@ -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 (
-
-
+
+
- {/* Inject custom styles for glass selects */}
-
-
-
-
-
+
+
{loading ? (
{
refreshTrigger={refreshTrigger}
/>
-
-
+
+
);
};