import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useDebounce } from '../hooks/useDebounce'; import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Progress, Drawer, Tag, Tooltip, Row, Col, Typography, Empty, Badge, Checkbox, Dropdown, Statistic, Spin, Pagination, } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, SearchOutlined, ReloadOutlined, EyeOutlined, DatabaseOutlined, HomeOutlined, ThunderboltOutlined, ExpandOutlined, CompressOutlined, MoreOutlined, CheckCircleOutlined, WarningOutlined, SyncOutlined, LineChartOutlined, FilterOutlined, DeleteFilled, } from '@ant-design/icons'; import { designTokens } from '../config/theme'; import CloseButton from '../components/CloseButton'; import RackImportModal from '../components/rack/RackImportModal'; import api from '../api'; const { Option } = Select; const { Title, Text } = Typography; const containerStyle = { minHeight: '100vh', background: 'linear-gradient(180deg, #f5f7fa 0%, #e8ecf1 100%)', padding: '24px', }; const headerStyle = { marginBottom: '24px', padding: '24px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '20px', color: '#fff', boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)', }; const statCardStyle = { background: 'rgba(255, 255, 255, 0.15)', borderRadius: designTokens.borderRadius.medium, padding: '16px', border: '1px solid rgba(255, 255, 255, 0.2)', backdropFilter: 'blur(10px)', }; const cardStyle = { borderRadius: designTokens.borderRadius.large, border: 'none', boxShadow: designTokens.shadows.medium, background: '#fff', overflow: 'hidden', }; const cardHeadStyle = { borderBottom: '1px solid #f0f0f0', padding: '16px 24px', background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)', }; const primaryButtonStyle = { height: '42px', borderRadius: '10px', background: designTokens.colors.primary.gradient, border: 'none', boxShadow: '0 4px 16px rgba(102, 126, 234, 0.35)', fontWeight: '500', }; const actionButtonStyle = { height: '36px', borderRadius: '8px', border: '1px solid #e8e8e8', }; const searchInputStyle = { borderRadius: '10px', height: '42px', border: '1px solid #e8e8e8', }; const statusConfig = { active: { text: '在用', color: 'success', icon: }, maintenance: { text: '维护中', color: 'warning', icon: }, inactive: { text: '停用', color: 'default', icon: }, }; const PowerGauge = ({ current, max }) => { const percentage = Math.min((current / max) * 100, 100); const getColor = () => { if (percentage >= 80) return designTokens.colors.error.main; if (percentage >= 60) return designTokens.colors.warning.main; return designTokens.colors.success.main; }; return (
{current} / {max} W {percentage.toFixed(1)}%
); }; const RackCard = ({ rack, onEdit, onDelete, onView, selected, onSelect }) => { const usedU = rack.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0; const powerUsage = (rack.currentPower / rack.maxPower) * 100; const statusInfo = statusConfig[rack.status]; const availableU = rack.height - usedU; return ( onSelect(rack.rackId)} onDoubleClick={() => onView(rack)} styles={{ body: { padding: '20px' } }} >
{rack.name}
{rack.rackId}
{statusInfo.text}
{rack.Room?.name || '未分配'}
高度/已用U位
{rack.height}U / {usedU}
0 ? Math.min((usedU / rack.height) * 100, 100) : 0} showInfo={false} size="small" strokeColor={usedU / rack.height >= 0.8 ? '#ff4d4f' : usedU / rack.height >= 0.6 ? '#faad14' : '#52c41a'} style={{ marginTop: '4px' }} />
功率使用
= 80 ? designTokens.colors.error.main : designTokens.colors.text.primary, }} > {powerUsage.toFixed(1)}%
最大功率: {rack.maxPower}W
); }; function RackManagement() { const [racks, setRacks] = useState([]); const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); const [drawerVisible, setDrawerVisible] = useState(false); const [importModalVisible, setImportModalVisible] = useState(false); const [editingRack, setEditingRack] = useState(null); const [viewingRack, setViewingRack] = useState(null); const [selectedRackIds, setSelectedRackIds] = useState([]); const [viewMode, setViewMode] = useState('table'); const [searchKeyword, setSearchKeyword] = useState(''); const debouncedSearchKeyword = useDebounce(searchKeyword, 300); const [statusFilter, setStatusFilter] = useState('all'); const [roomFilter, setRoomFilter] = useState('all'); const [stats, setStats] = useState({ total: 0, active: 0, totalPower: 0, totalDevices: 0 }); const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0, pageSizeOptions: ['10', '20', '30', '50', '100'], showSizeChanger: true, showTotal: total => `共 ${total} 条记录`, }); const [form] = Form.useForm(); const fetchRacks = useCallback( async (page = 1, pageSize = 10) => { try { setLoading(true); const [racksRes, statsRes] = await Promise.all([ api.get('/racks', { params: { page, pageSize, roomId: roomFilter, status: statusFilter, keyword: debouncedSearchKeyword || undefined, }, }), api.get('/racks/stats', { params: { roomId: roomFilter, status: statusFilter, keyword: debouncedSearchKeyword || undefined, }, }), ]); const { racks: data, total } = racksRes; setRacks(data); setPagination(prev => ({ ...prev, current: page, pageSize, total })); setStats(statsRes); } catch (error) { message.error('获取机柜列表失败'); console.error('获取机柜列表失败:', error); } finally { setLoading(false); } }, [roomFilter, statusFilter, debouncedSearchKeyword] ); const fetchRooms = useCallback(async () => { try { const response = await api.get('/rooms'); setRooms(response.rooms || []); } catch (error) { message.error('获取机房列表失败'); console.error('获取机房列表失败:', error); } }, []); useEffect(() => { fetchRacks(pagination.current, pagination.pageSize); fetchRooms(); }, [fetchRacks, fetchRooms]); // 当筛选条件变化时,重置到第一页并重新加载 useEffect(() => { setPagination(prev => ({ ...prev, current: 1 })); fetchRacks(1, pagination.pageSize); }, [roomFilter, statusFilter, debouncedSearchKeyword]); const handleTableChange = useCallback( pagination => { fetchRacks(pagination.current, pagination.pageSize); }, [fetchRacks] ); const showModal = (rack = null) => { setEditingRack(rack); if (rack) { form.setFieldsValue(rack); } else { form.resetFields(); } setModalVisible(true); }; const handleCancel = () => { setModalVisible(false); setEditingRack(null); }; const handleSubmit = async values => { try { if (editingRack) { await api.put(`/racks/${editingRack.rackId}`, values); message.success('机柜更新成功'); } else { await api.post('/racks', values); message.success('机柜创建成功'); } setModalVisible(false); fetchRacks(); setEditingRack(null); } catch (error) { message.error(editingRack ? '机柜更新失败' : '机柜创建失败'); console.error(editingRack ? '机柜更新失败:' : '机柜创建失败:', error); } }; const handleDelete = async rackId => { Modal.confirm({ title: '确认删除', content: '确定要删除这个机柜吗?删除后无法恢复。', okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { try { await api.delete(`/racks/${rackId}`); message.success('机柜删除成功'); fetchRacks(); } catch (error) { const errorMsg = error.response?.data?.error || '机柜删除失败'; message.error(errorMsg); console.error('机柜删除失败:', error); } }, }); }; const handleBatchDelete = async () => { if (selectedRackIds.length === 0) { message.warning('请先选择要删除的机柜'); return; } Modal.confirm({ title: '批量删除', content: `确定要删除选中的 ${selectedRackIds.length} 个机柜吗?`, okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { try { const results = await Promise.allSettled( selectedRackIds.map(id => api.delete(`/racks/${id}`)) ); const succeeded = results.filter(r => r.status === 'fulfilled').length; const failed = results.filter(r => r.status === 'rejected'); if (succeeded > 0) { message.success(`成功删除 ${succeeded} 个机柜`); } if (failed.length > 0) { const firstError = failed[0].reason.error || '部分机柜删除失败'; message.error(`${firstError}(${failed.length} 个失败)`); } setSelectedRackIds([]); fetchRacks(); } catch (error) { message.error('批量删除失败'); console.error('批量删除失败:', error); } }, }); }; const handleView = rack => { setViewingRack(rack); setDrawerVisible(true); }; const handleDownloadTemplate = useCallback(async () => { try { message.loading('正在下载模板...', 0); const response = await api.get('/racks/import-template', { responseType: 'blob', }); // 创建下载链接 const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.setAttribute('download', '机柜导入模板.xlsx'); document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); message.destroy(); message.success('模板下载成功'); } catch (error) { message.destroy(); message.error('模板下载失败'); console.error('模板下载失败:', error); } }, []); // 导出租机柜数据 const handleExport = useCallback(async () => { try { const hasSelection = selectedRackIds.length > 0; message.loading(hasSelection ? '正在导出选中机柜数据...' : '正在导出全部机柜数据...', 0); const response = await api.get('/racks/export', { params: hasSelection ? { rackIds: selectedRackIds.join(',') } : {}, responseType: 'blob', }); // 创建下载链接 const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; // 从响应头获取文件名,或使用默认文件名 const contentDisposition = response.headers['content-disposition']; let fileName = '机柜导出.xlsx'; if (contentDisposition) { const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;]+)/); if (match) { fileName = decodeURIComponent(match[1].replace(/['"]/g, '')); } } link.setAttribute('download', fileName); document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); message.destroy(); message.success(hasSelection ? `已导出 ${selectedRackIds.length} 个选中机柜` : '已导出全部机柜'); } catch (error) { message.destroy(); message.error('机柜导出失败'); console.error('机柜导出失败:', error); } }, [selectedRackIds]); const handleImport = useCallback( (file, { onProgress } = {}) => { return new Promise((resolve, reject) => { const doImport = async () => { try { onProgress?.(10, '正在上传文件...'); const formData = new FormData(); formData.append('file', file); onProgress?.(50, '正在处理数据...'); const response = await api.post('/racks/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); onProgress?.(100, '导入完成'); const resData = response; const importResult = { total: resData.total || 0, successCount: resData.imported || 0, duplicates: resData.duplicates || 0, failedCount: 0, errors: [], createdRacks: resData.createdRacks || [], skippedRacks: resData.skippedRacks || [], }; if (resData.details && Array.isArray(resData.details)) { importResult.failedCount = resData.details.length; importResult.errors = resData.details.map(item => ({ row: item.row, error: item.errors.join(';'), })); } if (resData.success) { message.success('机柜导入成功'); } else { message.warning(resData.message || '部分记录导入失败'); } fetchRacks(); resolve(importResult); } catch (error) { const errorData = error.response?.data; if (errorData?.details && Array.isArray(errorData.details)) { const importResult = { total: errorData.total || 0, successCount: 0, failedCount: errorData.details.length, errors: errorData.details.map(item => ({ row: item.row, error: item.errors.join(';'), })), }; resolve(importResult); } else { message.error(errorData?.error || errorData?.message || '机柜导入失败'); console.error('机柜导入失败:', error); reject(new Error(errorData?.error || errorData?.message || '机柜导入失败')); } } }; doImport(); }); }, [fetchRacks] ); // 后端已过滤,直接使用 racks 数据 const tableColumns = [ { title: '机柜信息', key: 'rackInfo', render: (_, record) => (
{record.name}
{record.rackId}
), }, { title: '所属机房', dataIndex: ['Room', 'name'], key: 'room', render: (name, record) => (
{name || '未分配'}
), }, { title: '高度/已用U位', key: 'heightUsage', render: (_, record) => { const used = record.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0; const percentage = record.height > 0 ? Math.min((used / record.height) * 100, 100) : 0; return (
{record.height}U / 已用 {used}U = 80 ? '#ff4d4f' : percentage >= 60 ? '#faad14' : '#52c41a' }}> {percentage.toFixed(0)}%
= 80 ? '#ff4d4f' : percentage >= 60 ? '#faad14' : '#52c41a'} />
); }, sorter: (a, b) => (a.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0) - (b.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0), }, { title: '功率使用', key: 'powerUsage', render: (_, record) => (
), sorter: (a, b) => (a.currentPower || 0) - (b.currentPower || 0), }, { title: '状态', dataIndex: 'status', key: 'status', render: status => { const config = statusConfig[status]; return ( {config.text} ); }, filters: [ { text: '在用', value: 'active' }, { text: '维护中', value: 'maintenance' }, { text: '停用', value: 'inactive' }, ], onFilter: (value, record) => record.status === value, }, { title: '设备数', key: 'deviceCount', render: (_, record) => ( ), sorter: (a, b) => (a.Devices?.length || 0) - (b.Devices?.length || 0), }, { title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', render: date => (date ? new Date(date).toLocaleString() : '-'), sorter: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0), }, { title: '操作', key: 'action', fixed: 'right', width: 160, render: (_, record) => ( )}
{viewMode === 'table' ? ( 'table-row'} /> ) : ( {racks.length > 0 ? ( racks.map(rack => ( { if (selectedRackIds.includes(id)) { setSelectedRackIds(selectedRackIds.filter(rid => rid !== id)); } else { setSelectedRackIds([...selectedRackIds, id]); } }} /> )) ) : ( )} )} {/* 卡片视图分页 - 始终显示当总数据大于0时 */} {viewMode === 'card' && (
{ handleTableChange({ current: page, pageSize }); }} />
)}
{editingRack ? '编辑机柜' : '添加机柜'}
} open={modalVisible} closeIcon={} onCancel={handleCancel} footer={null} width={600} destroyOnHidden styles={{ body: { padding: '24px' }, header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px', position: 'relative', }, }} style={{ borderRadius: '16px' }} classNames={{ header: 'modal-header-fix', }} >
机柜详情 - {viewingRack?.name} } open={drawerVisible} onClose={() => setDrawerVisible(false)} width={520} styles={{ header: { borderBottom: '1px solid #f0f0f0' }, body: { padding: '24px' }, }} > {viewingRack && (
{viewingRack.name}
{viewingRack.rackId}
{statusConfig[viewingRack.status].text}
所属机房
{viewingRack.Room?.name || '未分配'}
设备数量
{viewingRack.Devices?.length || 0}
机柜高度
{viewingRack.height}U
可用U位
{viewingRack.height - (viewingRack.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0)}U
功率使用情况
)} setImportModalVisible(false)} onImport={handleImport} onDownloadTemplate={handleDownloadTemplate} /> ); } export default React.memo(RackManagement);