feat(device): 增强导出功能,支持按筛选条件导出全部设备

- 导出接口从 GET 改为 POST,避免 URL 长度限制

- 新增 filters 参数支持按关键词、状态、类型、机房、机柜筛选导出

- 修复删除/编辑设备后分页跳回第一页的问题

- 导出模态框改为传递计数而非数组,支持显示总数

- 使用唯一文件名避免并发导出冲突

- 补充 idle 状态映射
This commit is contained in:
zhang1106
2026-06-12 09:39:06 +08:00
parent b075c23374
commit 170997ef61
5 changed files with 208 additions and 84 deletions
+95 -17
View File
@@ -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 { try {
const { deviceIds, format = 'csv' } = req.query; const { deviceIds, format = 'csv', filters } = req.body;
// 从数据库读取所有字段配置(不过滤 visible,以导出所有信息) // 从数据库读取所有字段配置(不过滤 visible,以导出所有信息)
const allFields = await DeviceField.findAll({ const allFields = await DeviceField.findAll({
@@ -1896,32 +1897,103 @@ router.get('/enhanced-export', async (req, res) => {
// 构建查询条件 // 构建查询条件
const where = {}; const where = {};
if (deviceIds) {
const ids = Array.isArray(deviceIds) ? deviceIds : [deviceIds]; // 按指定 deviceIds 导出(选择的行 / 当前页)
where.deviceId = { [Op.in]: ids }; 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;
const devices = await Device.findAll({ }
where,
include: [ 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, model: Rack,
include: [{ model: Room }], 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: includeConfig,
distinct: true,
}); });
if (devices.length === 0) { if (devices.length === 0) {
return res.status(404).json({ error: '未找到指定的设备' }); return res.status(404).json({ error: '未找到指定的设备' });
} }
// 状态和类型映射 // 状态和类型映射(补充 idle 状态)
const statusMap = { const statusMap = {
running: '运行中', running: '运行中',
maintenance: '维护中', maintenance: '维护中',
offline: '离线', offline: '离线',
fault: '故障', fault: '故障',
idle: '空闲',
}; };
const typeMap = { const typeMap = {
server: '服务器', server: '服务器',
@@ -2019,8 +2091,12 @@ router.get('/enhanced-export', async (req, res) => {
return res.status(400).json({ error: '没有可导出的字段' }); 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({ const csvWriter = createObjectCsvWriter({
path: path.join(__dirname, '../temp/enhanced_export.csv'), path: exportFilePath,
header: headers, header: headers,
encoding: 'utf8', encoding: 'utf8',
}); });
@@ -2031,17 +2107,19 @@ router.get('/enhanced-export', async (req, res) => {
await csvWriter.writeRecords(exportData); await csvWriter.writeRecords(exportData);
const csvContent = fs.readFileSync( const csvContent = fs.readFileSync(exportFilePath, 'utf8');
path.join(__dirname, '../temp/enhanced_export.csv'),
'utf8'
);
const gbkContent = iconv.encode(csvContent, 'gbk'); const gbkContent = iconv.encode(csvContent, 'gbk');
res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=devices.csv'); res.setHeader('Content-Disposition', 'attachment; filename=devices.csv');
res.send(gbkContent); res.send(gbkContent);
fs.unlinkSync(path.join(__dirname, '../temp/enhanced_export.csv')); // 清理临时文件
try {
fs.unlinkSync(exportFilePath);
} catch (cleanupErr) {
logger.warn('清理导出临时文件失败', { error: cleanupErr.message });
}
} else { } else {
// JSON 导出 // JSON 导出
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
+25 -16
View File
@@ -786,30 +786,39 @@ paths:
description: 导入成功 description: 导入成功
/api/devices/enhanced-export: /api/devices/enhanced-export:
get: post:
summary: 增强导出设备数据 summary: 增强导出设备数据(支持按筛选条件导出全部设备)
tags: [devices] tags: [devices]
parameters: requestBody:
- name: deviceIds required: true
in: query content:
application/json:
schema: schema:
type: object
properties:
deviceIds:
type: array
items:
type: string type: string
- name: format description: 指定导出的设备ID列表(与filters二选一)
in: query format:
schema:
type: string type: string
enum: [csv, json] enum: [csv, json]
default: csv default: csv
- name: fields filters:
in: query type: object
schema: description: 筛选条件(导出全部设备时使用)
properties:
keyword:
type: string type: string
description: JSON格式的字段列表 status:
- name: fieldLabels type: string
in: query type:
schema: type: string
roomId:
type: string
rackId:
type: string type: string
description: JSON格式的字段标签映射
responses: responses:
'200': '200':
description: 导出成功 description: 导出成功
+11 -7
View File
@@ -646,17 +646,21 @@ POST /api/devices/import
### 增强导出设备数据 ### 增强导出设备数据
```http ```http
GET /api/devices/enhanced-export POST /api/devices/enhanced-export
``` ```
**查询参数:** **请求体参数:**
| 参数名 | 类型 | 描述 | | 参数名 | 类型 | 描述 |
|--------|------|------| |--------|------|------|
| deviceIds | string/array | 指定导出的设备ID | | deviceIds | array | 指定导出的设备ID列表(与filters二选一) |
| format | string | 导出格式(csv/json | | format | string | 导出格式(csv/json,默认csv |
| fields | string | JSON格式的字段列表 | | filters | object | 筛选条件(导出全部设备时使用) |
| fieldLabels | string | JSON格式的字段标签映射 | | 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/import-template | GET | 获取设备导入模板 |
| /api/devices/export | GET | 导出设备数据 | | /api/devices/export | GET | 导出设备数据 |
| /api/devices/import | POST | 导入设备数据 | | /api/devices/import | POST | 导入设备数据 |
| /api/devices/enhanced-export | GET | 增强导出设备数据 | | /api/devices/enhanced-export | POST | 增强导出设备数据 |
| /api/devices/:deviceId/tickets | GET | 获取设备的工单列表 | | /api/devices/:deviceId/tickets | GET | 获取设备的工单列表 |
| /api/deviceFields | GET/POST | 设备字段列表/创建 | | /api/deviceFields | GET/POST | 设备字段列表/创建 |
| /api/deviceFields/:id | PUT/DELETE | 设备字段更新/删除 | | /api/deviceFields/:id | PUT/DELETE | 设备字段更新/删除 |
+22 -9
View File
@@ -13,16 +13,27 @@ const modalHeaderStyle = {
fontWeight: 600, fontWeight: 600,
}; };
/**
* 设备导出模态框
* @param {boolean} visible - 是否显示
* @param {number} selectedCount - 已选择的设备数量
* @param {number} currentPageCount - 当前页设备数量
* @param {number} totalCount - 符合筛选条件的设备总数
* @param {Function} onExport - 导出回调
* @param {Function} onCancel - 取消回调
*/
const ExportModal = ({ const ExportModal = ({
visible, visible,
selectedDevices, selectedCount,
currentPageDevices, currentPageCount,
allDevices, totalCount,
onExport, onExport,
onCancel, onCancel,
}) => { }) => {
const hasSelection = selectedCount > 0;
const [exportFormat, setExportFormat] = useState('csv'); const [exportFormat, setExportFormat] = useState('csv');
const [exportScope, setExportScope] = useState('selected'); // 有选中设备时默认导出选中行,否则默认导出全部
const [exportScope, setExportScope] = useState(hasSelection ? 'selected' : 'all');
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
const handleExport = async () => { const handleExport = async () => {
@@ -41,11 +52,11 @@ const ExportModal = ({
const getScopeLabel = () => { const getScopeLabel = () => {
switch (exportScope) { switch (exportScope) {
case 'selected': case 'selected':
return `选择的行 (${selectedDevices.length} 个)`; return `选择的行 (${selectedCount} 个)`;
case 'currentPage': case 'currentPage':
return `当前页 (${currentPageDevices.length} 个)`; return `当前页 (${currentPageCount} 个)`;
case 'all': case 'all':
return `全部设备 (${allDevices.length} 个)`; return `全部设备 (${totalCount} 个)`;
default: default:
return ''; return '';
} }
@@ -114,9 +125,11 @@ const ExportModal = ({
</Form.Item> </Form.Item>
<Form.Item label="导出范围"> <Form.Item label="导出范围">
<Select value={exportScope} onChange={setExportScope} style={{ width: '100%' }}> <Select value={exportScope} onChange={setExportScope} style={{ width: '100%' }}>
{hasSelection && (
<Option value="selected">{getScopeLabel()}</Option> <Option value="selected">{getScopeLabel()}</Option>
<Option value="currentPage">当前页 ({currentPageDevices.length} )</Option> )}
<Option value="all">全部设备 ({allDevices.length} )</Option> <Option value="currentPage">当前页 ({currentPageCount} )</Option>
<Option value="all">全部设备 ({totalCount} )</Option>
</Select> </Select>
</Form.Item> </Form.Item>
<div style={{ color: '#666', fontSize: '13px' }}> <div style={{ color: '#666', fontSize: '13px' }}>
+45 -25
View File
@@ -145,6 +145,14 @@ function DeviceManagement() {
const { devices: deviceList, total } = response.data; const { devices: deviceList, total } = response.data;
const processedDevices = deviceList.map(processDeviceData); const processedDevices = deviceList.map(processDeviceData);
// 当前页无数据且不是第 1 页时,回退到前一页
if (processedDevices.length === 0 && page > 1 && total > 0) {
const prevPage = Math.ceil(total / pageSize) || 1;
fetchDevices(prevPage, pageSize);
return;
}
setAllDevices(processedDevices); setAllDevices(processedDevices);
setPagination(prev => ({ ...prev, current: page, pageSize, total })); setPagination(prev => ({ ...prev, current: page, pageSize, total }));
} catch (error) { } catch (error) {
@@ -266,7 +274,7 @@ function DeviceManagement() {
} }
setModalVisible(false); setModalVisible(false);
fetchDevices(1, pagination.pageSize); fetchDevices(pagination.current, pagination.pageSize);
setEditingDevice(null); setEditingDevice(null);
} catch (error) { } catch (error) {
const errorMsg = error.response?.data?.error || error.message || '未知错误'; const errorMsg = error.response?.data?.error || error.message || '未知错误';
@@ -349,7 +357,7 @@ function DeviceManagement() {
message.success(response.data.message || '成功删除所有设备'); message.success(response.data.message || '成功删除所有设备');
setSelectedDevices([]); setSelectedDevices([]);
setSelectAll(false); setSelectAll(false);
fetchDevices(1, 50); fetchDevices(1, pagination.pageSize);
} catch (error) { } catch (error) {
message.error('删除所有设备失败'); message.error('删除所有设备失败');
console.error('删除所有设备失败:', error); console.error('删除所有设备失败:', error);
@@ -397,7 +405,7 @@ function DeviceManagement() {
try { try {
await axios.delete(`/api/devices/${deviceId}`); await axios.delete(`/api/devices/${deviceId}`);
message.success('设备删除成功'); message.success('设备删除成功');
fetchDevices(1, pagination.pageSize); fetchDevices(pagination.current, pagination.pageSize);
} catch (error) { } catch (error) {
message.error('设备删除失败'); message.error('设备删除失败');
console.error('设备删除失败:', error); console.error('设备删除失败:', error);
@@ -453,33 +461,45 @@ function DeviceManagement() {
}; };
const showExportModal = () => { const showExportModal = () => {
if (selectedDevices.length === 0) {
message.warning('请先选择要导出的设备');
return;
}
setExportModalVisible(true); setExportModalVisible(true);
}; };
/**
* 处理设备数据导出
* @param {Object} options - 导出选项
* @param {string} options.format - 导出格式(csv/json
* @param {string} options.scope - 导出范围(selected/currentPage/all
*/
const handleEnhancedExport = async ({ format, scope }) => { const handleEnhancedExport = async ({ format, scope }) => {
let deviceIds = []; let requestBody = { format };
if (scope === 'selected') {
deviceIds = selectedDevices;
} else if (scope === 'currentPage') {
deviceIds = allDevices.map(device => device.deviceId);
} else if (scope === 'all') {
deviceIds = allDevices.map(device => device.deviceId);
}
if (deviceIds.length === 0) { if (scope === 'selected') {
// 导出选中的设备
if (selectedDevices.length === 0) {
message.warning('没有可导出的设备'); message.warning('没有可导出的设备');
return; return;
} }
requestBody.deviceIds = selectedDevices;
} else if (scope === 'currentPage') {
// 导出当前页设备
const currentPageIds = allDevices.map(device => device.deviceId);
if (currentPageIds.length === 0) {
message.warning('没有可导出的设备');
return;
}
requestBody.deviceIds = currentPageIds;
} else if (scope === 'all') {
// 按筛选条件导出全部设备(由后端查询,不受分页限制)
requestBody.filters = {
keyword: debouncedKeyword || undefined,
status: status !== 'all' ? status : undefined,
type: type !== 'all' ? type : undefined,
roomId: roomId !== 'all' ? roomId : undefined,
rackId: rackId !== 'all' ? rackId : undefined,
};
}
const params = new URLSearchParams(); const response = await axios.post('/api/devices/enhanced-export', requestBody, {
deviceIds.forEach(id => params.append('deviceIds', id));
params.append('format', format);
const response = await axios.get(`/api/devices/enhanced-export?${params.toString()}`, {
responseType: 'blob', responseType: 'blob',
}); });
@@ -494,7 +514,7 @@ function DeviceManagement() {
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
message.success(`成功导出 ${deviceIds.length} 个设备`); message.success('设备数据导出成功');
}; };
const handleImport = async (file, callbacks) => { const handleImport = async (file, callbacks) => {
@@ -1294,9 +1314,9 @@ function DeviceManagement() {
<ExportModal <ExportModal
visible={exportModalVisible} visible={exportModalVisible}
selectedDevices={selectedDevices} selectedCount={selectedDevices.length}
currentPageDevices={allDevices} currentPageCount={allDevices.length}
allDevices={allDevices} totalCount={pagination.total}
onExport={handleEnhancedExport} onExport={handleEnhancedExport}
onCancel={() => setExportModalVisible(false)} onCancel={() => setExportModalVisible(false)}
/> />