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 ( + + + 导出工单数据 + + } + open={visible} + onCancel={onCancel} + footer={[ + , + , + ]} + destroyOnHidden + styles={{ + header: { + borderBottom: '1px solid #f0f0f0', + padding: '16px 24px', + position: 'relative', + }, + body: { padding: '24px' }, + }} + width={480} + > +
+ 导出格式} + > + + + + 导出范围} + > + + + +
+ 将导出工单的所有字段,包括基础信息和自定义字段 +
+
+
+ ); +}; + +export default React.memo(TicketExportModal); diff --git a/frontend/src/pages/TicketManagement.jsx b/frontend/src/pages/TicketManagement.jsx index 36fc5bd..60c87e9 100644 --- a/frontend/src/pages/TicketManagement.jsx +++ b/frontend/src/pages/TicketManagement.jsx @@ -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() { } onClick={() => showModal()}> - 创建工单 - + + + + } >
{ secureStorage.set(TICKET_COLUMNS_KEY, visibleColumns); @@ -1952,6 +2028,15 @@ function TicketManagement() { )} + + setExportModalVisible(false)} + selectedCount={selectedRowKeys.length} + currentPageCount={tickets.length} + totalCount={pagination.total} + /> ); }