feat(导入功能): 增强导入流程的用户体验和错误处理

为耗材、设备和机柜管理页面添加导入进度显示和阶段提示
优化错误处理逻辑,提供更详细的错误信息
统一三个页面的导入界面样式和交互
This commit is contained in:
zhang1106
2025-12-29 12:08:05 +08:00
parent 5ad3381d41
commit ae61fdaf30
3 changed files with 280 additions and 88 deletions
+106 -12
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable } from 'antd'; import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable, Progress } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@@ -25,7 +25,9 @@ function ConsumableManagement() {
const [importPreview, setImportPreview] = useState([]); const [importPreview, setImportPreview] = useState([]);
const [importFile, setImportFile] = useState(null); const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const importFormRef = useRef(null); const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
const [stockModalVisible, setStockModalVisible] = useState(false); const [stockModalVisible, setStockModalVisible] = useState(false);
const [stockRecord, setStockRecord] = useState(null); const [stockRecord, setStockRecord] = useState(null);
const [stockType, setStockType] = useState('in'); const [stockType, setStockType] = useState('in');
@@ -218,6 +220,9 @@ function ConsumableManagement() {
setImportModalVisible(false); setImportModalVisible(false);
setImportPreview([]); setImportPreview([]);
setImportFile(null); setImportFile(null);
setImportProgress(0);
setImportPhase('');
setImportResult(null);
}; };
const handleImport = async () => { const handleImport = async () => {
@@ -227,30 +232,119 @@ function ConsumableManagement() {
} }
setImporting(true); setImporting(true);
setImportProgress(0);
setImportPhase('正在读取文件...');
setImportResult(null);
try { try {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async (e) => {
const text = e.target.result; const text = e.target.result;
setImportProgress(10);
setImportPhase('正在读取文件...');
setTimeout(() => {
setImportProgress(20);
setImportPhase('正在解析CSV数据...');
}, 100);
const items = parseCSV(text); const items = parseCSV(text);
const totalItems = items.length;
setTimeout(() => {
setImportProgress(30);
setImportPhase(`共解析 ${totalItems} 条记录,准备提交...`);
}, 200);
setTimeout(() => {
setImportProgress(40);
setImportPhase('正在连接服务器...');
}, 300);
const response = await axios.post('/api/consumables/import', { items }); const response = await axios.post('/api/consumables/import', { items });
message.success(response.data.message);
if (response.data.results.failed > 0) { setTimeout(() => {
response.data.results.errors.forEach(err => console.error(err)); setImportProgress(60);
} setImportPhase('正在处理服务器响应...');
}, 100);
setImportModalVisible(false); setTimeout(() => {
setImportPreview([]); setImportProgress(80);
setImportFile(null); setImportPhase('正在更新本地数据...');
fetchConsumables(); }, 200);
const results = response.data.results;
setTimeout(() => {
setImportResult(results);
setImportProgress(100);
setImportPhase('导入完成');
setImporting(false);
if (results.failed > 0) {
message.warning(`导入完成,成功 ${results.success} 条,失败 ${results.failed}`);
} else {
message.success(response.data.message || `成功导入 ${results.success} 条记录`);
}
}, 300);
};
reader.onerror = () => {
setImporting(false); setImporting(false);
setImportProgress(0);
setImportPhase('文件读取失败');
setImportResult({
success: false,
total: 0,
imported: 0,
failed: 0,
errors: [{ row: '-', error: '文件读取失败,请检查文件是否损坏' }],
message: '文件读取失败'
});
message.error('文件读取失败');
}; };
reader.readAsText(importFile); reader.readAsText(importFile);
} catch (error) { } catch (error) {
message.error('导入失败');
console.error('导入失败:', error);
setImporting(false); setImporting(false);
setImportProgress(0);
setImportPhase('导入失败');
let errorMessage = '导入失败,请检查网络连接或服务器状态';
let errorDetails = [];
if (error.response && error.response.data) {
const { data } = error.response;
if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
errorDetails = data.errors.map((err, index) => ({
row: err.row || index + 1,
error: err.error || err.message || '数据格式错误'
}));
errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`;
} else if (data.message) {
errorMessage = data.message;
} else if (data.error) {
errorMessage = data.error;
}
} else if (error.message) {
if (error.message.includes('Network Error') || error.message.includes('network')) {
errorMessage = '网络连接失败,请检查服务器是否运行';
} else if (error.message.includes('timeout')) {
errorMessage = '请求超时,请稍后重试';
} else {
errorMessage = error.message;
}
}
setImportResult({
success: false,
total: 0,
imported: 0,
failed: 0,
errors: errorDetails,
message: errorMessage
});
message.error(errorMessage);
console.error('导入耗材失败:', error);
} }
}; };
+86 -25
View File
@@ -164,9 +164,10 @@ function DeviceManagement() {
// 设备字段配置 // 设备字段配置
const [deviceFields, setDeviceFields] = useState([]); const [deviceFields, setDeviceFields] = useState([]);
const [loadingFields, setLoadingFields] = useState(true); const [loadingFields, setLoadingFields] = useState(true);
// 导入导出状态 // 导入状态
const [importModalVisible, setImportModalVisible] = useState(false); const [importModalVisible, setImportModalVisible] = useState(false);
const [importProgress, setImportProgress] = useState(0); const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState(null); const [importResult, setImportResult] = useState(null);
const [selectedDevices, setSelectedDevices] = useState([]); const [selectedDevices, setSelectedDevices] = useState([]);
@@ -522,34 +523,41 @@ function DeviceManagement() {
try { try {
setIsImporting(true); setIsImporting(true);
setImportProgress(0); setImportProgress(0);
setImportPhase('正在上传文件...');
setImportResult(null); setImportResult(null);
const formData = new FormData(); const formData = new FormData();
formData.append('csvFile', file); formData.append('csvFile', file);
// 模拟进度
const progressInterval = setInterval(() => {
setImportProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
return prev;
}
return prev + 10;
});
}, 500);
const response = await axios.post('/api/devices/import', formData, { const response = await axios.post('/api/devices/import', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
const progress = Math.round((progressEvent.loaded * 50) / progressEvent.total);
setImportProgress(Math.min(progress, 50));
setImportPhase('正在上传文件...');
} }
}); });
clearInterval(progressInterval); setImportProgress(60);
setImportProgress(100); setImportPhase('正在处理数据...');
setTimeout(() => {
setImportProgress(80);
setImportPhase('正在验证数据...');
}, 200);
setTimeout(() => {
setImportProgress(90);
setImportPhase('正在保存数据...');
}, 400);
setImportResult(response.data); setImportResult(response.data);
setImportProgress(100);
setImportPhase('导入完成');
setIsImporting(false); setIsImporting(false);
// 根据导入结果显示不同的消息
const { success, failed } = response.data.statistics; const { success, failed } = response.data.statistics;
if (failed > 0) { if (failed > 0) {
message.warning(`导入完成,但有 ${failed} 条记录导入失败`); message.warning(`导入完成,但有 ${failed} 条记录导入失败`);
@@ -557,18 +565,47 @@ function DeviceManagement() {
message.success('所有记录导入成功'); message.success('所有记录导入成功');
} }
// 重新加载设备列表
setTimeout(() => { setTimeout(() => {
fetchDevices(); fetchDevices();
}, 1000); }, 1000);
} catch (error) { } catch (error) {
setIsImporting(false); setIsImporting(false);
message.error('导入失败'); setImportProgress(0);
let errorMessage = '导入失败';
let errorDetails = [];
if (error.response) {
const { data } = error.response;
if (data && data.errors && Array.isArray(data.errors)) {
errorDetails = data.errors.map((err, index) => ({
row: err.row || index + 1,
error: err.error || err.message || '未知错误'
}));
errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`;
} else if (data && data.message) {
errorMessage = data.message;
}
} else if (error.message) {
errorMessage = error.message;
}
setImportResult({
success: false,
statistics: {
total: 0,
success: 0,
failed: 0,
errors: errorDetails,
message: errorMessage
}
});
message.error(errorMessage);
console.error('导入设备失败:', error); console.error('导入设备失败:', error);
} }
// 阻止默认上传行为
return false; return false;
}; };
@@ -1188,11 +1225,12 @@ function DeviceManagement() {
onCancel={() => { onCancel={() => {
setImportModalVisible(false); setImportModalVisible(false);
setImportProgress(0); setImportProgress(0);
setImportPhase('');
setImportResult(null); setImportResult(null);
setIsImporting(false); setIsImporting(false);
}} }}
footer={null} footer={null}
width={600} width={650}
destroyOnHidden destroyOnHidden
styles={{ header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }, body: { padding: '24px' } }} styles={{ header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }, body: { padding: '24px' } }}
> >
@@ -1233,11 +1271,36 @@ function DeviceManagement() {
</Button> </Button>
</Upload> </Upload>
</div> </div>
) : ( ) : isImporting ? (
<div> <div>
<p style={{ marginBottom: '16px', color: '#666' }}>正在导入数据...</p> <div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<Progress percent={importProgress} status="active" style={{ marginBottom: '20px' }} strokeColor={{ '0%': '#667eea', '100%': '#764ba2' }} /> <div style={{
{importResult && importResult.statistics && ( width: '48px',
height: '48px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
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: '#667eea', fontSize: '14px' }}>{importPhase}</p>
</div>
</div>
<Progress
percent={importProgress}
status="active"
strokeColor={{ '0%': '#667eea', '100%': '#764ba2' }}
format={() => `${importProgress}%`}
/>
</div>
) : importResult && importResult.statistics && (
<div> <div>
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成</p> <p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', marginBottom: '16px' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', marginBottom: '16px' }}>
@@ -1299,8 +1362,6 @@ function DeviceManagement() {
</Button> </Button>
</div> </div>
)} )}
</div>
)}
</Modal> </Modal>
<Modal <Modal
+88 -51
View File
@@ -32,6 +32,7 @@ function RackManagement() {
// 导入模态框状态 // 导入模态框状态
const [importModalVisible, setImportModalVisible] = useState(false); const [importModalVisible, setImportModalVisible] = useState(false);
const [importProgress, setImportProgress] = useState(0); const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState(null); const [importResult, setImportResult] = useState(null);
@@ -148,35 +149,41 @@ function RackManagement() {
try { try {
setIsImporting(true); setIsImporting(true);
setImportProgress(0); setImportProgress(0);
setImportPhase('正在上传文件...');
setImportResult(null); setImportResult(null);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// 模拟进度
const progressInterval = setInterval(() => {
setImportProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
return prev;
}
return prev + 10;
});
}, 500);
// 发送文件到后端处理
const response = await axios.post('/api/racks/import', formData, { const response = await axios.post('/api/racks/import', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
const progress = Math.round((progressEvent.loaded * 50) / progressEvent.total);
setImportProgress(Math.min(progress, 50));
setImportPhase('正在上传文件...');
} }
}); });
clearInterval(progressInterval); setImportProgress(60);
setImportPhase('正在处理数据...');
setTimeout(() => {
setImportProgress(80);
setImportPhase('正在验证数据...');
}, 200);
setTimeout(() => {
setImportProgress(90);
setImportPhase('正在保存数据...');
}, 400);
setImportProgress(100); setImportProgress(100);
setImportPhase('导入完成');
setImportResult(response.data); setImportResult(response.data);
setIsImporting(false); setIsImporting(false);
// 根据导入结果显示不同的消息
if (response.data.success) { if (response.data.success) {
const { imported, duplicates, total } = response.data; const { imported, duplicates, total } = response.data;
if (duplicates > 0) { if (duplicates > 0) {
@@ -188,31 +195,44 @@ function RackManagement() {
message.error(response.data.message || '导入失败'); message.error(response.data.message || '导入失败');
} }
// 阻止自动上传
return false; return false;
} catch (error) { } catch (error) {
setIsImporting(false); setIsImporting(false);
setImportProgress(0);
let errorMessage = '机柜导入失败'; let errorMessage = '机柜导入失败';
let errorDetails = null; let errorDetails = [];
if (error.response) { if (error.response) {
// 服务器返回了错误响应 const { data } = error.response;
errorMessage = error.response.data.message || error.response.data.error || errorMessage; if (data && data.errors && Array.isArray(data.errors)) {
errorDetails = error.response.data.details; errorDetails = data.errors.map((err, index) => ({
} else if (error.request) { row: err.row || index + 1,
// 请求已发送但没有收到响应 error: err.error || err.message || '未知错误'
errorMessage = '网络错误,服务器没有响应'; }));
} else { errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`;
// 请求配置错误 } else if (data && data.message) {
errorMessage = `请求错误: ${error.message}`; errorMessage = data.message;
} else if (data && data.details && Array.isArray(data.details)) {
errorDetails = data.details.map((err, index) => ({
row: err.row || index + 1,
error: err.error || err.message || '未知错误'
}));
errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`;
}
} else if (error.message) {
errorMessage = error.message;
} }
setImportResult({ success: false, message: errorMessage, details: errorDetails }); setImportResult({
success: false,
message: errorMessage,
details: errorDetails
});
message.error(errorMessage); message.error(errorMessage);
console.error('机柜导入失败:', error); console.error('机柜导入失败:', error);
// 阻止自动上传
return false; return false;
} }
}, []); }, []);
@@ -512,11 +532,12 @@ function RackManagement() {
onCancel={() => { onCancel={() => {
setImportModalVisible(false); setImportModalVisible(false);
setImportProgress(0); setImportProgress(0);
setImportPhase('');
setImportResult(null); setImportResult(null);
setIsImporting(false); setIsImporting(false);
}} }}
footer={null} footer={null}
width={600} width={650}
destroyOnClose destroyOnClose
styles={{ styles={{
body: { padding: '24px' }, body: { padding: '24px' },
@@ -599,21 +620,39 @@ function RackManagement() {
</Button> </Button>
</Upload> </Upload>
</div> </div>
) : ( ) : isImporting ? (
<div style={{ textAlign: 'center', padding: '20px 0' }}> <div>
<p style={{ marginBottom: '20px', fontWeight: '500' }}> <div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
{isImporting ? '正在导入数据...' : '导入完成'} <div style={{
</p> width: '48px',
height: '48px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
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: '#667eea', fontSize: '14px' }}>{importPhase}</p>
</div>
</div>
<Progress <Progress
percent={importProgress} percent={importProgress}
status={isImporting ? "active" : "success"} status="active"
style={{ marginBottom: '24px' }}
strokeColor={{ strokeColor={{
'0%': '#667eea', '0%': '#667eea',
'100%': '#764ba2' '100%': '#764ba2'
}} }}
format={() => `${importProgress}%`}
/> />
{importResult && ( </div>
) : importResult && (
<div style={{ <div style={{
textAlign: 'left', textAlign: 'left',
background: '#f6f6f6', background: '#f6f6f6',
@@ -658,23 +697,21 @@ function RackManagement() {
</ul> </ul>
</div> </div>
)} )}
<Button
type="primary"
onClick={() => {
setImportModalVisible(false);
setImportProgress(0);
setImportResult(null);
setIsImporting(false);
fetchRacks();
}}
style={primaryButtonStyle}
>
确定
</Button>
</div> </div>
)} )}
<Button
type="primary"
onClick={() => {
setImportModalVisible(false);
setImportProgress(0);
setImportResult(null);
setIsImporting(false);
fetchRacks();
}}
style={primaryButtonStyle}
>
确定
</Button>
</div>
)}
</Modal> </Modal>
</div> </div>