diff --git a/backend/package.json b/backend/package.json index d3f2334..6b854dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 63b1d9e..ebf0d35 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -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 { diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..7bb2fa7 --- /dev/null +++ b/frontend/jsconfig.json @@ -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"] +} diff --git a/frontend/src/components/device/ImportModal.jsx b/frontend/src/components/device/ImportModal.jsx index 987f094..510b68e 100644 --- a/frontend/src/components/device/ImportModal.jsx +++ b/frontend/src/components/device/ImportModal.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; -import { Modal, Upload, Button, Progress, message } from 'antd'; -import { UploadOutlined, DownloadOutlined } from '@ant-design/icons'; +import { Modal, Upload, Button, Progress, message, Table, Alert, Space, Spin } from 'antd'; +import { UploadOutlined, DownloadOutlined, CheckCircleOutlined, WarningOutlined, FileTextOutlined } from '@ant-design/icons'; +import axios from 'axios'; import { designTokens } from '../../config/theme'; const modalHeaderStyle = { @@ -17,19 +18,91 @@ const ImportModal = ({ onImport, 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 [importPhase, setImportPhase] = useState(''); const [importResult, setImportResult] = useState(null); - const handleImport = async (file) => { - try { - setIsImporting(true); - setImportProgress(0); - setImportPhase('正在上传文件...'); - setImportResult(null); + const api = axios.create({ + baseURL: '/api', + }); - 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) => { setImportProgress(progress); setImportPhase(phase); @@ -38,7 +111,8 @@ const ImportModal = ({ setImportResult(result); setImportProgress(100); setImportPhase('导入完成'); - setIsImporting(false); + setIsConfirming(false); + setStep('result'); }, onError: (error) => { setImportResult({ @@ -50,41 +124,560 @@ const ImportModal = ({ errors: [{ row: 0, error: error.message || '导入失败' }], }, }); - setIsImporting(false); + setIsConfirming(false); + setStep('result'); }, }); } catch (error) { - setIsImporting(false); - setImportProgress(0); + setIsConfirming(false); 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 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 = () => ( +
请上传CSV格式的设备数据文件
++ 支持的编码格式:GBK +
+ ++ CSV文件格式要求: +
+| + 行号 + | ++ 失败原因 + | +
|---|---|
| + {item.row || index + 1} + | ++ {item.error || '未知错误'} + | +
请上传CSV格式的设备数据文件
-- 支持的编码格式:GBK -
- -- CSV文件格式要求: -
-- 正在导入设备数据 -
-{importPhase}
-导入完成:
-| - 行号 - | -- 失败原因 - | -
|---|---|
| - {item.row || index + 1} - | -- {item.error || '未知错误'} - | -