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}
功率使用
= 80
? designTokens.colors.error.main
: designTokens.colors.text.primary,
}}
>
{powerUsage.toFixed(1)}%
最大功率: {rack.maxPower}W
}
onClick={e => {
e.stopPropagation();
onView(rack);
}}
style={{ color: designTokens.colors.text.secondary }}
/>
}
onClick={e => {
e.stopPropagation();
onEdit(rack);
}}
style={{ color: designTokens.colors.primary.main }}
/>
}
danger
onClick={e => {
e.stopPropagation();
onDelete(rack.rackId);
}}
/>
);
};
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)}%
);
},
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) => (
}
onClick={() => handleView(record)}
style={{ color: designTokens.colors.text.secondary }}
/>
}
onClick={() => showModal(record)}
style={{ color: designTokens.colors.primary.main }}
/>
}
danger
onClick={() => handleDelete(record.rackId)}
/>
),
},
];
const rowSelection = {
selectedRowKeys: selectedRackIds,
onChange: selectedRowKeys => {
setSelectedRackIds(selectedRowKeys);
},
};
return (
设备总数
{stats.totalDevices}
总功率
{(stats.totalPower / 1000).toFixed(1)}kW
}
onClick={() => setViewMode('table')}
style={viewMode === 'table' ? primaryButtonStyle : actionButtonStyle}
>
表格视图
}
onClick={() => setViewMode('card')}
style={viewMode === 'card' ? primaryButtonStyle : actionButtonStyle}
>
卡片视图
{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
}
onClick={() => {
setDrawerVisible(false);
showModal(viewingRack);
}}
style={primaryButtonStyle}
>
编辑机柜
}
onClick={() => {
setDrawerVisible(false);
handleDelete(viewingRack.rackId);
}}
style={{ borderRadius: '8px', height: '42px' }}
>
删除机柜
)}
setImportModalVisible(false)}
onImport={handleImport}
onDownloadTemplate={handleDownloadTemplate}
/>
);
}
export default React.memo(RackManagement);