feat(机柜管理): 优化机柜导入导出功能

- 新增机柜导入预览功能,支持数据验证和错误提示
- 支持按选中机柜导出,优化导出文件名和提示信息
- 重构导入逻辑,分离Excel解析和验证逻辑
- 修复机柜关联设备查询问题,使用正确的关系别名
- 更新API文档,添加导入预览和选中导出接口说明
This commit is contained in:
zhang1106
2026-05-06 15:12:57 +08:00
parent 9c0fd0b3ff
commit fa39f2855f
11 changed files with 2095 additions and 543 deletions
+6 -1
View File
@@ -69,6 +69,9 @@ api.interceptors.request.use(
api.interceptors.response.use(
response => {
if (response.config.responseType === 'blob') {
return response;
}
return response.data;
},
error => {
@@ -96,7 +99,9 @@ api.interceptors.response.use(
}
}
return Promise.reject(data.message || '请求失败');
error.friendlyMessage = data.message || '请求失败';
error.message = data.message || '请求失败';
return Promise.reject(error);
}
if (error.code === 'ECONNABORTED') {
@@ -155,7 +155,7 @@ const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
const handleDownloadTemplate = async () => {
try {
const response = await deviceAPI.getImportTemplate();
const blob = new Blob([response], { type: 'text/csv; charset=gbk' });
const blob = new Blob([response.data], { type: 'text/csv; charset=gbk' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -0,0 +1,848 @@
import React, { useState, useCallback } from 'react';
import {
Modal,
Upload,
Button,
Progress,
message,
Steps,
Card,
Alert,
Space,
Tag,
Typography,
Divider,
Table,
Spin,
} from 'antd';
import {
UploadOutlined,
DownloadOutlined,
CheckCircleOutlined,
WarningOutlined,
FileExcelOutlined,
InboxOutlined,
DatabaseOutlined,
FileTextOutlined,
ArrowRightOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { designTokens } from '../../config/theme';
import CloseButton from '../CloseButton';
import api from '../../api';
const { Title, Text, Paragraph } = Typography;
const { Step } = Steps;
const { Dragger } = Upload;
/**
* 机柜导入弹窗组件
* @param {boolean} visible - 弹窗显示状态
* @param {Function} onClose - 关闭回调
* @param {Function} onImport - 导入回调
* @param {Function} onDownloadTemplate - 下载模板回调
* @returns {JSX.Element}
*/
function RackImportModal({ visible, onClose, onImport, onDownloadTemplate }) {
const [currentStep, setCurrentStep] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
const [selectedFile, setSelectedFile] = useState(null);
const [previewData, setPreviewData] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const resetState = useCallback(() => {
setCurrentStep(0);
setIsImporting(false);
setImportProgress(0);
setImportPhase('');
setImportResult(null);
setSelectedFile(null);
setPreviewData(null);
setPreviewLoading(false);
}, []);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/**
* 调用后端接口解析 Excel 文件获取预览数据
* @param {File} file - 上传的文件
*/
const fetchPreviewData = useCallback(async file => {
setPreviewLoading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/racks/import-preview', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (response.success) {
setPreviewData(response.data);
setCurrentStep(1);
} else {
message.error(response.error || '预览失败');
setSelectedFile(null);
}
} catch (error) {
console.error('预览失败:', error);
const errMsg = error.response?.data?.error || error.message || '预览失败,请检查文件格式';
message.error(errMsg);
setSelectedFile(null);
} finally {
setPreviewLoading(false);
}
}, []);
const handleBeforeUpload = useCallback(
file => {
const isXlsx =
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel';
if (!isXlsx) {
message.error('请上传 Excel 文件(.xlsx 或 .xls 格式)');
return false;
}
setSelectedFile(file);
fetchPreviewData(file);
return false;
},
[fetchPreviewData]
);
const handleStartImport = useCallback(async () => {
if (!selectedFile) {
message.warning('请先选择文件');
return;
}
setIsImporting(true);
setCurrentStep(2);
setImportProgress(0);
setImportPhase('正在解析文件...');
try {
const result = await onImport(selectedFile, {
onProgress: (progress, phase) => {
setImportProgress(progress);
setImportPhase(phase);
},
});
setImportResult(result);
setImportProgress(100);
setImportPhase('导入完成');
setCurrentStep(3);
} catch (error) {
message.error(error?.message || '导入失败');
setImportPhase('导入失败');
} finally {
setIsImporting(false);
}
}, [selectedFile, onImport]);
const steps = [
{ title: '上传文件', icon: <UploadOutlined /> },
{ title: '数据预览', icon: <FileTextOutlined /> },
{ title: '导入中', icon: <DatabaseOutlined /> },
{ title: '完成', icon: <CheckCircleOutlined /> },
];
const renderUploadStep = () => (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 文件上传区域 */}
<Card
style={{
borderRadius: designTokens.borderRadius.large,
border: `2px dashed ${designTokens.colors.border.light}`,
background: designTokens.colors.background.secondary,
}}
bodyStyle={{ padding: '40px 24px' }}
>
<Dragger
name="file"
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleBeforeUpload}
maxCount={1}
disabled={previewLoading}
style={{ background: 'transparent', border: 'none' }}
>
<div style={{ textAlign: 'center' }}>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '50%',
background: designTokens.colors.primary.bg,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '16px',
}}
>
{previewLoading ? (
<Spin size="large" />
) : (
<InboxOutlined
style={{ fontSize: '36px', color: designTokens.colors.primary.main }}
/>
)}
</div>
<Title level={5} style={{ margin: '0 0 8px', color: designTokens.colors.text.primary }}>
{previewLoading ? '正在解析文件...' : '点击或拖拽文件到此处上传'}
</Title>
<Paragraph style={{ color: designTokens.colors.text.tertiary, margin: 0 }}>
支持 .xlsx.xls 格式文件大小不超过 10MB
</Paragraph>
</div>
</Dragger>
</Card>
{/* 模板下载 */}
<Card
style={{
borderRadius: designTokens.borderRadius.large,
border: `1px solid ${designTokens.colors.border.light}`,
}}
bodyStyle={{ padding: '20px 24px' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: designTokens.borderRadius.medium,
background: designTokens.colors.success.bg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<FileExcelOutlined
style={{ fontSize: '24px', color: designTokens.colors.success.main }}
/>
</div>
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: '15px', color: designTokens.colors.text.primary }}>
下载导入模板
</Text>
<Paragraph style={{ color: designTokens.colors.text.secondary, margin: '4px 0 0', fontSize: '13px' }}>
包含标准字段和示例数据帮助您快速准备导入文件
</Paragraph>
</div>
<Button
icon={<DownloadOutlined />}
onClick={onDownloadTemplate}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.medium,
borderColor: designTokens.colors.primary.main,
color: designTokens.colors.primary.main,
}}
>
下载模板
</Button>
</div>
</Card>
{/* 格式说明 */}
<Alert
type="info"
showIcon
icon={<InfoCircleOutlined />}
message="Excel 文件格式要求"
description={
<ul style={{ margin: '8px 0 0', paddingLeft: '20px', lineHeight: '1.8' }}>
<li>
<Text strong>必填字段</Text>
机柜ID机柜名称所属机房名称高度(U)最大功率(W)状态
</li>
<li>
<Text strong>状态值</Text>
<Tag color="success">active在用</Tag>
<Tag color="warning">maintenance维护中</Tag>
<Tag>inactive停用</Tag>
</li>
<li>
<Text strong>数据格式</Text>
高度和功率必须是数字格式编码为 UTF-8
</li>
</ul>
}
style={{
borderRadius: designTokens.borderRadius.large,
border: `1px solid ${designTokens.colors.info.light}40`,
background: designTokens.colors.info.bg,
}}
/>
</Space>
);
const renderPreviewStep = () => {
if (!previewData) return null;
const columns = [
{ title: '行号', dataIndex: '_rowNum', key: '_rowNum', width: 60 },
{ title: '机柜ID', dataIndex: 'rackId', key: 'rackId', width: 100 },
{ title: '机柜名称', dataIndex: 'name', key: 'name', width: 120 },
{ title: '所属机房', dataIndex: 'roomName', key: 'roomName', width: 100 },
{ title: '高度(U)', dataIndex: 'height', key: 'height', width: 80 },
{ title: '最大功率(W)', dataIndex: 'maxPower', key: 'maxPower', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
];
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 统计卡片 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
}}
>
<Card
style={{
borderRadius: designTokens.borderRadius.large,
background: designTokens.colors.primary.bg,
border: `1px solid ${designTokens.colors.primary.light}30`,
}}
bodyStyle={{ padding: '16px', textAlign: 'center' }}
>
<Text style={{ fontSize: '24px', fontWeight: 700, color: designTokens.colors.primary.main }}>
{previewData.total}
</Text>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
总记录数
</div>
</Card>
<Card
style={{
borderRadius: designTokens.borderRadius.large,
background: designTokens.colors.success.bg,
border: `1px solid ${designTokens.colors.success.light}30`,
}}
bodyStyle={{ padding: '16px', textAlign: 'center' }}
>
<Text style={{ fontSize: '24px', fontWeight: 700, color: designTokens.colors.success.main }}>
{previewData.valid}
</Text>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
有效数据
</div>
</Card>
<Card
style={{
borderRadius: designTokens.borderRadius.large,
background: previewData.invalid > 0 ? designTokens.colors.error.bg : designTokens.colors.success.bg,
border: `1px solid ${previewData.invalid > 0 ? designTokens.colors.error.light : designTokens.colors.success.light}30`,
}}
bodyStyle={{ padding: '16px', textAlign: 'center' }}
>
<Text
style={{
fontSize: '24px',
fontWeight: 700,
color: previewData.invalid > 0 ? designTokens.colors.error.main : designTokens.colors.success.main,
}}
>
{previewData.invalid}
</Text>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
无效数据
</div>
</Card>
</div>
{/* 验证结果提示 */}
{previewData.invalid > 0 ? (
<Alert
type="warning"
showIcon
icon={<WarningOutlined />}
message={`发现 ${previewData.invalid} 条数据存在错误`}
description="错误行已用红色标记,请核对后确认导入。建议修复错误后重新上传。"
style={{ borderRadius: designTokens.borderRadius.large }}
/>
) : (
<Alert
type="success"
showIcon
icon={<CheckCircleOutlined />}
message="数据验证通过"
description="所有数据格式正确,可以进行导入。"
style={{ borderRadius: designTokens.borderRadius.large }}
/>
)}
{/* 数据预览表格 */}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<FileTextOutlined style={{ color: designTokens.colors.primary.main }} />
<Text strong>数据预览</Text>
<Tag color="blue"> {previewData.preview.length} </Tag>
</div>
}
style={{
borderRadius: designTokens.borderRadius.large,
border: `1px solid ${designTokens.colors.border.light}`,
}}
bodyStyle={{ padding: 0 }}
>
<Table
columns={columns}
dataSource={previewData.preview}
rowKey="_rowNum"
size="small"
pagination={false}
scroll={{ x: 'max-content' }}
rowClassName={record => (record._hasError ? 'ant-table-row-error' : '')}
style={{
borderRadius: `0 0 ${designTokens.borderRadius.large} ${designTokens.borderRadius.large}`,
}}
/>
</Card>
{/* 错误详情 */}
{previewData.errors && previewData.errors.length > 0 && (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<CloseCircleOutlined style={{ color: designTokens.colors.error.main }} />
<Text strong style={{ color: designTokens.colors.error.main }}>
错误详情
</Text>
</div>
}
style={{
borderRadius: designTokens.borderRadius.large,
border: `1px solid ${designTokens.colors.error.light}50`,
background: designTokens.colors.error.bg,
}}
bodyStyle={{ padding: '16px' }}
>
{previewData.errors.map((err, index) => (
<div
key={index}
style={{
padding: '8px 12px',
background: '#fff',
borderRadius: designTokens.borderRadius.medium,
marginBottom: index < previewData.errors.length - 1 ? '8px' : 0,
border: `1px solid ${designTokens.colors.error.light}30`,
}}
>
<Text strong style={{ color: designTokens.colors.error.main }}>
{err.row}
</Text>
<Text style={{ color: designTokens.colors.text.secondary }}>
{err.errors.join('')}
</Text>
</div>
))}
</Card>
)}
{/* 操作按钮 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<Button
size="large"
onClick={() => {
setSelectedFile(null);
setPreviewData(null);
setCurrentStep(0);
}}
style={{ borderRadius: designTokens.borderRadius.medium }}
>
重新选择
</Button>
<Button
type="primary"
size="large"
icon={<ArrowRightOutlined />}
onClick={handleStartImport}
disabled={previewData.invalid > 0}
style={{
borderRadius: designTokens.borderRadius.medium,
background: previewData.invalid > 0 ? undefined : designTokens.colors.primary.gradient,
border: 'none',
}}
>
确认导入
</Button>
</div>
</Space>
);
};
const renderImportingStep = () => (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '50%',
background: designTokens.colors.primary.gradient,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
animation: 'pulse 2s infinite',
}}
>
<DatabaseOutlined style={{ fontSize: '36px', color: '#fff' }} />
</div>
<Title level={4} style={{ margin: '0 0 8px', color: designTokens.colors.text.primary }}>
正在导入机柜数据
</Title>
<Text style={{ color: designTokens.colors.primary.main, fontSize: '15px' }}>
{importPhase}
</Text>
<div style={{ marginTop: '32px', maxWidth: '400px', margin: '32px auto 0' }}>
<Progress
percent={importProgress}
status="active"
strokeColor={{
'0%': designTokens.colors.primary.main,
'100%': designTokens.colors.purple.main,
}}
strokeWidth={8}
style={{ borderRadius: '4px' }}
/>
</div>
<style>{`
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.9; }
}
`}</style>
</div>
);
const renderResultStep = () => {
if (!importResult) return null;
const isSuccess = importResult.failedCount === 0;
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 结果图标和标题 */}
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '50%',
background: isSuccess ? designTokens.colors.success.gradient : designTokens.colors.warning.gradient,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '16px',
}}
>
{isSuccess ? (
<CheckCircleOutlined style={{ fontSize: '40px', color: '#fff' }} />
) : (
<WarningOutlined style={{ fontSize: '40px', color: '#fff' }} />
)}
</div>
<Title level={4} style={{ margin: '0 0 8px' }}>
{isSuccess ? '导入成功' : '导入完成(部分失败)'}
</Title>
<Text style={{ color: designTokens.colors.text.secondary }}>
{isSuccess
? '所有机柜数据已成功导入系统'
: '部分数据导入失败,请查看下方错误详情'}
</Text>
</div>
{/* 统计卡片 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
}}
>
<Card
style={{
borderRadius: designTokens.borderRadius.large,
background: designTokens.colors.primary.bg,
border: `1px solid ${designTokens.colors.primary.light}30`,
}}
bodyStyle={{ padding: '16px', textAlign: 'center' }}
>
<Text style={{ fontSize: '24px', fontWeight: 700, color: designTokens.colors.primary.main }}>
{importResult.total || 0}
</Text>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
总记录数
</div>
</Card>
<Card
style={{
borderRadius: designTokens.borderRadius.large,
background: designTokens.colors.success.bg,
border: `1px solid ${designTokens.colors.success.light}30`,
}}
bodyStyle={{ padding: '16px', textAlign: 'center' }}
>
<Text style={{ fontSize: '24px', fontWeight: 700, color: designTokens.colors.success.main }}>
{importResult.successCount || 0}
</Text>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
成功导入
</div>
</Card>
<Card
style={{
borderRadius: designTokens.borderRadius.large,
background:
importResult.failedCount > 0
? designTokens.colors.error.bg
: designTokens.colors.success.bg,
border: `1px solid ${importResult.failedCount > 0 ? designTokens.colors.error.light : designTokens.colors.success.light}30`,
}}
bodyStyle={{ padding: '16px', textAlign: 'center' }}
>
<Text
style={{
fontSize: '24px',
fontWeight: 700,
color:
importResult.failedCount > 0
? designTokens.colors.error.main
: designTokens.colors.success.main,
}}
>
{importResult.failedCount || 0}
</Text>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
导入失败
</div>
</Card>
</div>
{/* 新增机柜列表 */}
{importResult.createdRacks && importResult.createdRacks.length > 0 && (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />
<Text strong>本次新增机柜</Text>
<Tag color="success">{importResult.createdRacks.length} </Tag>
</div>
}
style={{
borderRadius: designTokens.borderRadius.large,
border: `1px solid ${designTokens.colors.success.light}50`,
background: designTokens.colors.success.bg,
}}
bodyStyle={{ padding: '16px', maxHeight: '200px', overflow: 'auto' }}
>
{importResult.createdRacks.map((rack, idx) => (
<div
key={idx}
style={{
padding: '8px 12px',
background: '#fff',
borderRadius: designTokens.borderRadius.medium,
marginBottom: idx < importResult.createdRacks.length - 1 ? '8px' : 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<DatabaseOutlined style={{ color: designTokens.colors.primary.main }} />
<Text strong>{rack.rackId}</Text>
<Text type="secondary">-</Text>
<Text>{rack.name}</Text>
</div>
))}
</Card>
)}
{/* 跳过机柜列表 */}
{importResult.skippedRacks && importResult.skippedRacks.length > 0 && (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<WarningOutlined style={{ color: designTokens.colors.warning.main }} />
<Text strong>已跳过机柜已存在</Text>
<Tag color="warning">{importResult.skippedRacks.length} </Tag>
</div>
}
style={{
borderRadius: designTokens.borderRadius.large,
border: `1px solid ${designTokens.colors.warning.light}50`,
background: designTokens.colors.warning.bg,
}}
bodyStyle={{ padding: '16px', maxHeight: '200px', overflow: 'auto' }}
>
{importResult.skippedRacks.map((rack, idx) => (
<div
key={idx}
style={{
padding: '8px 12px',
background: '#接口',
borderRadius: designTokens.borderRadius.medium,
marginBottom: idx < importResult.skippedRacks.length - 1 ? '8px' : 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<DatabaseOutlined style={{ color: designTokens.colors.warning.main }} />
<Text strong>{rack.rackId}</Text>
<Text type="secondary">-</Text>
<Text>{rack.name}</Text>
</div>
))}
</Card>
)}
{/* 错误详情 */}
{importResult.errors && importResult.errors.length > 0 && (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<CloseCircleOutlined style={{ color: designTokens.colors.error.main }} />
<Text strong style={{ color: designTokens.colors.error.main }}>
错误详情
</Text>
</div>
}
style={{
borderRadius: designTokens.borderRadius.large,
border: `1px solid ${designTokens.colors.error.light}50`,
background: designTokens.colors.error.bg,
}}
bodyStyle={{ padding: '16px', maxHeight: '250px', overflow: 'auto' }}
>
{importResult.errors.slice(0, 5).map((err, idx) => (
<div
key={idx}
style={{
padding: '10px 12px',
background: '#fff',
borderRadius: designTokens.borderRadius.medium,
marginBottom: idx < Math.min(importResult.errors.length, 5) - 1 ? '8px' : 0,
border: `1px solid ${designTokens.colors.error.light}30`,
}}
>
<Text strong style={{ color: designTokens.colors.error.main }}>
{err.row || idx + 1}
</Text>
<Divider type="vertical" style={{ margin: '0 8px' }} />
<Text style={{ color: designTokens.colors.text.secondary }}>{err.error}</Text>
</div>
))}
{importResult.errors.length > 5 && (
<div style={{ textAlign: 'center', marginTop: '8px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
还有 {importResult.errors.length - 5} 处错误未显示...
</Text>
</div>
)}
</Card>
)}
{/* 完成按钮 */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<Button
type="primary"
size="large"
onClick={handleClose}
style={{
borderRadius: designTokens.borderRadius.medium,
background: designTokens.colors.primary.gradient,
border: 'none',
minWidth: '120px',
}}
>
完成
</Button>
</div>
</Space>
);
};
const renderStepContent = () => {
switch (currentStep) {
case 0:
return renderUploadStep();
case 1:
return renderPreviewStep();
case 2:
return renderImportingStep();
case 3:
return renderResultStep();
default:
return renderUploadStep();
}
};
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', paddingRight: '32px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: designTokens.borderRadius.medium,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
<UploadOutlined />
</div>
<span style={{ fontSize: '18px', fontWeight: 600 }}>导入机柜</span>
</div>
}
open={visible}
closeIcon={<CloseButton />}
onCancel={handleClose}
footer={null}
width={720}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
style={{ borderRadius: designTokens.borderRadius.large }}
>
{/* 步骤条 */}
<Steps
current={currentStep}
items={steps}
style={{ marginBottom: '24px' }}
size="small"
/>
{/* 步骤内容 */}
{renderStepContent()}
</Modal>
);
}
export default React.memo(RackImportModal);
+2 -2
View File
@@ -224,9 +224,9 @@ const BackupManagement = () => {
const handleDownload = async filename => {
try {
const blob = await backupAPI.download(filename);
const response = await backupAPI.download(filename);
// 创建临时链接并触发下载
const url = window.URL.createObjectURL(blob);
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.download = filename;
+81 -354
View File
@@ -23,7 +23,6 @@ import {
Checkbox,
Dropdown,
Statistic,
Upload,
Spin,
Pagination,
} from 'antd';
@@ -51,6 +50,7 @@ import {
} 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;
@@ -312,10 +312,6 @@ function RackManagement() {
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) => {
@@ -475,7 +471,7 @@ function RackManagement() {
});
// 创建下载链接
const blob = new Blob([response], {
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
@@ -499,14 +495,16 @@ function RackManagement() {
// 导出租机柜数据
const handleExport = useCallback(async () => {
try {
message.loading('正在导出租机柜数据...', 0);
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], {
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
@@ -530,85 +528,82 @@ function RackManagement() {
window.URL.revokeObjectURL(url);
message.destroy();
message.success('机柜导出成功');
message.success(hasSelection ? `已导出 ${selectedRackIds.length} 个选中机柜` : '已导出全部机柜');
} catch (error) {
message.destroy();
message.error('机柜导出失败');
console.error('机柜导出失败:', error);
}
}, []);
}, [selectedRackIds]);
const handleImport = useCallback(
async file => {
try {
setIsImporting(true);
setImportProgress(0);
setImportPhase('正在上传文件...');
setImportResult(null);
(file, { onProgress } = {}) => {
return new Promise((resolve, reject) => {
const doImport = async () => {
try {
onProgress?.(10, '正在上传文件...');
const formData = new FormData();
formData.append('file', file);
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/racks/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
onProgress?.(50, '正在处理数据...');
setImportProgress(100);
setImportPhase('导入完成');
const response = await api.post('/racks/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
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 || [],
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 || '机柜导入失败'));
}
}
};
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;
}
doImport();
});
},
[fetchRacks]
);
@@ -911,8 +906,12 @@ function RackManagement() {
>
导入
</Button>
<Button icon={<DownloadOutlined />} onClick={handleExport} style={actionButtonStyle}>
导出
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
style={actionButtonStyle}
>
{selectedRackIds.length > 0 ? `导出选中 (${selectedRackIds.length})` : '导出全部'}
</Button>
<Button
type="primary"
@@ -1273,284 +1272,12 @@ function RackManagement() {
)}
</Drawer>
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', paddingRight: '32px' }}>
<div
style={{
width: '4px',
height: '20px',
background: designTokens.colors.primary.gradient,
borderRadius: '2px',
}}
/>
导入机柜
</div>
}
open={importModalVisible}
closeIcon={<CloseButton />}
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 ? (
<div>
<div
style={{
padding: '16px',
background: designTokens.colors.primary.bgGradient,
borderRadius: '12px',
marginBottom: '20px',
border: '1px solid #e8e8e8',
}}
>
<p style={{ margin: '0 0 12px 0', fontWeight: '600', color: '#262626' }}>
请上传XLSX格式的机柜数据文件
</p>
<p style={{ margin: 0, color: '#8c8c8c', fontSize: '12px' }}>支持的编码格式UTF-8</p>
</div>
<div style={{ marginBottom: '24px' }}>
<p style={{ fontWeight: '600', marginBottom: '12px', color: '#262626' }}>
Excel文件格式要求
</p>
<ul
style={{
paddingLeft: '20px',
marginBottom: '10px',
color: '#595959',
lineHeight: '1.8',
}}
>
<li>必填字段机柜ID机柜名称所属机房名称高度(U)最大功率(W)状态</li>
<li>状态值active(在用)maintenance(维护中)inactive(停用)</li>
<li>高度和功率必须是数字格式</li>
</ul>
</div>
<div
style={{
padding: '16px',
background: '#f6f6f6',
borderRadius: '8px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<Button
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
style={actionButtonStyle}
>
下载导入模板
</Button>
<span style={{ color: '#8c8c8c', fontSize: '12px' }}>包含示例数据的XLSX模板文件</span>
</div>
<Upload
name="file"
accept=".xlsx, .xls"
showUploadList={false}
beforeUpload={handleImport}
maxCount={1}
>
<Button
type="primary"
icon={<UploadOutlined />}
block
style={{
...primaryButtonStyle,
height: '48px',
fontSize: '16px',
}}
>
选择Excel文件
</Button>
</Upload>
</div>
) : isImporting ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '16px',
color: '#fff',
fontSize: '20px',
}}
>
<UploadOutlined spin />
</div>
<div>
<p
style={{
margin: '0 0 4px 0',
fontWeight: '600',
color: '#333',
fontSize: '16px',
}}
>
正在导入机柜数据
</p>
<p style={{ margin: 0, color: designTokens.colors.primary.main, fontSize: '14px' }}>
{importPhase}
</p>
</div>
</div>
<Progress
percent={importProgress}
status="active"
strokeColor={{
'0%': '#667eea',
'100%': '#764ba2',
}}
format={() => `${importProgress}%`}
/>
</div>
) : (
importResult && (
<div>
<div
style={{
padding: '20px',
background: '#f6f6f6',
borderRadius: '12px',
marginBottom: '20px',
}}
>
<p style={{ marginBottom: '12px', fontWeight: '600' }}>导入结果</p>
<p style={{ margin: '8px 0', color: '#52c41a' }}>
总记录数{importResult.total || 0}
</p>
<p style={{ margin: '8px 0', color: '#52c41a' }}>
成功导入{importResult.successCount || 0}
</p>
{importResult.failedCount > 0 && (
<p style={{ margin: '8px 0', color: '#ff4d4f' }}>
导入失败{importResult.failedCount}
</p>
)}
{importResult.duplicates > 0 && (
<p style={{ margin: '8px 0', color: '#faad14' }}>
跳过已存在{importResult.duplicates}
</p>
)}
</div>
{importResult.createdRacks && importResult.createdRacks.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#52c41a' }}>
本次新增机柜{importResult.createdRacks.length}
</p>
<div
style={{
maxHeight: '150px',
overflow: 'auto',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '8px',
padding: '12px',
}}
>
{importResult.createdRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
</div>
))}
</div>
</div>
)}
{importResult.skippedRacks && importResult.skippedRacks.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#faad14' }}>
已跳过机柜{importResult.skippedRacks.length}
</p>
<div
style={{
maxHeight: '150px',
overflow: 'auto',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: '8px',
padding: '12px',
}}
>
{importResult.skippedRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
</div>
))}
</div>
</div>
)}
{importResult.errors && importResult.errors.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#ff4d4f' }}>
错误详情
</p>
{importResult.errors.slice(0, 5).map((err, idx) => (
<div
key={idx}
style={{
padding: '8px 12px',
background: '#fff2f0',
borderRadius: '6px',
marginBottom: '8px',
fontSize: '13px',
color: '#cf1322',
}}
>
{err.row || idx + 1}{err.error}
</div>
))}
{importResult.errors.length > 5 && (
<p style={{ color: '#8c8c8c', fontSize: '12px' }}>
还有 {importResult.errors.length - 5} 处错误...
</p>
)}
</div>
)}
<Button
type="primary"
onClick={() => {
setImportModalVisible(false);
setImportResult(null);
fetchRacks();
}}
style={primaryButtonStyle}
>
完成
</Button>
</div>
)
)}
</Modal>
<RackImportModal
visible={importModalVisible}
onClose={() => setImportModalVisible(false)}
onImport={handleImport}
onDownloadTemplate={handleDownloadTemplate}
/>
</div>
);
}