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-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"jest": "^30.2.0",
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"prettier": "^3.8.1",
"supertest": "^7.1.4"
+259
View File
@@ -28,6 +28,265 @@ const {
Device.belongsTo(Rack, { 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) => {
try {