feat: 优化3D可视化性能并更新文档
- 实现3D可视化性能优化(降低DPR、阴影贴图、简化光源) - 实现LOD多级细节系统,根据相机距离自动切换 - 添加设备弹出动画开关,默认关闭 - 修复设备缩放时位置偏移问题 - 更新README添加项目截图占位符 - 精简DEPLOYMENT.md文档,添加Gitee仓库支持 - 更新CHANGELOG.md记录v1.1.0版本
This commit is contained in:
@@ -0,0 +1,862 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Layout, Select, Card, Spin, message, Typography, Descriptions, Tag, Button, Space, Empty, Modal, Form, Input, InputNumber, DatePicker, Checkbox, Switch } from 'antd';
|
||||
import { CloudServerOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, UpOutlined, DownOutlined, EditOutlined, SettingOutlined, FullscreenOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import Scene from '../components/3d/Scene';
|
||||
import NetworkCardCreateModal from '../components/NetworkCardCreateModal';
|
||||
import PortCreateModal from '../components/PortCreateModal';
|
||||
import CableCreateModal from '../components/CableCreateModal';
|
||||
import DeviceDetailDrawer from '../components/DeviceDetailDrawer';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const Rack3DVisualization = () => {
|
||||
const navigate = useNavigate();
|
||||
const [racks, setRacks] = useState([]);
|
||||
const [selectedRack, setSelectedRack] = useState(null);
|
||||
const [selectedRoom, setSelectedRoom] = useState(null);
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingDevices, setLoadingDevices] = useState(false);
|
||||
const [selectedDevice, setSelectedDevice] = useState(null); // Currently selected device in 3D view
|
||||
const [hoveredDevice, setHoveredDevice] = useState(null); // Currently hovered device
|
||||
const [isRackInfoCollapsed, setIsRackInfoCollapsed] = useState(false); // Rack Info Card collapsed state
|
||||
|
||||
// Edit Modal State
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Field configuration state
|
||||
const [tooltipFields, setTooltipFields] = useState({});
|
||||
const [showTooltipConfig, setShowTooltipConfig] = useState(false);
|
||||
const [loadingTooltipFields, setLoadingTooltipFields] = useState(false);
|
||||
const [savingTooltipConfig, setSavingTooltipConfig] = useState(false);
|
||||
|
||||
// Add Port/NIC/Cable Modal State
|
||||
const [nicModalVisible, setNicModalVisible] = useState(false);
|
||||
const [portModalVisible, setPortModalVisible] = useState(false);
|
||||
const [cableModalVisible, setCableModalVisible] = useState(false);
|
||||
const [operatingDevice, setOperatingDevice] = useState(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// Device Cables State
|
||||
const [deviceCables, setDeviceCables] = useState([]);
|
||||
|
||||
// Device Slide Animation Toggle
|
||||
const [deviceSlideEnabled, setDeviceSlideEnabled] = useState(false);
|
||||
|
||||
const fetchDeviceCables = useCallback(async (deviceId) => {
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const response = await axios.get(`/api/cables/device/${deviceId}`);
|
||||
setDeviceCables(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch device cables:', error);
|
||||
setDeviceCables([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddNic = (device) => {
|
||||
setOperatingDevice(device);
|
||||
setNicModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddPort = (device) => {
|
||||
setOperatingDevice(device);
|
||||
setPortModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddCable = (device) => {
|
||||
setOperatingDevice(device);
|
||||
setCableModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle Edit Device Click
|
||||
const handleEditDevice = (device) => {
|
||||
setEditingDevice(device);
|
||||
|
||||
// Prepare form data (similar to DeviceManagement)
|
||||
const deviceData = { ...device };
|
||||
if (deviceData.purchaseDate) deviceData.purchaseDate = dayjs(deviceData.purchaseDate);
|
||||
if (deviceData.warrantyExpiry) deviceData.warrantyExpiry = dayjs(deviceData.warrantyExpiry);
|
||||
|
||||
// Fixed fields
|
||||
const fixedFields = [
|
||||
'deviceId', 'name', 'type', 'model', 'serialNumber', 'rackId',
|
||||
'position', 'height', 'powerConsumption', 'status', 'purchaseDate',
|
||||
'warrantyExpiry', 'ipAddress', 'description'
|
||||
];
|
||||
|
||||
const cleanDeviceData = {};
|
||||
fixedFields.forEach(field => {
|
||||
if (deviceData[field] !== undefined) {
|
||||
cleanDeviceData[field] = deviceData[field];
|
||||
}
|
||||
});
|
||||
|
||||
// Custom fields if any (simplified for 3D view context)
|
||||
if (deviceData.customFields && typeof deviceData.customFields === 'object') {
|
||||
Object.entries(deviceData.customFields).forEach(([key, value]) => {
|
||||
cleanDeviceData[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
form.setFieldsValue(cleanDeviceData);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalVisible(false);
|
||||
setEditingDevice(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleModalSubmit = async (values) => {
|
||||
try {
|
||||
const deviceData = {
|
||||
...values,
|
||||
purchaseDate: values.purchaseDate ? values.purchaseDate.format('YYYY-MM-DD') : null,
|
||||
warrantyExpiry: values.warrantyExpiry ? values.warrantyExpiry.format('YYYY-MM-DD') : null,
|
||||
};
|
||||
|
||||
if (editingDevice) {
|
||||
await axios.put(`/api/devices/${editingDevice.deviceId || editingDevice.id}`, deviceData);
|
||||
message.success('设备更新成功');
|
||||
|
||||
// Refresh devices
|
||||
if (selectedRack) {
|
||||
fetchDevices(selectedRack.rackId);
|
||||
}
|
||||
setModalVisible(false);
|
||||
setEditingDevice(null);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('设备更新失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Racks
|
||||
const fetchRacks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/racks', { params: { pageSize: 1000 } });
|
||||
const racksArray = response.data.racks || [];
|
||||
// Filter valid racks
|
||||
const validRacks = racksArray.filter(r => r && r.rackId && typeof r.height === 'number');
|
||||
setRacks(validRacks);
|
||||
|
||||
if (validRacks.length > 0) {
|
||||
// Default select first rack
|
||||
const firstRack = validRacks[0];
|
||||
setSelectedRack(firstRack);
|
||||
if (firstRack?.Room) {
|
||||
const roomKey = firstRack.Room.roomId || firstRack.Room.id || firstRack.Room.name;
|
||||
setSelectedRoom(roomKey);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch racks:', error);
|
||||
message.error('获取机柜列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch Devices for Rack
|
||||
const fetchDevices = useCallback(async (rackId) => {
|
||||
if (!rackId) return;
|
||||
try {
|
||||
setLoadingDevices(true);
|
||||
const response = await axios.get(`/api/devices?rackId=${rackId}`);
|
||||
|
||||
let devicesData = [];
|
||||
if (Array.isArray(response.data)) {
|
||||
devicesData = response.data;
|
||||
} else if (response.data && Array.isArray(response.data.data)) {
|
||||
devicesData = response.data.data;
|
||||
} else if (response.data && Array.isArray(response.data.devices)) {
|
||||
devicesData = response.data.devices;
|
||||
}
|
||||
|
||||
// Process devices (ensure position/height are numbers)
|
||||
const validDevices = devicesData.filter(d => {
|
||||
if (!d.deviceId && !d.id) return false;
|
||||
|
||||
// Normalize deviceId
|
||||
if (!d.deviceId && d.id) d.deviceId = d.id;
|
||||
|
||||
let pos = d.position || d.u_position;
|
||||
let h = d.height || d.u_height || 1;
|
||||
if (typeof pos === 'string') pos = parseInt(pos, 10);
|
||||
if (typeof h === 'string') h = parseInt(h, 10);
|
||||
|
||||
// Mutate for consistency
|
||||
d.position = pos;
|
||||
d.height = h;
|
||||
|
||||
return typeof pos === 'number' && !isNaN(pos);
|
||||
});
|
||||
|
||||
setDevices(validDevices);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch devices:', error);
|
||||
message.error('获取设备列表失败');
|
||||
setDevices([]);
|
||||
} finally {
|
||||
setLoadingDevices(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch Tooltip Fields from API
|
||||
const fetchTooltipDeviceFields = useCallback(async () => {
|
||||
try {
|
||||
setLoadingTooltipFields(true);
|
||||
const response = await axios.get('/api/deviceFields');
|
||||
const fields = response.data;
|
||||
|
||||
const newTooltipFields = {};
|
||||
if (fields && fields.length > 0) {
|
||||
fields.forEach(field => {
|
||||
newTooltipFields[field.fieldName] = {
|
||||
label: field.displayName,
|
||||
enabled: field.visible,
|
||||
field: field.fieldName,
|
||||
fieldType: field.fieldType || 'text'
|
||||
};
|
||||
});
|
||||
}
|
||||
setTooltipFields(newTooltipFields);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch device fields:', error);
|
||||
message.error('获取字段配置失败');
|
||||
} finally {
|
||||
setLoadingTooltipFields(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveTooltipConfig = async () => {
|
||||
try {
|
||||
setSavingTooltipConfig(true);
|
||||
|
||||
// Transform object to array for backend API
|
||||
const fieldConfigs = Object.values(tooltipFields).map(field => ({
|
||||
fieldName: field.field,
|
||||
visible: field.enabled,
|
||||
displayName: field.label,
|
||||
fieldType: field.fieldType
|
||||
}));
|
||||
|
||||
await axios.post('/api/deviceFields/config', fieldConfigs);
|
||||
message.success('字段配置保存成功');
|
||||
setShowTooltipConfig(false);
|
||||
|
||||
// Refresh fields to sync with DB
|
||||
fetchTooltipDeviceFields();
|
||||
} catch (error) {
|
||||
console.error('Failed to save tooltip config:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
setSavingTooltipConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRacks();
|
||||
fetchTooltipDeviceFields();
|
||||
}, [fetchRacks, fetchTooltipDeviceFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRack?.rackId) {
|
||||
fetchDevices(selectedRack.rackId);
|
||||
setSelectedDevice(null); // Reset selection on rack change
|
||||
}
|
||||
}, [selectedRack, fetchDevices]);
|
||||
|
||||
// Group racks by room
|
||||
const rooms = useMemo(() => {
|
||||
const roomMap = new Map();
|
||||
racks.forEach(rack => {
|
||||
if (rack?.Room) {
|
||||
const roomKey = rack.Room.roomId || rack.Room.id || rack.Room.name;
|
||||
if (!roomMap.has(roomKey)) {
|
||||
roomMap.set(roomKey, { ...rack.Room, key: roomKey });
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(roomMap.values());
|
||||
}, [racks]);
|
||||
|
||||
const racksInSelectedRoom = useMemo(() => {
|
||||
if (!selectedRoom) return [];
|
||||
return racks.filter(r => {
|
||||
const rKey = r.Room?.roomId || r.Room?.id || r.Room?.name;
|
||||
return rKey === selectedRoom;
|
||||
});
|
||||
}, [selectedRoom, racks]);
|
||||
|
||||
const handleDeviceClick = (device) => {
|
||||
setSelectedDevice(device);
|
||||
if (device) {
|
||||
fetchDeviceCables(device.deviceId || device.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeviceLeave = (device) => {
|
||||
// 移除鼠标离开事件,避免抽屉意外关闭
|
||||
// setSelectedDevice((current) => {
|
||||
// const deviceId = device.deviceId || device.id;
|
||||
// const currentId = current?.deviceId || current?.id;
|
||||
// return currentId === deviceId ? null : current;
|
||||
// });
|
||||
};
|
||||
|
||||
const handleDeviceHover = useCallback((device) => {
|
||||
if (modalVisible || nicModalVisible || portModalVisible) return;
|
||||
setHoveredDevice(device);
|
||||
}, [modalVisible, nicModalVisible, portModalVisible]);
|
||||
|
||||
const handleClosePortModal = useCallback(() => {
|
||||
setPortModalVisible(false);
|
||||
setOperatingDevice(null);
|
||||
}, []);
|
||||
|
||||
const handlePortSuccess = useCallback(() => {
|
||||
message.success('端口添加成功');
|
||||
}, []);
|
||||
|
||||
const handleCloseNicModal = useCallback(() => {
|
||||
setNicModalVisible(false);
|
||||
setOperatingDevice(null);
|
||||
}, []);
|
||||
|
||||
const handleNicSuccess = useCallback(() => {
|
||||
message.success('网卡添加成功');
|
||||
}, []);
|
||||
|
||||
const handleCloseCableModal = useCallback(() => {
|
||||
setCableModalVisible(false);
|
||||
setOperatingDevice(null);
|
||||
}, []);
|
||||
|
||||
const handleCableSuccess = useCallback(() => {
|
||||
message.success('接线添加成功');
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
if (selectedDevice) {
|
||||
fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id);
|
||||
}
|
||||
}, [selectedDevice, fetchDeviceCables]);
|
||||
|
||||
const handleDeleteCable = useCallback(async (cableId) => {
|
||||
try {
|
||||
await axios.delete(`/api/cables/${cableId}`);
|
||||
message.success('接线删除成功');
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
if (selectedDevice) {
|
||||
fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除接线失败:', error);
|
||||
message.error('删除接线失败');
|
||||
}
|
||||
}, [selectedDevice, fetchDeviceCables]);
|
||||
|
||||
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: 16 }}
|
||||
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"
|
||||
>
|
||||
{rooms.map(room => (
|
||||
<Option key={room.key} value={room.key}>{room.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="选择机柜"
|
||||
style={{ width: 180 }}
|
||||
value={selectedRack?.rackId}
|
||||
onChange={(val) => setSelectedRack(racks.find(r => r.rackId === val))}
|
||||
disabled={!selectedRoom}
|
||||
variant="borderless"
|
||||
className="glass-select"
|
||||
>
|
||||
{racksInSelectedRoom.map(rack => (
|
||||
<Option key={rack.rackId} value={rack.rackId}>{rack.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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;
|
||||
}
|
||||
.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-selection-item,
|
||||
.glass-select .ant-select-selection-placeholder {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
.glass-select .ant-select-arrow {
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.hover-bright:hover {
|
||||
color: white !important;
|
||||
background: rgba(255,255,255,0.1) !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<Layout>
|
||||
<Content style={{ position: 'relative', background: '#ffffff' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16, color: '#1890ff' }}>加载资源中...</div>
|
||||
</div>
|
||||
) : selectedRack ? (
|
||||
<>
|
||||
<Scene
|
||||
rack={selectedRack}
|
||||
devices={devices}
|
||||
selectedDeviceId={selectedDevice?.deviceId || selectedDevice?.id}
|
||||
onDeviceClick={handleDeviceClick}
|
||||
onDeviceLeave={handleDeviceLeave}
|
||||
onDeviceHover={handleDeviceHover}
|
||||
tooltipFields={tooltipFields}
|
||||
deviceSlideEnabled={deviceSlideEnabled}
|
||||
/>
|
||||
|
||||
{/* Rack Info Overlay (New) */}
|
||||
{selectedRack && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 88, // Moved down to avoid header overlap
|
||||
left: 24,
|
||||
background: 'rgba(255, 255, 255, 0.85)', // Slightly more transparent
|
||||
backdropFilter: 'blur(12px)',
|
||||
padding: isRackInfoCollapsed ? '12px 16px' : '20px',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)', // Softer, deeper shadow
|
||||
width: isRackInfoCollapsed ? 'auto' : '280px',
|
||||
zIndex: 5,
|
||||
border: '1px solid rgba(255, 255, 255, 0.6)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
overflow: 'hidden',
|
||||
cursor: isRackInfoCollapsed ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={() => isRackInfoCollapsed && setIsRackInfoCollapsed(false)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: isRackInfoCollapsed ? 0 : 16, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{
|
||||
width: 36, height: 36,
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
||||
borderRadius: '10px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<CloudServerOutlined style={{ color: 'white', fontSize: 18 }} />
|
||||
</div>
|
||||
<div style={{ opacity: isRackInfoCollapsed ? 0 : 1, width: isRackInfoCollapsed ? 0 : 'auto', transition: 'opacity 0.2s', whiteSpace: 'nowrap', overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: '#1e293b', lineHeight: 1.2 }}>
|
||||
{selectedRack.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginTop: 2 }}>
|
||||
ID: {selectedRack.rackId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={isRackInfoCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRackInfoCollapsed(!isRackInfoCollapsed);
|
||||
}}
|
||||
style={{ color: '#94a3b8', marginLeft: isRackInfoCollapsed ? 8 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Collapsible */}
|
||||
<div style={{
|
||||
maxHeight: isRackInfoCollapsed ? 0 : '500px',
|
||||
opacity: isRackInfoCollapsed ? 0 : 1,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Stats Grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
<div style={{ background: 'rgba(241, 245, 249, 0.6)', padding: '10px', borderRadius: '10px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: 2 }}>总高度</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, color: '#0f172a' }}>
|
||||
{selectedRack.height}<span style={{ fontSize: '12px', fontWeight: 400, marginLeft: 2 }}>U</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: 'rgba(241, 245, 249, 0.6)', padding: '10px', borderRadius: '10px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: 2 }}>设备数</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, color: '#3b82f6' }}>
|
||||
{devices.length}<span style={{ fontSize: '12px', fontWeight: 400, marginLeft: 2 }}>台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guide Section */}
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#94a3b8',
|
||||
marginBottom: 8,
|
||||
display: 'flex', alignItems: 'center'
|
||||
}}>
|
||||
<InfoCircleOutlined style={{ marginRight: 6 }} /> 操作指南
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{[
|
||||
{ icon: '🖱️', text: '左键旋转 / 中键平移' },
|
||||
{ icon: '🔍', text: '滚轮缩放视图' },
|
||||
{ icon: '👆', text: '点击设备查看详情' }
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{
|
||||
fontSize: '12px',
|
||||
color: '#475569',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'rgba(255,255,255,0.5)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
<span style={{ marginRight: 8, opacity: 0.8 }}>{item.icon}</span>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay Info for Hovered Device */}
|
||||
{hoveredDevice && !selectedDevice && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 88, // Moved down to avoid header overlap
|
||||
right: 24,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '300px',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{hoveredDevice.name}</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8 }}>
|
||||
<div>位置: U{hoveredDevice.position}</div>
|
||||
<div>高度: {hoveredDevice.height}U</div>
|
||||
<div>状态: {hoveredDevice.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Detail Floating Popup - Removed, now handled in DeviceModel */}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Empty description={<span style={{ color: 'rgba(0, 0, 0, 0.45)' }}>请选择一个机柜以查看 3D 视图</span>} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<EditOutlined style={{ color: '#667eea' }} />
|
||||
<span>编辑设备信息</span>
|
||||
</div>
|
||||
}
|
||||
open={modalVisible}
|
||||
onCancel={handleModalCancel}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleModalSubmit}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<Form.Item name="name" label="设备名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="设备类型" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Option value="server">服务器</Option>
|
||||
<Option value="switch">交换机</Option>
|
||||
<Option value="router">路由器</Option>
|
||||
<Option value="storage">存储设备</Option>
|
||||
<Option value="firewall">防火墙</Option>
|
||||
<Option value="other">其他设备</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="model" label="型号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="serialNumber" label="序列号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="position" label="位置(U)" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={selectedRack?.height || 45} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="height" label="高度(U)" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Option value="running">运行中</Option>
|
||||
<Option value="maintenance">维护中</Option>
|
||||
<Option value="offline">离线</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="powerConsumption" label="功率(W)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="ipAddress" label="IP地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="purchaseDate" label="购买日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="warrantyExpiry" label="保修到期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button onClick={handleModalCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">保存</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Field Configuration Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SettingOutlined style={{ color: '#1890ff' }} />
|
||||
<span>设备详情字段配置</span>
|
||||
</div>
|
||||
}
|
||||
open={showTooltipConfig}
|
||||
onCancel={() => setShowTooltipConfig(false)}
|
||||
footer={null}
|
||||
width={520}
|
||||
>
|
||||
{loadingTooltipFields ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin />
|
||||
<div style={{ marginTop: 8 }}>加载配置中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, color: '#666' }}>选择要在设备详情卡片中显示的字段:</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{Object.entries(tooltipFields).map(([key, field]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: field.enabled ? '1px solid #1890ff' : '1px solid #d9d9d9',
|
||||
background: field.enabled ? '#e6f7ff' : '#fff',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onClick={() => {
|
||||
setTooltipFields(prev => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], enabled: !prev[key].enabled }
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={field.enabled} style={{ marginRight: 8 }} />
|
||||
<span>{field.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 24, textAlign: 'right', borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
|
||||
<Space>
|
||||
<Button onClick={() => setShowTooltipConfig(false)}>取消</Button>
|
||||
<Button type="primary" onClick={saveTooltipConfig} loading={savingTooltipConfig}>保存配置</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<NetworkCardCreateModal
|
||||
device={operatingDevice}
|
||||
visible={nicModalVisible}
|
||||
onClose={handleCloseNicModal}
|
||||
onSuccess={handleNicSuccess}
|
||||
/>
|
||||
|
||||
<PortCreateModal
|
||||
device={operatingDevice}
|
||||
visible={portModalVisible}
|
||||
onClose={handleClosePortModal}
|
||||
onSuccess={handlePortSuccess}
|
||||
/>
|
||||
|
||||
<CableCreateModal
|
||||
visible={cableModalVisible}
|
||||
onClose={handleCloseCableModal}
|
||||
onSuccess={handleCableSuccess}
|
||||
sourceDevice={operatingDevice}
|
||||
/>
|
||||
|
||||
<DeviceDetailDrawer
|
||||
visible={!!selectedDevice}
|
||||
device={selectedDevice}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
onEdit={handleEditDevice}
|
||||
onAddNic={handleAddNic}
|
||||
onAddPort={handleAddPort}
|
||||
onAddCable={handleAddCable}
|
||||
tooltipFields={tooltipFields}
|
||||
cables={deviceCables}
|
||||
onRefreshCables={() => selectedDevice && fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id)}
|
||||
onDeleteCable={handleDeleteCable}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rack3DVisualization;
|
||||
Reference in New Issue
Block a user