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 = () => ( +
+
+ ); + + 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()} + +
+ ); + } + + 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} /> - - +
+
); };