From 170997ef610b0c6f07697ec3d6e1186d0fa6d320 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Fri, 12 Jun 2026 09:39:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(device):=20=E5=A2=9E=E5=BC=BA=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=9D=A1=E4=BB=B6=E5=AF=BC=E5=87=BA=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E8=AE=BE=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导出接口从 GET 改为 POST,避免 URL 长度限制 - 新增 filters 参数支持按关键词、状态、类型、机房、机柜筛选导出 - 修复删除/编辑设备后分页跳回第一页的问题 - 导出模态框改为传递计数而非数组,支持显示总数 - 使用唯一文件名避免并发导出冲突 - 补充 idle 状态映射 --- backend/routes/devices.js | 116 +++++++++++++++--- backend/swagger_docs.yaml | 55 +++++---- docs/api/README.md | 18 +-- .../src/components/device/ExportModal.jsx | 33 +++-- frontend/src/pages/DeviceManagement.jsx | 70 +++++++---- 5 files changed, 208 insertions(+), 84 deletions(-) diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 3f003fc..6c6e544 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -1877,9 +1877,10 @@ router.put('/batch-move', async (req, res) => { }); // 增强导出设备数据(支持所有字段和自定义字段) -router.get('/enhanced-export', async (req, res) => { +// 改为 POST 请求,支持按筛选条件导出全部设备,避免 URL 长度限制 +router.post('/enhanced-export', async (req, res) => { try { - const { deviceIds, format = 'csv' } = req.query; + const { deviceIds, format = 'csv', filters } = req.body; // 从数据库读取所有字段配置(不过滤 visible,以导出所有信息) const allFields = await DeviceField.findAll({ @@ -1896,32 +1897,103 @@ router.get('/enhanced-export', async (req, res) => { // 构建查询条件 const where = {}; - if (deviceIds) { - const ids = Array.isArray(deviceIds) ? deviceIds : [deviceIds]; - where.deviceId = { [Op.in]: ids }; + + // 按指定 deviceIds 导出(选择的行 / 当前页) + if (deviceIds && Array.isArray(deviceIds) && deviceIds.length > 0) { + where.deviceId = { [Op.in]: deviceIds }; + } else if (filters) { + // 按筛选条件导出全部设备(复用列表查询的筛选逻辑) + const { keyword, status, type, rackId, roomId } = filters; + + if (keyword) { + const escapedKeyword = keyword + .replace(/\\/g, '\\\\') + .replace(/'/g, "''") + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); + + const searchConditions = [ + { deviceId: { [Op.like]: `%${escapedKeyword}%` } }, + { name: { [Op.like]: `%${escapedKeyword}%` } }, + { type: { [Op.like]: `%${escapedKeyword}%` } }, + { model: { [Op.like]: `%${escapedKeyword}%` } }, + { serialNumber: { [Op.like]: `%${escapedKeyword}%` } }, + { ipAddress: { [Op.like]: `%${escapedKeyword}%` } }, + { description: { [Op.like]: `%${escapedKeyword}%` } }, + ]; + + // 动态获取文本类型的自定义字段 + const customFields = await DeviceField.findAll({ + where: { + isSystem: false, + fieldType: { [Op.in]: ['string', 'textarea'] }, + }, + }); + + if (customFields.length > 0) { + const jsonConditions = customFields.map(field => { + const safeFieldName = field.fieldName + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/'/g, "''"); + + if (dbDialect === 'mysql') { + return sequelize.literal( + `JSON_UNQUOTE(JSON_EXTRACT(customFields, '$."${safeFieldName}"')) LIKE '%${escapedKeyword}%' ESCAPE '\\\\'` + ); + } else { + return sequelize.literal( + `CAST(json_extract(customFields, '$.${safeFieldName}') AS TEXT) LIKE '%${escapedKeyword}%' ESCAPE '\\'` + ); + } + }); + searchConditions.push(...jsonConditions); + } + + where[Op.or] = searchConditions; + } + + if (status && status !== 'all') { + where.status = status; + } + if (type && type !== 'all') { + where.type = type; + } + if (rackId && rackId !== 'all') { + where.rackId = rackId; + } + } + + // 查询设备数据(不分页,导出全部匹配结果) + const includeConfig = [ + { + model: Rack, + include: [{ model: Room }], + }, + ]; + + // 如果按筛选条件导出且有机房筛选,添加 Rack 的 where 条件 + if (filters && filters.roomId && filters.roomId !== 'all') { + includeConfig[0].where = { roomId: filters.roomId }; } - // 查询设备数据 const devices = await Device.findAll({ where, - include: [ - { - model: Rack, - include: [{ model: Room }], - }, - ], + include: includeConfig, + distinct: true, }); if (devices.length === 0) { return res.status(404).json({ error: '未找到指定的设备' }); } - // 状态和类型映射 + // 状态和类型映射(补充 idle 状态) const statusMap = { running: '运行中', maintenance: '维护中', offline: '离线', fault: '故障', + idle: '空闲', }; const typeMap = { server: '服务器', @@ -2019,8 +2091,12 @@ router.get('/enhanced-export', async (req, res) => { return res.status(400).json({ error: '没有可导出的字段' }); } + // 使用唯一文件名避免并发导出冲突 + const exportFileName = `enhanced_export_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.csv`; + const exportFilePath = path.join(__dirname, '../temp', exportFileName); + const csvWriter = createObjectCsvWriter({ - path: path.join(__dirname, '../temp/enhanced_export.csv'), + path: exportFilePath, header: headers, encoding: 'utf8', }); @@ -2031,17 +2107,19 @@ router.get('/enhanced-export', async (req, res) => { await csvWriter.writeRecords(exportData); - const csvContent = fs.readFileSync( - path.join(__dirname, '../temp/enhanced_export.csv'), - 'utf8' - ); + const csvContent = fs.readFileSync(exportFilePath, 'utf8'); const gbkContent = iconv.encode(csvContent, 'gbk'); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', 'attachment; filename=devices.csv'); res.send(gbkContent); - fs.unlinkSync(path.join(__dirname, '../temp/enhanced_export.csv')); + // 清理临时文件 + try { + fs.unlinkSync(exportFilePath); + } catch (cleanupErr) { + logger.warn('清理导出临时文件失败', { error: cleanupErr.message }); + } } else { // JSON 导出 res.setHeader('Content-Type', 'application/json'); diff --git a/backend/swagger_docs.yaml b/backend/swagger_docs.yaml index d427552..19379b5 100644 --- a/backend/swagger_docs.yaml +++ b/backend/swagger_docs.yaml @@ -786,30 +786,39 @@ paths: description: 导入成功 /api/devices/enhanced-export: - get: - summary: 增强导出设备数据 + post: + summary: 增强导出设备数据(支持按筛选条件导出全部设备) tags: [devices] - parameters: - - name: deviceIds - in: query - schema: - type: string - - name: format - in: query - schema: - type: string - enum: [csv, json] - default: csv - - name: fields - in: query - schema: - type: string - description: JSON格式的字段列表 - - name: fieldLabels - in: query - schema: - type: string - description: JSON格式的字段标签映射 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + deviceIds: + type: array + items: + type: string + description: 指定导出的设备ID列表(与filters二选一) + format: + type: string + enum: [csv, json] + default: csv + filters: + type: object + description: 筛选条件(导出全部设备时使用) + properties: + keyword: + type: string + status: + type: string + type: + type: string + roomId: + type: string + rackId: + type: string responses: '200': description: 导出成功 diff --git a/docs/api/README.md b/docs/api/README.md index 2e9043b..92ae712 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -646,17 +646,21 @@ POST /api/devices/import ### 增强导出设备数据 ```http -GET /api/devices/enhanced-export +POST /api/devices/enhanced-export ``` -**查询参数:** +**请求体参数:** | 参数名 | 类型 | 描述 | |--------|------|------| -| deviceIds | string/array | 指定导出的设备ID | -| format | string | 导出格式(csv/json) | -| fields | string | JSON格式的字段列表 | -| fieldLabels | string | JSON格式的字段标签映射 | +| deviceIds | array | 指定导出的设备ID列表(与filters二选一) | +| format | string | 导出格式(csv/json),默认csv | +| filters | object | 筛选条件(导出全部设备时使用) | +| filters.keyword | string | 搜索关键词 | +| filters.status | string | 设备状态筛选 | +| filters.type | string | 设备类型筛选 | +| filters.roomId | string | 机房ID筛选 | +| filters.rackId | string | 机柜ID筛选 | ### 获取设备的工单列表 @@ -2239,7 +2243,7 @@ GET /health | /api/devices/import-template | GET | 获取设备导入模板 | | /api/devices/export | GET | 导出设备数据 | | /api/devices/import | POST | 导入设备数据 | -| /api/devices/enhanced-export | GET | 增强导出设备数据 | +| /api/devices/enhanced-export | POST | 增强导出设备数据 | | /api/devices/:deviceId/tickets | GET | 获取设备的工单列表 | | /api/deviceFields | GET/POST | 设备字段列表/创建 | | /api/deviceFields/:id | PUT/DELETE | 设备字段更新/删除 | diff --git a/frontend/src/components/device/ExportModal.jsx b/frontend/src/components/device/ExportModal.jsx index 9a913bf..053582d 100644 --- a/frontend/src/components/device/ExportModal.jsx +++ b/frontend/src/components/device/ExportModal.jsx @@ -13,16 +13,27 @@ const modalHeaderStyle = { fontWeight: 600, }; +/** + * 设备导出模态框 + * @param {boolean} visible - 是否显示 + * @param {number} selectedCount - 已选择的设备数量 + * @param {number} currentPageCount - 当前页设备数量 + * @param {number} totalCount - 符合筛选条件的设备总数 + * @param {Function} onExport - 导出回调 + * @param {Function} onCancel - 取消回调 + */ const ExportModal = ({ visible, - selectedDevices, - currentPageDevices, - allDevices, + selectedCount, + currentPageCount, + totalCount, onExport, onCancel, }) => { + const hasSelection = selectedCount > 0; const [exportFormat, setExportFormat] = useState('csv'); - const [exportScope, setExportScope] = useState('selected'); + // 有选中设备时默认导出选中行,否则默认导出全部 + const [exportScope, setExportScope] = useState(hasSelection ? 'selected' : 'all'); const [exportLoading, setExportLoading] = useState(false); const handleExport = async () => { @@ -41,11 +52,11 @@ const ExportModal = ({ const getScopeLabel = () => { switch (exportScope) { case 'selected': - return `选择的行 (${selectedDevices.length} 个)`; + return `选择的行 (${selectedCount} 个)`; case 'currentPage': - return `当前页 (${currentPageDevices.length} 个)`; + return `当前页 (${currentPageCount} 个)`; case 'all': - return `全部设备 (${allDevices.length} 个)`; + return `全部设备 (${totalCount} 个)`; default: return ''; } @@ -114,9 +125,11 @@ const ExportModal = ({