feat(设备导入): 实现CSV导入预览功能并优化导入流程
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
baseURL: '/api',
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
setIsImporting(true);
|
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);
|
setImportProgress(0);
|
||||||
setImportPhase('正在上传文件...');
|
setImportPhase('正在上传文件...');
|
||||||
setImportResult(null);
|
|
||||||
|
|
||||||
await onImport(file, {
|
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,52 +124,47 @@ 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);
|
||||||
|
|
||||||
return (
|
const previewColumns = previewData?.fieldList
|
||||||
<Modal
|
? previewData.fieldList.map((field) => ({
|
||||||
title={
|
title: field.displayName + (field.required ? ' *' : ''),
|
||||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
dataIndex: field.fieldName,
|
||||||
<UploadOutlined style={{ color: '#667eea' }} />
|
key: field.fieldName,
|
||||||
导入设备
|
width: 120,
|
||||||
</div>
|
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';
|
||||||
}
|
}
|
||||||
open={visible}
|
return '';
|
||||||
onCancel={handleClose}
|
};
|
||||||
footer={null}
|
|
||||||
width={650}
|
const renderUploadStep = () => (
|
||||||
destroyOnHidden
|
|
||||||
styles={{
|
|
||||||
header: {
|
|
||||||
borderBottom: '1px solid #f0f0f0',
|
|
||||||
padding: '16px 24px',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
body: { padding: '24px' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isImporting && !importResult ? (
|
|
||||||
<div>
|
<div>
|
||||||
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p>
|
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p>
|
||||||
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>
|
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>
|
||||||
@@ -171,7 +240,7 @@ const ImportModal = ({
|
|||||||
name="csvFile"
|
name="csvFile"
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
beforeUpload={handleImport}
|
beforeUpload={handlePreview}
|
||||||
maxCount={1}
|
maxCount={1}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -195,7 +264,186 @@ const ImportModal = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</div>
|
</div>
|
||||||
) : isImporting ? (
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
<div
|
<div
|
||||||
@@ -235,7 +483,9 @@ const ImportModal = ({
|
|||||||
format={() => `${importProgress}%`}
|
format={() => `${importProgress}%`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : importResult?.statistics ? (
|
);
|
||||||
|
|
||||||
|
const renderResultStep = () => (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成:</p>
|
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成:</p>
|
||||||
<div
|
<div
|
||||||
@@ -256,7 +506,7 @@ const ImportModal = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
||||||
{importResult.statistics.total || 0}
|
{importResult?.statistics?.total || 0}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
|
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +520,7 @@ const ImportModal = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
||||||
{importResult.statistics.success || 0}
|
{importResult?.statistics?.success || 0}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>成功</div>
|
<div style={{ fontSize: '12px', opacity: 0.9 }}>成功</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,13 +534,13 @@ const ImportModal = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
||||||
{importResult.statistics.failed || 0}
|
{importResult?.statistics?.failed || 0}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>失败</div>
|
<div style={{ fontSize: '12px', opacity: 0.9 }}>失败</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{importResult.statistics?.errors?.length > 0 && (
|
{importResult?.statistics?.errors?.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '20px',
|
marginTop: '20px',
|
||||||
@@ -377,7 +627,72 @@ const ImportModal = ({
|
|||||||
确定
|
确定
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
title={getStepTitle()}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
width={step === 'preview' ? 900 : 650}
|
||||||
|
destroyOnHidden
|
||||||
|
styles={{
|
||||||
|
header: {
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
padding: '16px 24px',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
body: { padding: step === 'loading' ? '40px 24px' : '24px' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === 'upload' && renderUploadStep()}
|
||||||
|
{step === 'loading' && renderLoadingStep()}
|
||||||
|
{step === 'preview' && renderPreviewStep()}
|
||||||
|
{step === 'confirming' && renderConfirmingStep()}
|
||||||
|
{step === 'result' && renderResultStep()}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user