feat(工单管理): 添加工单导出功能

实现工单数据的导出功能,支持CSV、JSON和Excel格式。前端添加导出模态框和选择逻辑,后端实现数据处理和文件生成。用户可选择导出选中项、当前页或全部工单。

- 前端添加TicketExportModal组件处理导出选项
- 后端添加/export接口处理不同格式的导出请求
- 支持导出工单基础信息和自定义字段
- 添加导出按钮到工单管理页面
This commit is contained in:
zhang1106
2026-03-31 21:58:09 +08:00
parent 44137bb851
commit 773efdb9a4
3 changed files with 406 additions and 3 deletions
+159
View File
@@ -8,6 +8,10 @@ const User = require('../models/User');
const Rack = require('../models/Rack');
const Room = require('../models/Room');
const { dbDialect } = require('../db');
const { createObjectCsvWriter } = require('csv-writer');
const XLSX = require('xlsx');
const path = require('path');
const fs = require('fs');
// 获取工单统计 (必须定义在 /:ticketId 之前)
router.get('/stats', async (req, res) => {
@@ -650,4 +654,159 @@ router.post('/:ticketId/evaluate', async (req, res) => {
}
});
const TICKET_EXPORT_FIELDS = [
{ fieldName: 'ticketId', displayName: '工单编号' },
{ fieldName: 'title', displayName: '标题' },
{ fieldName: 'deviceName', displayName: '设备名称' },
{ fieldName: 'deviceModel', displayName: '设备型号' },
{ fieldName: 'serialNumber', displayName: '设备序列号' },
{ fieldName: 'faultCategory', displayName: '故障分类' },
{ fieldName: 'faultSubCategory', displayName: '故障子分类' },
{ fieldName: 'priority', displayName: '优先级' },
{ fieldName: 'status', displayName: '状态' },
{ fieldName: 'description', displayName: '故障描述' },
{ fieldName: 'expectedCompletionDate', displayName: '期望完成时间' },
{ fieldName: 'reporterId', displayName: '报告人ID' },
{ fieldName: 'reporterName', displayName: '报告人' },
{ fieldName: 'assigneeId', displayName: '处理人ID' },
{ fieldName: 'assigneeName', displayName: '处理人' },
{ fieldName: 'location', displayName: '设备位置' },
{ fieldName: 'resolution', displayName: '解决方案' },
{ fieldName: 'completionDate', displayName: '完成时间' },
{ fieldName: 'evaluation', displayName: '评价' },
{ fieldName: 'evaluationRating', displayName: '评价星级' },
{ fieldName: 'createdAt', displayName: '创建时间' },
{ fieldName: 'updatedAt', displayName: '更新时间' },
];
router.get('/export', async (req, res) => {
try {
const { keyword, status, priority, faultCategory, deviceId, format = 'csv', ticketIds } = req.query;
const where = {};
if (ticketIds) {
const ids = Array.isArray(ticketIds) ? ticketIds : [ticketIds];
where.ticketId = { [Op.in]: ids };
} else {
if (keyword) {
where[Op.or] = [
{ ticketId: { [Op.like]: `%${keyword}%` } },
{ title: { [Op.like]: `%${keyword}%` } },
{ deviceName: { [Op.like]: `%${keyword}%` } },
{ serialNumber: { [Op.like]: `%${keyword}%` } },
{ description: { [Op.like]: `%${keyword}%` } },
];
}
if (status && status !== 'all') {
where.status = status;
}
if (priority && priority !== 'all') {
where.priority = priority;
}
if (faultCategory && faultCategory !== 'all') {
where.faultCategory = faultCategory;
}
if (deviceId && deviceId !== 'all') {
where.deviceId = deviceId;
}
}
const tickets = await Ticket.findAll({
where,
include: [
{ model: User, as: 'reporter', attributes: ['userId', 'username'] },
{ model: Device, attributes: ['deviceId', 'name', 'type', 'model', 'serialNumber'] },
],
order: [['createdAt', 'DESC']],
});
const exportData = tickets.map(ticket => {
const item = {};
TICKET_EXPORT_FIELDS.forEach(({ fieldName, displayName }) => {
let value = ticket[fieldName];
if (fieldName === 'priority') {
const priorityMap = { low: '低', medium: '中', high: '高', urgent: '紧急' };
value = priorityMap[value] || value;
} else if (fieldName === 'status') {
const statusMap = { pending: '待处理', in_progress: '处理中', completed: '已完成', closed: '已关闭' };
value = statusMap[value] || value;
} else if (fieldName === 'expectedCompletionDate' || fieldName === 'completionDate' || fieldName === 'createdAt' || fieldName === 'updatedAt') {
value = value ? new Date(value).toLocaleString('zh-CN') : '';
}
item[displayName] = value !== null && value !== undefined ? String(value) : '';
});
if (ticket.metadata && typeof ticket.metadata === 'object') {
Object.entries(ticket.metadata).forEach(([key, val]) => {
const customDisplayName = key;
item[customDisplayName] = val !== null && val !== undefined ? String(val) : '';
});
}
return item;
});
if (format === 'json') {
return res.setHeader('Content-Type', 'application/json; charset=utf-8')
.setHeader('Content-Disposition', `attachment; filename=tickets_${Date.now()}.json`)
.json({ success: true, data: exportData, total: exportData.length });
}
if (format === 'xlsx') {
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, '工单数据');
const xlsxBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=tickets_${Date.now()}.xlsx`);
return res.send(xlsxBuffer);
}
const headers = [
...TICKET_EXPORT_FIELDS.map(f => ({ id: f.displayName, title: f.displayName })),
];
if (tickets.length > 0 && tickets[0].metadata && typeof tickets[0].metadata === 'object') {
Object.keys(tickets[0].metadata).forEach(key => {
headers.push({ id: key, title: key });
});
}
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
fs.mkdirSync(path.join(__dirname, '../temp'));
}
const tempFilePath = path.join(__dirname, `../temp/tickets_export_${Date.now()}.csv`);
const csvWriter = createObjectCsvWriter({
path: tempFilePath,
header: headers,
encoding: 'utf8',
});
await csvWriter.writeRecords(exportData);
const csvContent = fs.readFileSync(tempFilePath, 'utf8');
const bom = '\uFEFF';
const csvWithBom = bom + csvContent;
fs.unlinkSync(tempFilePath);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=tickets_${Date.now()}.csv`);
return res.send(csvWithBom);
} catch (error) {
console.error('导出工单失败:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { Modal, Form, Select, Button, Space, message } from 'antd';
import { ExportOutlined, FileTextOutlined, BranchesOutlined, TableOutlined } from '@ant-design/icons';
const { Option } = Select;
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const TicketExportModal = ({
visible,
onExport,
onCancel,
selectedCount = 0,
currentPageCount = 0,
totalCount = 0,
}) => {
const [exportFormat, setExportFormat] = useState('csv');
const [exportScope, setExportScope] = useState('selected');
const [exportLoading, setExportLoading] = useState(false);
const handleExport = async () => {
if (exportScope === 'selected' && selectedCount === 0) {
message.warning('请先选择要导出的工单');
return;
}
setExportLoading(true);
try {
await onExport({
format: exportFormat,
scope: exportScope,
});
onCancel();
} catch (error) {
message.error('导出失败');
} finally {
setExportLoading(false);
}
};
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<ExportOutlined style={{ color: '#fa8c16' }} />
导出工单数据
</div>
}
open={visible}
onCancel={onCancel}
footer={[
<Button
key="cancel"
onClick={onCancel}
style={{
height: '40px',
borderRadius: '6px',
}}
>
取消
</Button>,
<Button
key="submit"
type="primary"
loading={exportLoading}
onClick={handleExport}
style={{
height: '40px',
borderRadius: '6px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
color: '#ffffff',
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
导出
</Button>,
]}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
width={480}
>
<Form layout="vertical">
<Form.Item
label={<span style={{ fontWeight: 500 }}>导出格式</span>}
>
<Select
value={exportFormat}
onChange={setExportFormat}
style={{ width: '100%' }}
>
<Option value="csv">
<Space>
<FileTextOutlined />
CSV 格式适合Excel打开
</Space>
</Option>
<Option value="json">
<Space>
<BranchesOutlined />
JSON 格式适合程序处理
</Space>
</Option>
<Option value="xlsx">
<Space>
<TableOutlined />
Excel 格式.xlsx
</Space>
</Option>
</Select>
</Form.Item>
<Form.Item
label={<span style={{ fontWeight: 500 }}>导出范围</span>}
>
<Select
value={exportScope}
onChange={setExportScope}
style={{ width: '100%' }}
>
<Option value="selected">选中工单 ({selectedCount} )</Option>
<Option value="currentPage">当前页 ({currentPageCount} )</Option>
<Option value="all">全部工单 ({totalCount} )</Option>
</Select>
</Form.Item>
<div
style={{
background: '#f6f7ff',
border: '1px solid #e8eaff',
borderRadius: '8px',
padding: '12px 16px',
color: '#666',
fontSize: '13px',
}}
>
将导出工单的所有字段包括基础信息和自定义字段
</div>
</Form>
</Modal>
);
};
export default React.memo(TicketExportModal);
+88 -3
View File
@@ -41,9 +41,11 @@ import {
DatabaseOutlined,
EnvironmentOutlined,
TagOutlined,
ExportOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import CloseButton from '../components/CloseButton';
import TicketExportModal from '../components/TicketExportModal';
import dayjs from 'dayjs';
import { useSearchParams } from 'react-router-dom';
import { debounce, getUserFromStorage } from '../utils/common';
@@ -246,6 +248,8 @@ function TicketManagement() {
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [processingModalVisible, setProcessingModalVisible] = useState(false);
const [exportModalVisible, setExportModalVisible] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [editingTicket, setEditingTicket] = useState(null);
const [selectedTicket, setSelectedTicket] = useState(null);
const [operationRecords, setOperationRecords] = useState([]);
@@ -815,6 +819,69 @@ function TicketManagement() {
fetchTickets(1, pagination.pageSize, {});
}, [fetchTickets, pagination.pageSize]);
const handleExport = useCallback(
async ({ format, scope }) => {
try {
let ticketIds = [];
if (scope === 'selected') {
ticketIds = selectedRowKeys;
} else if (scope === 'currentPage') {
ticketIds = tickets.map(t => t.ticketId);
} else {
ticketIds = tickets.map(t => t.ticketId);
}
if (ticketIds.length === 0) {
message.warning('没有可导出的工单');
return;
}
const params = new URLSearchParams();
ticketIds.forEach(id => params.append('ticketIds', id));
params.append('format', format);
Object.entries(searchFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, value);
}
});
const response = await axios.get(`/api/tickets/export?${params.toString()}`, {
responseType: 'blob',
});
let mimeType;
let filename;
if (format === 'json') {
mimeType = 'application/json';
filename = `tickets_${Date.now()}.json`;
} else if (format === 'xlsx') {
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
filename = `tickets_${Date.now()}.xlsx`;
} else {
mimeType = 'text/csv;charset=utf-8';
filename = `tickets_${Date.now()}.csv`;
}
const blob = new Blob([response.data], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success(`成功导出 ${ticketIds.length} 个工单`);
} catch (error) {
console.error('导出失败:', error);
message.error('导出失败');
}
},
[searchFilters, selectedRowKeys, tickets]
);
const handleTableChange = useCallback(
paginationInfo => {
setPagination(paginationInfo);
@@ -1070,9 +1137,14 @@ function TicketManagement() {
<Card
title="工单管理"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
创建工单
</Button>
<Space>
<Button icon={<ExportOutlined />} onClick={() => setExportModalVisible(true)}>
导出
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
创建工单
</Button>
</Space>
}
>
<Form
@@ -1127,6 +1199,10 @@ function TicketManagement() {
loading={loading}
onChange={handleTableChange}
scroll={{ x: 1200 }}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
columnsState={{
onChange: ({ visibleColumns }) => {
secureStorage.set(TICKET_COLUMNS_KEY, visibleColumns);
@@ -1952,6 +2028,15 @@ function TicketManagement() {
</Tabs>
)}
</Modal>
<TicketExportModal
visible={exportModalVisible}
onExport={handleExport}
onCancel={() => setExportModalVisible(false)}
selectedCount={selectedRowKeys.length}
currentPageCount={tickets.length}
totalCount={pagination.total}
/>
</div>
);
}