import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, Tag, Dropdown, Menu, Tabs, Timeline, Descriptions, Checkbox, Popover, InputNumber, Switch, Row, Col, } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined, MoreOutlined, UserOutlined, ToolOutlined, CheckCircleOutlined, SyncOutlined, ClockCircleOutlined, CloseCircleOutlined, SettingOutlined, CloudServerOutlined, 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'; import secureStorage, { TICKET_COLUMNS_KEY } from '../utils/secureStorage'; const { Option } = Select; const { RangePicker } = DatePicker; const { TextArea } = Input; const { TabPane } = Tabs; const BUILTIN_TICKET_FIELDS = [ 'ticketId', 'title', 'deviceId', 'deviceName', 'deviceModel', 'serialNumber', 'faultCategory', 'faultSubCategory', 'priority', 'status', 'description', 'expectedCompletionDate', 'reporterId', 'reporterName', 'assigneeId', 'assigneeName', 'location', 'resolution', 'completionDate', 'evaluation', 'evaluationRating', 'attachments', 'tags', 'notes', 'result', 'solution', 'usedParts', ]; const DEFAULT_TICKET_FIELDS = [ { fieldName: 'title', displayName: '标题', fieldType: 'string', required: true, order: 1, visible: true, }, { fieldName: 'deviceId', displayName: '关联设备', fieldType: 'device', required: false, order: 2, visible: true, }, { fieldName: 'deviceName', displayName: '设备名称', fieldType: 'string', required: false, order: 3, visible: true, }, { fieldName: 'serialNumber', displayName: '设备序列号', fieldType: 'string', required: false, order: 4, visible: true, }, { fieldName: 'faultCategory', displayName: '故障分类', fieldType: 'select', required: true, order: 5, visible: true, options: [], }, { fieldName: 'priority', displayName: '优先级', fieldType: 'select', required: true, order: 6, visible: true, options: [ { value: 'low', label: '低' }, { value: 'medium', label: '中' }, { value: 'high', label: '高' }, { value: 'urgent', label: '紧急' }, ], }, { fieldName: 'status', displayName: '状态', fieldType: 'select', required: false, order: 6.5, visible: true, options: [ { value: 'pending', label: '待处理' }, { value: 'in_progress', label: '处理中' }, { value: 'completed', label: '已完成' }, { value: 'closed', label: '已关闭' }, ], }, { fieldName: 'description', displayName: '故障描述', fieldType: 'textarea', required: true, order: 7, visible: true, }, { fieldName: 'expectedCompletionDate', displayName: '期望完成时间', fieldType: 'datetime', required: false, order: 8, visible: true, }, { fieldName: 'resolution', displayName: '解决方案', fieldType: 'textarea', required: false, order: 9, visible: true, }, { fieldName: 'notes', displayName: '备注', fieldType: 'textarea', required: false, order: 10, visible: true, }, ]; const getStatusColor = status => { const colors = { pending: 'orange', in_progress: 'processing', completed: 'green', closed: 'default', }; return colors[status] || 'default'; }; const getStatusText = status => { const texts = { pending: '待处理', in_progress: '处理中', completed: '已完成', closed: '已关闭', }; return texts[status] || status; }; const getPriorityColor = priority => { const colors = { low: 'green', medium: 'orange', high: 'red', urgent: 'magenta', }; return colors[priority] || 'default'; }; const getPriorityText = priority => { const texts = { low: '低', medium: '中', high: '高', urgent: '紧急', }; return texts[priority] || priority; }; const generateTicketId = () => { const prefix = 'TKT'; const timestamp = dayjs().format('YYYYMMDDHHmmss'); const random = Math.floor(Math.random() * 1000) .toString() .padStart(3, '0'); return `${prefix}${timestamp}${random}`; }; function TicketManagement() { const [searchParams] = useSearchParams(); const [tickets, setTickets] = useState([]); const [devices, setDevices] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); 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([]); const [form] = Form.useForm(); const [processForm] = Form.useForm(); const [searchForm] = Form.useForm(); // 从URL参数获取设备信息(从设备详情页跳转过来) const urlDeviceId = searchParams.get('deviceId'); const urlDeviceName = searchParams.get('deviceName'); const urlSerialNumber = searchParams.get('serialNumber'); const urlCreate = searchParams.get('create'); const urlView = searchParams.get('view'); const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0, pageSizeOptions: ['10', '20', '30', '50'], showSizeChanger: true, showTotal: total => `共 ${total} 条记录`, }); const [searchFilters, setSearchFilters] = useState({}); const [manualDeviceSource, setManualDeviceSource] = useState(false); const [ticketFields, setTicketFields] = useState(DEFAULT_TICKET_FIELDS); const [loadingFields, setLoadingFields] = useState(true); const [deviceFields, setDeviceFields] = useState([]); const fetchTickets = useCallback( async (page = 1, pageSize = 10, filters = {}) => { try { setLoading(true); const params = { page, pageSize, ...searchFilters, ...filters, }; const response = await axios.get('/api/tickets', { params }); const { tickets: ticketList, total } = response.data; const processedTickets = ticketList.map(ticket => { const processed = { ...ticket }; if (ticket.metadata && typeof ticket.metadata === 'object') { Object.entries(ticket.metadata).forEach(([key, value]) => { processed[key] = value; }); } return processed; }); setTickets(processedTickets); setPagination(prev => ({ ...prev, current: page, pageSize, total })); } catch (error) { message.error('获取工单列表失败'); console.error('获取工单列表失败:', error); } finally { setLoading(false); } }, [searchFilters] ); const [deviceSearching, setDeviceSearching] = useState(false); const [deviceSearchValue, setDeviceSearchValue] = useState(''); const fetchDevices = useCallback(async (keyword = '') => { try { setDeviceSearching(true); const params = {}; if (keyword && keyword.trim()) { params.keyword = keyword.trim(); } const response = await axios.get('/api/devices/all', { params }); setDevices(response.data.devices || []); } catch (error) { console.error('获取设备列表失败:', error); } finally { setDeviceSearching(false); } }, []); const handleDeviceSearch = useCallback( debounce(value => { fetchDevices(value); }, 300), [fetchDevices] ); const fetchCategories = useCallback(async () => { try { const response = await axios.get('/api/ticket-categories'); const categoryOptions = (response.data || []).map(cat => ({ value: cat.name, label: cat.name, })); setCategories(response.data || []); setTicketFields(prev => prev.map(field => field.fieldName === 'faultCategory' ? { ...field, options: categoryOptions } : field ) ); } catch (error) { console.error('获取分类列表失败:', error); } }, []); const fetchTicketFields = useCallback(async () => { try { setLoadingFields(true); const response = await axios.get('/api/ticket-fields'); const dbFields = response.data.sort((a, b) => a.order - b.order); const isValidOptions = opts => { if (!opts) return false; if (!Array.isArray(opts)) return false; if (opts.length === 0) return false; return opts.some( opt => opt && opt.value !== undefined && opt.value !== null && opt.value !== '' ); }; const mergedFields = DEFAULT_TICKET_FIELDS.map(defaultField => { const dbField = dbFields.find(f => f.fieldName === defaultField.fieldName); if (dbField) { return { ...defaultField, ...dbField, options: isValidOptions(dbField.options) ? dbField.options : defaultField.options, }; } return defaultField; }); dbFields.forEach(dbField => { if (!DEFAULT_TICKET_FIELDS.some(f => f.fieldName === dbField.fieldName)) { mergedFields.push(dbField); } }); setTicketFields(mergedFields); } catch (error) { console.error('获取工单字段配置失败:', error); setTicketFields(DEFAULT_TICKET_FIELDS); } finally { setLoadingFields(false); } }, []); // 获取设备字段配置 const fetchDeviceFields = useCallback(async () => { try { const response = await axios.get('/api/deviceFields'); setDeviceFields(response.data || []); } catch (error) { console.error('获取设备字段配置失败:', error); setDeviceFields([]); } }, []); useEffect(() => { fetchTickets(); fetchDevices(); fetchTicketFields().then(() => { // 在 ticketFields 加载完成后再加载分类数据 fetchCategories(); }); fetchDeviceFields(); }, [fetchTickets, fetchDevices, fetchTicketFields, fetchCategories, fetchDeviceFields]); // 处理从设备详情页跳转过来创建工单的情况 useEffect(() => { if (urlDeviceId && devices.length > 0 && urlCreate === 'true') { setEditingTicket(null); setModalVisible(true); setTimeout(() => { form.setFieldsValue({ deviceId: urlDeviceId, }); }, 100); } }, [urlDeviceId, urlCreate, devices, form]); // 处理从设备详情页跳转过来查看工单的情况 useEffect(() => { if (urlDeviceId && urlView === 'true') { // 设置搜索筛选条件,只显示该设备的工单 const filters = { deviceId: urlDeviceId }; setSearchFilters(filters); // 在搜索框中显示设备名称提示 searchForm.setFieldsValue({ keyword: urlDeviceName || '' }); } }, [urlDeviceId, urlView, urlDeviceName, searchForm]); // 当 searchFilters 变化时,重新获取工单列表 useEffect(() => { if (urlDeviceId && urlView === 'true' && searchFilters.deviceId === urlDeviceId) { fetchTickets(1, pagination.pageSize); } }, [searchFilters, urlDeviceId, urlView]); const renderFormItem = useCallback( field => { const { fieldName, displayName, fieldType, required, options, placeholder } = field; const rules = required ? [{ required: true, message: `请选择或输入${displayName}` }] : []; let formItem; switch (fieldType) { case 'string': formItem = ; break; case 'number': formItem = ( ); break; case 'textarea': formItem = ( ); break; case 'boolean': formItem = ; break; case 'date': formItem = ; break; case 'datetime': formItem = ; break; case 'select': const selectOptions = options && Array.isArray(options) ? options : []; formItem = ( ); break; case 'device': formItem = ( ); break; default: formItem = ; } return ( {displayName}} rules={rules} valuePropName={fieldType === 'boolean' ? 'checked' : undefined} > {formItem} ); }, [devices, deviceSearching, handleDeviceSearch, fetchDevices] ); const tableColumns = useMemo(() => { const baseColumns = [ { title: '工单编号', dataIndex: 'ticketId', key: 'ticketId', width: 150, fixed: 'left' }, { title: '标题', dataIndex: 'title', key: 'title', width: 200, ellipsis: true }, { title: '设备信息', key: 'deviceInfo', width: 180, render: (_, record) => (
{record.deviceName || '-'}
{record.serialNumber || '-'}
), }, { title: '故障分类', dataIndex: 'faultCategory', key: 'faultCategory', width: 120, render: value => (value ? {value} : '-'), }, { title: '优先级', dataIndex: 'priority', key: 'priority', width: 80, render: value => {getPriorityText(value)}, }, { title: '状态', dataIndex: 'status', key: 'status', width: 100, render: value => {getStatusText(value)}, }, { title: '报告人', dataIndex: 'reporterName', key: 'reporterName', width: 100 }, { title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, render: value => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'), }, ]; const customFieldColumns = ticketFields .filter(field => !BUILTIN_TICKET_FIELDS.includes(field.fieldName) && field.visible) .map(field => ({ title: field.displayName, dataIndex: field.fieldName, key: field.fieldName, width: 120, render: value => { if (value === null || value === undefined) return '-'; if (field.fieldType === 'boolean') { return ; } if (field.fieldType === 'date' || field.fieldType === 'datetime') { return value ? dayjs(value).format(field.fieldType === 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm') : '-'; } if (field.fieldType === 'select' && Array.isArray(field.options)) { const option = field.options.find(opt => opt.value === value); return option ? option.label : value; } return String(value); }, })); const actionColumn = { title: '操作', key: 'action', width: 100, fixed: 'right', render: (_, record) => ( } >
{ secureStorage.set(TICKET_COLUMNS_KEY, visibleColumns); }, }} />
{editingTicket ? '编辑工单' : '创建工单'} } open={modalVisible} closeIcon={} onCancel={handleCancel} footer={null} width={800} destroyOnClose style={{ top: 40 }} bodyStyle={{ padding: '24px 24px 8px 24px' }} >
{!editingTicket && (
TKT
工单编号(自动生成)
)}
设备信息
{(() => { const hasDeviceIdField = ticketFields.some(f => f.fieldName === 'deviceId'); const hasDeviceNameField = ticketFields.some(f => f.fieldName === 'deviceName'); const renderDeviceSection = () => (
设备来源} name="deviceSource" initialValue="select" >
{manualDeviceSource ? ( <>
设备序列号 * } rules={[{ required: true, message: '请输入设备序列号' }]} >
设备名称} >
) : (
关联设备 * } rules={[{ required: true, message: '请选择关联设备' }]} >
)}
); if (hasDeviceIdField) { return renderDeviceSection(); } else if (hasDeviceNameField) { if (!ticketFields.some(f => f.fieldName === 'deviceId')) { return
{renderDeviceSection()}
; } } else { return renderDeviceSection(); } return null; })()}
工单信息
{ticketFields .filter( f => ![ 'ticketId', 'deviceId', 'deviceName', 'serialNumber', 'expectedCompletionDate', 'resolution', 'notes', 'description', ].includes(f.fieldName) ) .map(field => (
{renderFormItem(field)}
))}
工单标题 * } rules={[{ required: true, message: '请输入工单标题' }]} >
故障分类 * } rules={[{ required: true, message: '请选择故障分类' }]} >
优先级 * } rules={[{ required: true, message: '请选择优先级' }]} initialValue="medium" >
期望完成时间} >
故障描述 * } rules={[{ required: true, message: '请输入故障描述' }]} >