feat(设备导入): 实现CSV导入预览功能并优化导入流程

This commit is contained in:
zhang1106
2026-03-19 16:15:20 +08:00
parent a620575d92
commit b98e8ad9af
5 changed files with 917 additions and 324 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-react-refresh": "^0.5.0",
"jest": "^30.2.0", "jest": "^29.7.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"supertest": "^7.1.4" "supertest": "^7.1.4"
+259
View File
@@ -28,6 +28,265 @@ const {
Device.belongsTo(Rack, { foreignKey: 'rackId' }); Device.belongsTo(Rack, { foreignKey: 'rackId' });
Rack.hasMany(Device, { foreignKey: 'rackId' }); Rack.hasMany(Device, { foreignKey: 'rackId' });
Rack.belongsTo(Room, { foreignKey: 'roomId' });
Room.hasMany(Rack, { foreignKey: 'roomId' });
const PREVIEW_COUNT = 20;
router.post('/import-preview', async (req, res) => {
try {
if (!req.files || !req.files.csvFile) {
return res.status(400).json({ error: '请上传CSV文件' });
}
const csvFile = req.files.csvFile;
const stats = { total: 0, valid: 0, invalid: 0, errors: [] };
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
fs.mkdirSync(path.join(__dirname, '../temp'), { recursive: true });
}
const filePath = path.join(__dirname, '../temp', `preview_${Date.now()}_${csvFile.name}`);
await csvFile.mv(filePath);
const results = [];
const stream = fs.createReadStream(filePath)
.pipe(iconv.decodeStream('gbk'))
.pipe(csv());
await new Promise((resolve, reject) => {
stream.on('data', (data) => results.push(data))
.on('end', resolve)
.on('error', reject);
});
fs.unlinkSync(filePath);
const [rooms, racks, deviceFields] = await Promise.all([
Room.findAll(),
Rack.findAll({ include: [{ model: Room }] }),
DeviceField.findAll({ order: [['order', 'ASC']] })
]);
const roomNameToIdMap = new Map(rooms.map(room => [room.name, room.roomId]));
const rackLocationMap = new Map(racks.map(rack => [`${rack.Room?.name || ''}_${rack.name}`, rack.rackId]));
const fieldMapping = {};
const fieldNameToDisplayName = {};
deviceFields.forEach(field => {
fieldMapping[field.displayName] = field;
fieldNameToDisplayName[field.fieldName] = field.displayName;
});
const extractFieldName = (fieldNameWithFormat) => {
const match = fieldNameWithFormat.match(/^(.+?)(\(必填\)|\(可选\)|\([a-zA-Z0-9\-\/]+\))$/);
return match ? match[1].trim() : fieldNameWithFormat;
};
const validTypes = ['server', 'switch', 'router', 'storage', 'other'];
const validStatuses = ['running', 'maintenance', 'offline', 'fault'];
const baseFieldNames = ['deviceId', 'name', 'type', 'model', 'serialNumber', 'rackId', 'position', 'height', 'powerConsumption', 'ipAddress', 'status', 'purchaseDate', 'warrantyExpiry', 'description'];
const previewData = [];
const allDeviceIds = new Set();
const allSerialNumbers = new Set();
stats.total = results.length;
for (let i = 0; i < results.length; i++) {
const row = results[i];
const rowNum = i + 2;
const rowErrors = [];
const parsedRow = { _rowNum: rowNum };
try {
const fieldValueMap = {};
Object.entries(row).forEach(([displayName, value]) => {
const originalFieldName = extractFieldName(displayName);
fieldValueMap[originalFieldName] = value;
fieldValueMap[displayName] = value;
});
const getFieldValue = (fieldName) => {
const displayName = fieldNameToDisplayName[fieldName];
return displayName ? fieldValueMap[displayName] : undefined;
};
const trulyRequiredFields = [];
deviceFields.forEach(field => {
if (field.fieldName === 'deviceId') return;
if (field.fieldName === 'rackId') {
if (field.required) {
trulyRequiredFields.push('所在机房名称', '所在机柜名称');
}
return;
}
if (field.required) trulyRequiredFields.push(field.displayName);
});
const missingFields = trulyRequiredFields.filter(fieldName => {
const value = fieldValueMap[fieldName];
return !value || (typeof value === 'string' && value.trim() === '');
});
if (missingFields.length > 0) {
rowErrors.push(`缺少必填字段:${missingFields.join('、')}`);
}
const deviceType = getFieldValue('type');
if (deviceType && !validTypes.includes(deviceType)) {
rowErrors.push(`设备类型无效:${deviceType}`);
}
let deviceId = getFieldValue('deviceId');
if (deviceId && deviceId.trim() !== '') {
if (allDeviceIds.has(deviceId)) {
rowErrors.push(`设备ID重复:${deviceId}`);
}
allDeviceIds.add(deviceId);
}
const serialNumber = getFieldValue('serialNumber');
if (!serialNumber || serialNumber.trim() === '') {
rowErrors.push('序列号不能为空');
} else if (allSerialNumbers.has(serialNumber)) {
rowErrors.push(`序列号重复:${serialNumber}`);
} else {
allSerialNumbers.add(serialNumber);
}
const roomName = fieldValueMap['所在机房名称'];
const rackName = fieldValueMap['所在机柜名称'];
if (!roomName?.trim()) {
rowErrors.push('所在机房名称不能为空');
} else if (!roomNameToIdMap.get(roomName.trim())) {
rowErrors.push(`机房不存在:${roomName}`);
}
if (!rackName?.trim()) {
rowErrors.push('所在机柜名称不能为空');
}
const status = getFieldValue('status');
if (status && !validStatuses.includes(status)) {
rowErrors.push(`状态值无效:${status}`);
}
const position = getFieldValue('position');
const height = getFieldValue('height');
const powerConsumption = getFieldValue('powerConsumption');
if (position !== undefined && position !== '' && isNaN(Number(position))) {
rowErrors.push(`位置必须是数字:${position}`);
}
if (height !== undefined && height !== '' && isNaN(Number(height))) {
rowErrors.push(`高度必须是数字:${height}`);
}
if (powerConsumption !== undefined && powerConsumption !== '' && isNaN(Number(powerConsumption))) {
rowErrors.push(`功率必须是数字:${powerConsumption}`);
}
const purchaseDateValue = getFieldValue('purchaseDate');
const warrantyExpiryValue = getFieldValue('warrantyExpiry');
const purchaseDate = purchaseDateValue ? new Date(purchaseDateValue) : null;
const warrantyExpiry = warrantyExpiryValue ? new Date(warrantyExpiryValue) : null;
if (purchaseDateValue && isNaN(purchaseDate.getTime())) {
rowErrors.push(`购买日期格式无效:${purchaseDateValue}`);
}
if (warrantyExpiryValue && isNaN(warrantyExpiry.getTime())) {
rowErrors.push(`保修日期格式无效:${warrantyExpiryValue}`);
}
if (purchaseDate && warrantyExpiry && warrantyExpiry <= purchaseDate) {
rowErrors.push('保修日期必须晚于购买日期');
}
Object.keys(row).forEach(displayName => {
const originalDisplayName = extractFieldName(displayName);
const fieldConfig = fieldMapping[originalDisplayName];
if (fieldConfig && !baseFieldNames.includes(fieldConfig.fieldName)) {
parsedRow[fieldConfig.fieldName] = row[displayName];
}
});
parsedRow.name = getFieldValue('name') || '';
parsedRow.type = deviceType || '';
parsedRow.model = getFieldValue('model') || '';
parsedRow.serialNumber = serialNumber || '';
parsedRow.roomName = roomName || '';
parsedRow.rackName = rackName || '';
parsedRow.status = status || '';
parsedRow.position = position || '';
parsedRow.height = height || '';
parsedRow.powerConsumption = powerConsumption || '';
parsedRow.ipAddress = getFieldValue('ipAddress') || '';
parsedRow.description = getFieldValue('description') || '';
if (rowErrors.length === 0) {
stats.valid++;
parsedRow._hasError = false;
} else {
stats.invalid++;
parsedRow._hasError = true;
parsedRow._errors = rowErrors;
stats.errors.push({ row: rowNum, errors: rowErrors });
}
if (previewData.length < PREVIEW_COUNT) {
previewData.push(parsedRow);
}
} catch (error) {
stats.invalid++;
const errorMsg = error.message || '未知错误';
stats.errors.push({ row: rowNum, errors: [errorMsg] });
if (previewData.length < PREVIEW_COUNT) {
previewData.push({
_rowNum: rowNum,
_hasError: true,
_errors: [errorMsg],
name: row['设备名称'] || '',
type: row['设备类型'] || '',
serialNumber: row['序列号'] || '',
roomName: row['所在机房名称'] || '',
rackName: row['所在机柜名称'] || '',
status: row['状态'] || ''
});
}
}
}
const fieldList = deviceFields
.filter(field => field.visible && field.fieldName !== 'deviceId')
.map(field => ({
fieldName: field.fieldName,
displayName: field.displayName,
fieldType: field.fieldType,
required: field.required
}));
res.json({
success: true,
data: {
preview: previewData,
total: stats.total,
previewCount: PREVIEW_COUNT,
statistics: {
total: stats.total,
valid: stats.valid,
invalid: stats.invalid
},
errors: stats.errors.slice(0, 50),
fieldList
}
});
} catch (error) {
console.error('预览设备数据失败:', error);
res.status(500).json({
error: error.message || '预览过程中发生未知错误'
});
}
});
router.get('/', validateQuery(queryDeviceSchema), async (req, res) => { router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
try { try {
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"checkJs": false,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+631 -316
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal, Upload, Button, Progress, message } from 'antd'; import { Modal, Upload, Button, Progress, message, Table, Alert, Space, Spin } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons'; import { UploadOutlined, DownloadOutlined, CheckCircleOutlined, WarningOutlined, FileTextOutlined } from '@ant-design/icons';
import axios from 'axios';
import { designTokens } from '../../config/theme'; import { designTokens } from '../../config/theme';
const modalHeaderStyle = { const modalHeaderStyle = {
@@ -17,19 +18,91 @@ const ImportModal = ({
onImport, onImport,
onCancel, onCancel,
}) => { }) => {
const [isImporting, setIsImporting] = useState(false); const [step, setStep] = useState('upload');
const [isPreviewing, setIsPreviewing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewData, setPreviewData] = useState(null);
const [selectedFile, setSelectedFile] = useState(null);
const [importProgress, setImportProgress] = useState(0); const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState(''); const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null); const [importResult, setImportResult] = useState(null);
const handleImport = async (file) => { const api = axios.create({
try { baseURL: '/api',
setIsImporting(true); });
setImportProgress(0);
setImportPhase('正在上传文件...');
setImportResult(null);
await onImport(file, { api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
const resetState = () => {
setStep('upload');
setIsPreviewing(false);
setIsConfirming(false);
setPreviewLoading(false);
setPreviewData(null);
setSelectedFile(null);
setImportProgress(0);
setImportPhase('');
setImportResult(null);
};
const handleClose = () => {
resetState();
onCancel();
};
const handlePreview = async (file) => {
const actualFile = file.originFileObj || file;
setSelectedFile(actualFile);
setPreviewLoading(true);
setIsPreviewing(true);
try {
const formData = new FormData();
formData.append('csvFile', actualFile);
const response = await api.post('/devices/import-preview', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.data.success) {
setPreviewData(response.data.data);
setStep('preview');
} else {
message.error(response.data.error || '预览失败');
resetState();
}
} catch (error) {
console.error('预览失败:', error);
message.error(error.response?.data?.error || '预览失败,请检查文件格式');
resetState();
} finally {
setPreviewLoading(false);
}
return false;
};
const handleConfirmImport = async () => {
if (!selectedFile) {
message.error('请先选择文件');
return;
}
setIsConfirming(true);
setImportProgress(0);
setImportPhase('正在上传文件...');
try {
await onImport(selectedFile, {
onProgress: (progress, phase) => { onProgress: (progress, phase) => {
setImportProgress(progress); setImportProgress(progress);
setImportPhase(phase); setImportPhase(phase);
@@ -38,7 +111,8 @@ const ImportModal = ({
setImportResult(result); setImportResult(result);
setImportProgress(100); setImportProgress(100);
setImportPhase('导入完成'); setImportPhase('导入完成');
setIsImporting(false); setIsConfirming(false);
setStep('result');
}, },
onError: (error) => { onError: (error) => {
setImportResult({ setImportResult({
@@ -50,41 +124,560 @@ const ImportModal = ({
errors: [{ row: 0, error: error.message || '导入失败' }], errors: [{ row: 0, error: error.message || '导入失败' }],
}, },
}); });
setIsImporting(false); setIsConfirming(false);
setStep('result');
}, },
}); });
} catch (error) { } catch (error) {
setIsImporting(false); setIsConfirming(false);
setImportProgress(0);
message.error('导入失败'); message.error('导入失败');
resetState();
} }
return false;
};
const handleClose = () => {
setImportProgress(0);
setImportPhase('');
setImportResult(null);
setIsImporting(false);
onCancel();
}; };
const requiredFields = deviceFields.filter((f) => f.visible && f.required); const requiredFields = deviceFields.filter((f) => f.visible && f.required);
const optionalFields = deviceFields.filter((f) => f.visible && !f.required); const optionalFields = deviceFields.filter((f) => f.visible && !f.required);
const previewColumns = previewData?.fieldList
? previewData.fieldList.map((field) => ({
title: field.displayName + (field.required ? ' *' : ''),
dataIndex: field.fieldName,
key: field.fieldName,
width: 120,
ellipsis: true,
}))
: [
{ title: '行号', dataIndex: '_rowNum', key: '_rowNum', width: 60 },
{ title: '设备名称', dataIndex: 'name', key: 'name', width: 120 },
{ title: '设备类型', dataIndex: 'type', key: 'type', width: 80 },
{ title: '品牌', dataIndex: 'model', key: 'model', width: 100 },
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber', width: 140 },
{ title: '所在机房', dataIndex: 'roomName', key: 'roomName', width: 100 },
{ title: '所在机柜', dataIndex: 'rackName', key: 'rackName', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
];
const getRowClassName = (record) => {
if (record._hasError) {
return 'ant-table-row-error';
}
return '';
};
const renderUploadStep = () => (
<div>
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p>
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>
支持的编码格式GBK
</p>
<div
style={{
marginBottom: '20px',
padding: '16px',
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
borderRadius: '12px',
border: '1px solid #f0f0f0',
}}
>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#333' }}>
CSV文件格式要求
</p>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{requiredFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#d93025', fontWeight: '500' }}>必填字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{requiredFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
{optionalFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#666', fontWeight: '500' }}>可选字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{optionalFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
<ul
style={{
paddingLeft: '20px',
marginBottom: '10px',
color: '#666',
fontSize: '13px',
marginTop: '12px',
}}
>
<li>
设备类型server(服务器)switch(交换机)router(路由器)storage(存储设备)other(其他)
</li>
<li>状态值running(运行中)maintenance(维护中)offline(离线)fault(故障)</li>
<li>日期格式YYYY-MM-DD (例如2023-01-01)</li>
</ul>
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<a href="/api/devices/import-template" download="设备导入模板.csv">
<Button
icon={<DownloadOutlined />}
style={{
height: '36px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
}}
>
下载导入模板
</Button>
</a>
<span style={{ color: '#999', fontSize: '12px', marginLeft: '10px' }}>
包含示例数据的CSV模板文件根据当前字段配置生成
</span>
</div>
<Upload
name="csvFile"
accept=".csv"
showUploadList={false}
beforeUpload={handlePreview}
maxCount={1}
>
<Button
type="primary"
icon={<UploadOutlined />}
block
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
选择CSV文件
</Button>
</Upload>
</div>
);
const renderPreviewStep = () => (
<div>
<div
style={{
marginBottom: '16px',
padding: '16px',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
borderRadius: '12px',
border: '1px solid #667eea40',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<FileTextOutlined style={{ fontSize: '24px', color: '#667eea' }} />
<div>
<div style={{ fontWeight: '600', color: '#333', fontSize: '15px' }}>
{selectedFile?.name || '已选择文件'}
</div>
<div style={{ color: '#666', fontSize: '13px', marginTop: '2px' }}>
{previewData?.total || 0} 条记录已解析 {previewData?.previewCount || 0} 条作为预览
</div>
</div>
</div>
{previewData?.statistics?.invalid > 0 ? (
<Alert
type="warning"
showIcon
icon={<WarningOutlined />}
message={`发现 ${previewData.statistics.invalid} 条数据存在错误`}
description="错误行已用红色标记,请核对后确认导入"
style={{ marginTop: '8px' }}
/>
) : (
<Alert
type="success"
showIcon
icon={<CheckCircleOutlined />}
message="数据验证通过"
description="所有数据格式正确,可以进行导入"
style={{ marginTop: '8px' }}
/>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<div
style={{
padding: '12px 20px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
minWidth: '100px',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{previewData?.statistics?.total || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
</div>
<div
style={{
padding: '12px 20px',
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
minWidth: '100px',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{previewData?.statistics?.valid || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>有效</div>
</div>
<div
style={{
padding: '12px 20px',
background: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
minWidth: '100px',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{previewData?.statistics?.invalid || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>无效</div>
</div>
</div>
</div>
<div
style={{
marginBottom: '16px',
maxHeight: '300px',
overflowY: 'auto',
border: '1px solid #f0f0f0',
borderRadius: '8px',
}}
>
<Table
columns={previewColumns}
dataSource={previewData?.preview || []}
rowKey="_rowNum"
rowClassName={getRowClassName}
size="small"
pagination={{
pageSize: 10,
showSizeChanger: false,
showTotal: (total) => `${total}`,
}}
scroll={{ x: 'max-content' }}
/>
</div>
{previewData?.errors?.length > 0 && (
<div
style={{
marginBottom: '16px',
maxHeight: '150px',
overflowY: 'auto',
border: '1px solid #ffcccc',
borderRadius: '8px',
padding: '12px',
backgroundColor: '#fff7f7',
}}
>
<h4 style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600', fontSize: '13px' }}>
错误详情
</h4>
{previewData.errors.slice(0, 10).map((err, index) => (
<div key={index} style={{ marginBottom: '8px', fontSize: '13px' }}>
<span style={{ fontWeight: 'bold', color: '#d93025' }}>{err.row}</span>
<span style={{ color: '#666' }}>{err.errors.join('')}</span>
</div>
))}
{previewData.errors.length > 10 && (
<div style={{ color: '#999', fontSize: '12px', textAlign: 'center' }}>
还有 {previewData.errors.length - 10} 条错误未显示...
</div>
)}
</div>
)}
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button
onClick={handleClose}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
}}
>
取消
</Button>
<Button
type="primary"
onClick={handleConfirmImport}
disabled={previewData?.statistics?.invalid > 0}
icon={<CheckCircleOutlined />}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: previewData?.statistics?.invalid > 0 ? '#ccc' : designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
fontWeight: '500',
}}
>
确认导入
</Button>
</Space>
</div>
);
const renderConfirmingStep = () => (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<div
style={{
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>
);
const renderResultStep = () => (
<div>
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
marginBottom: '16px',
}}
>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult?.statistics?.total || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult?.statistics?.success || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>成功</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult?.statistics?.failed || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>失败</div>
</div>
</div>
{importResult?.statistics?.errors?.length > 0 && (
<div
style={{
marginTop: '20px',
maxHeight: 400,
overflowY: 'auto',
border: '1px solid #ffcccc',
borderRadius: '8px',
padding: '12px',
backgroundColor: '#fff7f7',
}}
>
<h4 style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600' }}>
失败记录详情
</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ backgroundColor: '#ffeeee' }}>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
width: '80px',
}}
>
行号
</th>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
}}
>
失败原因
</th>
</tr>
</thead>
<tbody>
{importResult.statistics.errors.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #ffcccc' }}>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
fontWeight: 'bold',
}}
>
{item.row || index + 1}
</td>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
color: '#d93025',
}}
>
{item.error || '未知错误'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
type="primary"
onClick={handleClose}
style={{
marginTop: '20px',
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
确定
</Button>
</div>
);
const renderLoadingStep = () => (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
<div style={{ marginTop: '16px', color: '#666' }}>正在解析文件...</div>
</div>
);
const getStepTitle = () => {
switch (step) {
case 'upload':
return (
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<UploadOutlined style={{ color: '#667eea' }} />
导入设备
</div>
);
case 'preview':
return (
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<FileTextOutlined style={{ color: '#667eea' }} />
导入设备 - 数据预览
</div>
);
case 'confirming':
return (
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<UploadOutlined style={{ color: '#667eea' }} />
导入设备 - 导入中
</div>
);
case 'result':
return (
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
导入设备 - 结果
</div>
);
default:
return '导入设备';
}
};
return ( return (
<Modal <Modal
title={ title={getStepTitle()}
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<UploadOutlined style={{ color: '#667eea' }} />
导入设备
</div>
}
open={visible} open={visible}
onCancel={handleClose} onCancel={handleClose}
footer={null} footer={null}
width={650} width={step === 'preview' ? 900 : 650}
destroyOnHidden destroyOnHidden
styles={{ styles={{
header: { header: {
@@ -92,294 +685,16 @@ const ImportModal = ({
padding: '16px 24px', padding: '16px 24px',
position: 'relative', position: 'relative',
}, },
body: { padding: '24px' }, body: { padding: step === 'loading' ? '40px 24px' : '24px' },
}} }}
> >
{!isImporting && !importResult ? ( {step === 'upload' && renderUploadStep()}
<div> {step === 'loading' && renderLoadingStep()}
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p> {step === 'preview' && renderPreviewStep()}
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}> {step === 'confirming' && renderConfirmingStep()}
支持的编码格式GBK {step === 'result' && renderResultStep()}
</p>
<div
style={{
marginBottom: '20px',
padding: '16px',
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
borderRadius: '12px',
border: '1px solid #f0f0f0',
}}
>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#333' }}>
CSV文件格式要求
</p>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{requiredFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#d93025', fontWeight: '500' }}>必填字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{requiredFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
{optionalFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#666', fontWeight: '500' }}>可选字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{optionalFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
<ul
style={{
paddingLeft: '20px',
marginBottom: '10px',
color: '#666',
fontSize: '13px',
marginTop: '12px',
}}
>
<li>
设备类型server(服务器)switch(交换机)router(路由器)storage(存储设备)other(其他)
</li>
<li>状态值running(运行中)maintenance(维护中)offline(离线)fault(故障)</li>
<li>日期格式YYYY-MM-DD (例如2023-01-01)</li>
</ul>
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<a href="/api/devices/import-template" download="设备导入模板.csv">
<Button
icon={<DownloadOutlined />}
style={{
height: '36px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
}}
>
下载导入模板
</Button>
</a>
<span style={{ color: '#999', fontSize: '12px', marginLeft: '10px' }}>
包含示例数据的CSV模板文件根据当前字段配置生成
</span>
</div>
<Upload
name="csvFile"
accept=".csv"
showUploadList={false}
beforeUpload={handleImport}
maxCount={1}
>
<Button
type="primary"
icon={<UploadOutlined />}
block
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
选择CSV文件
</Button>
</Upload>
</div>
) : isImporting ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<div
style={{
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?.statistics ? (
<div>
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
marginBottom: '16px',
}}
>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.total || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.success || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>成功</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.failed || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>失败</div>
</div>
</div>
{importResult.statistics?.errors?.length > 0 && (
<div
style={{
marginTop: '20px',
maxHeight: 400,
overflowY: 'auto',
border: '1px solid #ffcccc',
borderRadius: '8px',
padding: '12px',
backgroundColor: '#fff7f7',
}}
>
<h4 style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600' }}>
失败记录详情
</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ backgroundColor: '#ffeeee' }}>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
width: '80px',
}}
>
行号
</th>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
}}
>
失败原因
</th>
</tr>
</thead>
<tbody>
{importResult.statistics.errors.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #ffcccc' }}>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
fontWeight: 'bold',
}}
>
{item.row || index + 1}
</td>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
color: '#d93025',
}}
>
{item.error || '未知错误'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
type="primary"
onClick={handleClose}
style={{
marginTop: '20px',
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
确定
</Button>
</div>
) : null}
</Modal> </Modal>
); );
}; };
export default React.memo(ImportModal); export default React.memo(ImportModal);
+7 -7
View File
@@ -73,6 +73,13 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: false, sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
@@ -85,13 +92,6 @@ export default defineConfig({
assetFileNames: '[ext]/[name]-[hash].[ext]', assetFileNames: '[ext]/[name]-[hash].[ext]',
compact: true compact: true
} }
},
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
} }
}, },
optimizeDeps: { optimizeDeps: {