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, } 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'; const { Option } = Select; const { Title, Text } = Typography; const designTokens = { colors: { primary: { main: '#1890ff', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', bgGradient: 'linear-gradient(135deg, #667eea15 0%, #764ba208 100%)', }, success: { main: '#52c41a', gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)', }, warning: { main: '#faad14', gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)', }, error: { main: '#ff4d4f', gradient: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)', }, purple: { main: '#722ed1', gradient: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)', }, text: { primary: '#262626', secondary: '#8c8c8c', tertiary: '#bfbfbf', }, }, shadows: { small: '0 2px 8px rgba(0, 0, 0, 0.06)', medium: '0 4px 16px rgba(0, 0, 0, 0.08)', large: '0 8px 24px rgba(0, 0, 0, 0.12)', }, borderRadius: { small: '8px', medium: '12px', large: '16px', }, transitions: { normal: '0.3s cubic-bezier(0.4, 0, 0.2, 1)', }, }; 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 deviceCount = rack.Devices?.length || 0; const powerUsage = (rack.currentPower / rack.maxPower) * 100; const statusInfo = statusConfig[rack.status]; const availableU = rack.height - deviceCount; return ( onSelect(rack.rackId)} onDoubleClick={() => onView(rack)} styles={{ body: { padding: '20px' } }} >
{rack.name}
{rack.rackId}
{statusInfo.text}
{rack.Room?.name || '未分配'}
高度/已用U位
{rack.height}U / {deviceCount}
功率使用
= 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); } 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) { message.error('机柜删除失败'); 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 { await Promise.all(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`))); message.success(`成功删除 ${selectedRackIds.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('导入完成'); setImportResult(response.data); setIsImporting(false); if (response.data.success) { message.success('机柜导入成功'); } else { message.warning(response.data.message || '部分记录导入失败'); } fetchRacks(); return false; } catch (error) { setIsImporting(false); setImportProgress(0); message.error('机柜导入失败'); 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?.length || 0; const percentage = (used / record.height) * 100; return (
{record.height}U = 90 ? designTokens.colors.error.main : designTokens.colors.success.main } trailColor="#f0f0f0" style={{ marginTop: '4px', marginBottom: 0 }} /> 已用 {used} U位
); }, sorter: (a, b) => (a.Devices?.length || 0) - (b.Devices?.length || 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]); } }} /> )) ) : ( )} )}
{editingRack ? '编辑机柜' : '添加机柜'}
} open={modalVisible} onCancel={handleCancel} footer={null} width={600} destroyOnHidden styles={{ body: { padding: '24px' }, header: { borderBottom: '1px solid #f0f0f0', padding: '16px 50px 16px 24px' }, }} style={{ borderRadius: '16px' }} >
机柜详情 - {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} 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' }, }} 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.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);