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文件格式要求: +

+
+ {requiredFields.length > 0 && ( +
+ 必填字段: + + {requiredFields.map((f) => f.displayName).join('、')} + +
+ )} + {optionalFields.length > 0 && ( +
+ 可选字段: + + {optionalFields.map((f) => f.displayName).join('、')} + +
+ )} +
    +
  • + 设备类型:server(服务器)、switch(交换机)、router(路由器)、storage(存储设备)、other(其他) +
  • +
  • 状态值:running(运行中)、maintenance(维护中)、offline(离线)、fault(故障)
  • +
  • 日期格式:YYYY-MM-DD (例如:2023-01-01)
  • +
+
+
+ +
+ + + + + 包含示例数据的CSV模板文件(根据当前字段配置生成) + +
+ + + + +
+ ); + + const renderPreviewStep = () => ( +
+
+
+ +
+
+ {selectedFile?.name || '已选择文件'} +
+
+ 共 {previewData?.total || 0} 条记录,已解析 {previewData?.previewCount || 0} 条作为预览 +
+
+
+ + {previewData?.statistics?.invalid > 0 ? ( + } + message={`发现 ${previewData.statistics.invalid} 条数据存在错误`} + description="错误行已用红色标记,请核对后确认导入" + style={{ marginTop: '8px' }} + /> + ) : ( + } + message="数据验证通过" + description="所有数据格式正确,可以进行导入" + style={{ marginTop: '8px' }} + /> + )} +
+ +
+
+
+
+ {previewData?.statistics?.total || 0} +
+
总记录数
+
+
+
+ {previewData?.statistics?.valid || 0} +
+
有效
+
+
+
+ {previewData?.statistics?.invalid || 0} +
+
无效
+
+
+
+ +
+ `共 ${total} 条`, + }} + scroll={{ x: 'max-content' }} + /> + + + {previewData?.errors?.length > 0 && ( +
+

+ 错误详情: +

+ {previewData.errors.slice(0, 10).map((err, index) => ( +
+ 第{err.row}行: + {err.errors.join(',')} +
+ ))} + {previewData.errors.length > 10 && ( +
+ 还有 {previewData.errors.length - 10} 条错误未显示... +
+ )} +
+ )} + + + + + + + ); + + const renderConfirmingStep = () => ( +
+
+
+ +
+
+

+ 正在导入设备数据 +

+

{importPhase}

+
+
+ `${importProgress}%`} + /> +
+ ); + + const renderResultStep = () => ( +
+

导入完成:

+
+
+
+ {importResult?.statistics?.total || 0} +
+
总记录数
+
+
+
+ {importResult?.statistics?.success || 0} +
+
成功
+
+
+
+ {importResult?.statistics?.failed || 0} +
+
失败
+
+
+ + {importResult?.statistics?.errors?.length > 0 && ( +
+

+ 失败记录详情: +

+
+ + + + + + + + {importResult.statistics.errors.map((item, index) => ( + + + + + ))} + +
+ 行号 + + 失败原因 +
+ {item.row || index + 1} + + {item.error || '未知错误'} +
+
+ )} + + +
+ ); + + const renderLoadingStep = () => ( +
+ +
正在解析文件...
+
+ ); + + const getStepTitle = () => { + switch (step) { + case 'upload': + return ( +
+ + 导入设备 +
+ ); + case 'preview': + return ( +
+ + 导入设备 - 数据预览 +
+ ); + case 'confirming': + return ( +
+ + 导入设备 - 导入中 +
+ ); + case 'result': + return ( +
+ + 导入设备 - 结果 +
+ ); + default: + return '导入设备'; + } + }; + return ( - - 导入设备 - - } + title={getStepTitle()} open={visible} onCancel={handleClose} footer={null} - width={650} + width={step === 'preview' ? 900 : 650} destroyOnHidden styles={{ header: { @@ -92,294 +685,16 @@ const ImportModal = ({ padding: '16px 24px', position: 'relative', }, - body: { padding: '24px' }, + body: { padding: step === 'loading' ? '40px 24px' : '24px' }, }} > - {!isImporting && !importResult ? ( -
-

请上传CSV格式的设备数据文件

-

- 支持的编码格式:GBK -

- -
-

- CSV文件格式要求: -

-
- {requiredFields.length > 0 && ( -
- 必填字段: - - {requiredFields.map((f) => f.displayName).join('、')} - -
- )} - {optionalFields.length > 0 && ( -
- 可选字段: - - {optionalFields.map((f) => f.displayName).join('、')} - -
- )} -
    -
  • - 设备类型:server(服务器)、switch(交换机)、router(路由器)、storage(存储设备)、other(其他) -
  • -
  • 状态值:running(运行中)、maintenance(维护中)、offline(离线)、fault(故障)
  • -
  • 日期格式:YYYY-MM-DD (例如:2023-01-01)
  • -
-
-
- -
- - - - - 包含示例数据的CSV模板文件(根据当前字段配置生成) - -
- - - - -
- ) : isImporting ? ( -
-
-
- -
-
-

- 正在导入设备数据 -

-

{importPhase}

-
-
- `${importProgress}%`} - /> -
- ) : importResult?.statistics ? ( -
-

导入完成:

-
-
-
- {importResult.statistics.total || 0} -
-
总记录数
-
-
-
- {importResult.statistics.success || 0} -
-
成功
-
-
-
- {importResult.statistics.failed || 0} -
-
失败
-
-
- - {importResult.statistics?.errors?.length > 0 && ( -
-

- 失败记录详情: -

- - - - - - - - - {importResult.statistics.errors.map((item, index) => ( - - - - - ))} - -
- 行号 - - 失败原因 -
- {item.row || index + 1} - - {item.error || '未知错误'} -
-
- )} - - -
- ) : null} + {step === 'upload' && renderUploadStep()} + {step === 'loading' && renderLoadingStep()} + {step === 'preview' && renderPreviewStep()} + {step === 'confirming' && renderConfirmingStep()} + {step === 'result' && renderResultStep()}
); }; -export default React.memo(ImportModal); +export default React.memo(ImportModal); \ No newline at end of file diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index 3741294..b1a2744 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -73,6 +73,13 @@ export default defineConfig({ build: { outDir: 'dist', sourcemap: false, + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true + } + }, rollupOptions: { output: { manualChunks: { @@ -85,13 +92,6 @@ export default defineConfig({ assetFileNames: '[ext]/[name]-[hash].[ext]', compact: true } - }, - minify: 'terser', - terserOptions: { - compress: { - drop_console: true, - drop_debugger: true - } } }, optimizeDeps: {