From 773efdb9a4547eefff1276d4521c5838cf526b52 Mon Sep 17 00:00:00 2001
From: zhang1106 <849185023@qq.com>
Date: Tue, 31 Mar 2026 21:58:09 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E5=B7=A5=E5=8D=95=E7=AE=A1=E7=90=86):=20?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B7=A5=E5=8D=95=E5=AF=BC=E5=87=BA=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
实现工单数据的导出功能,支持CSV、JSON和Excel格式。前端添加导出模态框和选择逻辑,后端实现数据处理和文件生成。用户可选择导出选中项、当前页或全部工单。
- 前端添加TicketExportModal组件处理导出选项
- 后端添加/export接口处理不同格式的导出请求
- 支持导出工单基础信息和自定义字段
- 添加导出按钮到工单管理页面
---
backend/routes/tickets.js | 159 ++++++++++++++++++
frontend/src/components/TicketExportModal.jsx | 159 ++++++++++++++++++
frontend/src/pages/TicketManagement.jsx | 91 +++++++++-
3 files changed, 406 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/components/TicketExportModal.jsx
diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js
index 0a46f16..aaef032 100644
--- a/backend/routes/tickets.js
+++ b/backend/routes/tickets.js
@@ -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;
diff --git a/frontend/src/components/TicketExportModal.jsx b/frontend/src/components/TicketExportModal.jsx
new file mode 100644
index 0000000..b2f6995
--- /dev/null
+++ b/frontend/src/components/TicketExportModal.jsx
@@ -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 (
+