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, Upload, 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 axios from 'axios'; import { designTokens } from '../config/theme'; import CloseButton from '../components/CloseButton'; 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}
功率使用
= 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 [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 [importProgress, setImportProgress] = useState(0); const [importPhase, setImportPhase] = useState(''); const [isImporting, setIsImporting] = useState(false); const [importResult, setImportResult] = useState(null); const fetchRacks = useCallback( async (page = 1, pageSize = 10) => { try { setLoading(true); const response = await axios.get('/api/racks', { params: { page, pageSize, roomId: roomFilter, status: statusFilter, keyword: debouncedSearchKeyword || undefined, }, }); const { racks: data, total } = response.data; setRacks(data); setPagination(prev => ({ ...prev, current: page, pageSize, total })); } catch (error) { message.error('获取机柜列表失败'); console.error('获取机柜列表失败:', error); } finally { setLoading(false); } }, [roomFilter, statusFilter, debouncedSearchKeyword] ); const fetchRooms = useCallback(async () => { try { const response = await axios.get('/api/rooms'); setRooms(response.data.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 axios.put(`/api/racks/${editingRack.rackId}`, values); message.success('机柜更新成功'); } else { await axios.post('/api/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 axios.delete(`/api/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 => axios.delete(`/api/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.response?.data?.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(() => { window.open('/api/racks/import-template', '_blank'); message.success('模板下载成功'); }, []); // 导出租机柜数据 const handleExport = useCallback(async () => { try { message.loading('正在导出租机柜数据...', 0); const response = await axios.get('/api/racks/export', { 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('机柜导出成功'); } catch (error) { message.destroy(); message.error('机柜导出失败'); console.error('机柜导出失败:', error); } }, []); const handleImport = useCallback( async file => { try { setIsImporting(true); setImportProgress(0); setImportPhase('正在上传文件...'); setImportResult(null); const formData = new FormData(); formData.append('file', file); const response = await axios.post('/api/racks/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setImportProgress(100); setImportPhase('导入完成'); const resData = response.data; 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(';'), })); } setImportResult(importResult); setIsImporting(false); if (resData.success) { message.success('机柜导入成功'); } else { message.warning(resData.message || '部分记录导入失败'); } fetchRacks(); return false; } catch (error) { setIsImporting(false); setImportProgress(0); 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(';'), })), }; setImportResult(importResult); setImportPhase('导入失败'); } else { message.error(errorData?.error || errorData?.message || '机柜导入失败'); console.error('机柜导入失败:', error); } return false; } }, [fetchRacks] ); // 后端已过滤,直接使用 racks 数据 const filteredRacks = racks; const stats = useMemo( () => ({ total: racks.length, active: racks.filter(r => r.status === 'active').length, maintenance: racks.filter(r => r.status === 'maintenance').length, totalPower: racks.reduce((sum, r) => sum + (r.currentPower || 0), 0), totalDevices: racks.reduce((sum, r) => sum + (r.Devices?.length || 0), 0), }), [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; return ( {record.height}U / 已用 {used} U位 ); }, 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'} /> ) : ( {filteredRacks.length > 0 ? ( filteredRacks.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?.length || 0)}U
功率使用情况
)}
导入机柜
} open={importModalVisible} closeIcon={} onCancel={() => { setImportModalVisible(false); setImportProgress(0); setImportPhase(''); setImportResult(null); setIsImporting(false); }} footer={null} width={650} destroyOnHidden styles={{ body: { padding: '24px' }, header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px', position: 'relative', }, }} style={{ borderRadius: '16px' }} > {!isImporting && !importResult ? (

请上传XLSX格式的机柜数据文件

支持的编码格式:UTF-8

Excel文件格式要求:

  • 必填字段:机柜ID、机柜名称、所属机房名称、高度(U)、最大功率(W)、状态
  • 状态值:active(在用)、maintenance(维护中)、inactive(停用)
  • 高度和功率必须是数字格式
包含示例数据的XLSX模板文件
) : isImporting ? (

正在导入机柜数据

{importPhase}

`${importProgress}%`} />
) : ( importResult && (

导入结果:

✓ 总记录数:{importResult.total || 0}

✓ 成功导入:{importResult.successCount || 0}

{importResult.failedCount > 0 && (

✗ 导入失败:{importResult.failedCount}

)} {importResult.duplicates > 0 && (

⚠ 跳过(已存在):{importResult.duplicates}

)}
{importResult.createdRacks && importResult.createdRacks.length > 0 && (

✓ 本次新增机柜({importResult.createdRacks.length}):

{importResult.createdRacks.map((rack, idx) => (
• {rack.rackId} - {rack.name}
))}
)} {importResult.skippedRacks && importResult.skippedRacks.length > 0 && (

⚠ 已跳过机柜({importResult.skippedRacks.length}):

{importResult.skippedRacks.map((rack, idx) => (
• {rack.rackId} - {rack.name}
))}
)} {importResult.errors && importResult.errors.length > 0 && (

错误详情:

{importResult.errors.slice(0, 5).map((err, idx) => (
第{err.row || idx + 1}行:{err.error}
))} {importResult.errors.length > 5 && (

还有 {importResult.errors.length - 5} 处错误...

)}
)}
) )}
); } export default React.memo(RackManagement);