Files
yunrui_asset/frontend/src/pages/TicketManagement.jsx
T
zhang1106 773efdb9a4 feat(工单管理): 添加工单导出功能
实现工单数据的导出功能,支持CSV、JSON和Excel格式。前端添加导出模态框和选择逻辑,后端实现数据处理和文件生成。用户可选择导出选中项、当前页或全部工单。

- 前端添加TicketExportModal组件处理导出选项
- 后端添加/export接口处理不同格式的导出请求
- 支持导出工单基础信息和自定义字段
- 添加导出按钮到工单管理页面
2026-03-31 21:58:09 +08:00

2045 lines
70 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = <Input placeholder={placeholder || `请输入${displayName}`} size="large" />;
break;
case 'number':
formItem = (
<InputNumber
placeholder={placeholder || `请输入${displayName}`}
style={{ width: '100%' }}
size="large"
/>
);
break;
case 'textarea':
formItem = (
<Input.TextArea
rows={3}
placeholder={placeholder || `请输入${displayName}`}
showCount
/>
);
break;
case 'boolean':
formItem = <Switch />;
break;
case 'date':
formItem = <DatePicker style={{ width: '100%' }} size="large" />;
break;
case 'datetime':
formItem = <DatePicker showTime style={{ width: '100%' }} size="large" />;
break;
case 'select':
const selectOptions = options && Array.isArray(options) ? options : [];
formItem = (
<Select placeholder={placeholder || `请选择${displayName}`} size="large">
{selectOptions.map((opt, idx) => (
<Option key={idx} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
);
break;
case 'device':
formItem = (
<Select
placeholder="输入关键词搜索设备(序列号/名称/IP等)"
showSearch
allowClear
loading={deviceSearching}
filterOption={false}
onSearch={handleDeviceSearch}
notFoundContent={deviceSearching ? '搜索中...' : '请输入关键词搜索'}
onDropdownVisibleChange={open => {
if (open && devices.length === 0) {
fetchDevices();
}
}}
size="large"
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
{device.name} {device.serialNumber ? `- ${device.serialNumber}` : ''}
</Option>
))}
</Select>
);
break;
default:
formItem = <Input placeholder={placeholder || `请输入${displayName}`} size="large" />;
}
return (
<Form.Item
key={fieldName}
name={fieldName}
label={<span style={{ fontWeight: 500 }}>{displayName}</span>}
rules={rules}
valuePropName={fieldType === 'boolean' ? 'checked' : undefined}
>
{formItem}
</Form.Item>
);
},
[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) => (
<div>
<div>{record.deviceName || '-'}</div>
<div style={{ fontSize: 12, color: '#888' }}>{record.serialNumber || '-'}</div>
</div>
),
},
{
title: '故障分类',
dataIndex: 'faultCategory',
key: 'faultCategory',
width: 120,
render: value => (value ? <Tag>{value}</Tag> : '-'),
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 80,
render: value => <Tag color={getPriorityColor(value)}>{getPriorityText(value)}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: value => <Tag color={getStatusColor(value)}>{getStatusText(value)}</Tag>,
},
{ 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 <Switch checked={value} disabled />;
}
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) => (
<Dropdown trigger={['click']} overlay={getActionItems(record)}>
<Button type="text" icon={<MoreOutlined />} />
</Dropdown>
),
};
return [...baseColumns, ...customFieldColumns, actionColumn];
}, [ticketFields]);
const fetchTicketDetail = useCallback(async ticketId => {
try {
const [ticketRes, operationsRes] = await Promise.all([
axios.get(`/api/tickets/${ticketId}`),
axios.get(`/api/tickets/${ticketId}/operations`),
]);
setSelectedTicket(ticketRes.data);
setOperationRecords(operationsRes.data || []);
setDetailModalVisible(true);
} catch (error) {
message.error('获取工单详情失败');
console.error('获取工单详情失败:', error);
}
}, []);
const showModal = useCallback((ticket = null) => {
setEditingTicket(ticket);
if (ticket) {
setManualDeviceSource(!ticket.deviceId);
const ticketData = { ...ticket };
if (ticketData.expectedCompletionDate) {
ticketData.expectedCompletionDate = dayjs(ticketData.expectedCompletionDate);
}
if (ticketData.completionDate) {
ticketData.completionDate = dayjs(ticketData.completionDate);
}
if (ticket.metadata && typeof ticket.metadata === 'object') {
Object.entries(ticket.metadata).forEach(([key, value]) => {
ticketData[key] = value;
});
}
form.setFieldsValue(ticketData);
} else {
setManualDeviceSource(false);
form.resetFields();
const newTicketId = generateTicketId();
form.setFieldsValue({ ticketId: newTicketId });
}
setModalVisible(true);
}, []);
const handleCancel = useCallback(() => {
setModalVisible(false);
setEditingTicket(null);
}, []);
const handleSubmit = useCallback(
async values => {
try {
const ticketData = {
...values,
expectedCompletionDate: values.expectedCompletionDate
? values.expectedCompletionDate.format('YYYY-MM-DD HH:mm:ss')
: null,
completionDate: values.completionDate
? values.completionDate.format('YYYY-MM-DD HH:mm:ss')
: null,
metadata: {},
};
ticketFields.forEach(field => {
if (!BUILTIN_TICKET_FIELDS.includes(field.fieldName)) {
if (values[field.fieldName] !== undefined) {
ticketData.metadata[field.fieldName] = values[field.fieldName];
}
delete ticketData[field.fieldName];
}
});
if (manualDeviceSource) {
ticketData.deviceId = null;
ticketData.deviceName = values.deviceName || '未知设备';
ticketData.serialNumber = values.serialNumber;
} else {
const selectedDevice = devices.find(d => d.deviceId === values.deviceId);
if (selectedDevice) {
ticketData.deviceName = selectedDevice.name;
ticketData.serialNumber = selectedDevice.serialNumber;
}
}
if (editingTicket) {
await axios.put(`/api/tickets/${editingTicket.ticketId}`, ticketData);
message.success('工单更新成功');
} else {
const user = getUserFromStorage();
ticketData.reporterId = user.userId || 'USER001';
ticketData.reporterName = user.username || '系统用户';
await axios.post('/api/tickets', ticketData);
message.success('工单创建成功');
}
setModalVisible(false);
fetchTickets();
setEditingTicket(null);
} catch (error) {
message.error(editingTicket ? '工单更新失败' : '工单创建失败');
console.error(error);
}
},
[editingTicket, fetchTickets, ticketFields, devices, manualDeviceSource]
);
const handleDelete = useCallback(
async ticketId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个工单吗?',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await axios.delete(`/api/tickets/${ticketId}`);
message.success('工单删除成功');
fetchTickets();
} catch (error) {
message.error('工单删除失败');
console.error(error);
}
},
});
},
[fetchTickets]
);
const handleProcess = useCallback(ticket => {
setSelectedTicket(ticket);
processForm.resetFields();
setProcessingModalVisible(true);
}, []);
const handleProcessSubmit = useCallback(
async values => {
try {
const user = getUserFromStorage();
await axios.put(`/api/tickets/${selectedTicket.ticketId}/process`, {
...values,
operatorId: user.userId,
operatorName: user.username,
});
message.success('工单处理完成');
setProcessingModalVisible(false);
fetchTickets();
} catch (error) {
message.error('处理失败');
console.error(error);
}
},
[selectedTicket, fetchTickets]
);
const handleStatusChange = useCallback(
async (ticketId, newStatus) => {
try {
const user = getUserFromStorage();
await axios.put(`/api/tickets/${ticketId}/status`, {
status: newStatus,
operatorId: user.userId,
operatorName: user.username,
});
message.success('状态更新成功');
fetchTickets();
} catch (error) {
message.error('状态更新失败');
console.error(error);
}
},
[fetchTickets]
);
const handleSearch = useCallback(
values => {
setSearchFilters(values);
fetchTickets(1, pagination.pageSize, values);
},
[fetchTickets, pagination.pageSize]
);
const handleReset = useCallback(() => {
searchForm.resetFields();
setSearchFilters({});
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);
fetchTickets(paginationInfo.current, paginationInfo.pageSize, searchFilters);
},
[fetchTickets, searchFilters]
);
const renderFormItems = useCallback(() => {
const items = [];
if (!editingTicket) {
items.push(
<Form.Item key="ticketId" name="ticketId" label="工单编号">
<Input placeholder="自动生成" disabled style={{ background: '#f5f5f5' }} />
</Form.Item>
);
}
const hasDeviceIdField = ticketFields.some(f => f.fieldName === 'deviceId');
const hasDeviceNameField = ticketFields.some(f => f.fieldName === 'deviceName');
ticketFields.forEach(field => {
if (field.fieldName === 'ticketId') {
} else if (field.fieldName === 'deviceId') {
items.push(
<React.Fragment key="deviceSource">
<Form.Item label="设备来源" required>
<Select
value={manualDeviceSource ? 'manual' : 'select'}
onChange={val => setManualDeviceSource(val === 'manual')}
style={{ width: 200 }}
>
<Option value="select">从设备列表选择</Option>
<Option value="manual">手动输入序列号</Option>
</Select>
</Form.Item>
{manualDeviceSource ? (
<>
<Form.Item
name="serialNumber"
label="设备序列号"
rules={[{ required: true, message: '请输入设备序列号' }]}
>
<Input placeholder="请输入设备序列号" />
</Form.Item>
<Form.Item
name="deviceName"
label="设备名称"
rules={[{ required: false, message: '请输入设备名称(选填)' }]}
>
<Input placeholder="请输入设备名称(选填)" />
</Form.Item>
</>
) : (
<Form.Item
name="deviceId"
label="关联设备"
rules={[{ required: true, message: '请选择关联设备' }]}
>
<Select
placeholder="输入关键词搜索设备(序列号/名称/IP等)"
showSearch
allowClear
loading={deviceSearching}
filterOption={false}
onSearch={handleDeviceSearch}
notFoundContent={deviceSearching ? '搜索中...' : '请输入关键词搜索'}
onDropdownVisibleChange={open => {
if (open && devices.length === 0) {
fetchDevices();
}
}}
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
{device.name} {device.serialNumber ? `- ${device.serialNumber}` : ''}
</Option>
))}
</Select>
</Form.Item>
)}
</React.Fragment>
);
} else if (field.fieldName === 'deviceName' || field.fieldName === 'serialNumber') {
if (!hasDeviceIdField && field.fieldName === 'deviceName') {
items.push(
<Form.Item
key="deviceId"
name="deviceId"
label="关联设备"
rules={[{ required: true, message: '请选择关联设备' }]}
>
<Select
placeholder="输入关键词搜索设备(序列号/名称/IP等)"
showSearch
allowClear
loading={deviceSearching}
filterOption={false}
onSearch={handleDeviceSearch}
notFoundContent={deviceSearching ? '搜索中...' : '请输入关键词搜索'}
onDropdownVisibleChange={open => {
if (open && devices.length === 0) {
fetchDevices();
}
}}
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
{device.name} {device.serialNumber ? `- ${device.serialNumber}` : ''}
</Option>
))}
</Select>
</Form.Item>
);
}
} else {
items.push(renderFormItem(field));
}
});
if (!hasDeviceIdField && !hasDeviceNameField) {
items.push(
<React.Fragment key="deviceSource">
<Form.Item label="设备来源" required>
<Select
value={manualDeviceSource ? 'manual' : 'select'}
onChange={val => setManualDeviceSource(val === 'manual')}
style={{ width: 200 }}
>
<Option value="select">从设备列表选择</Option>
<Option value="manual">手动输入序列号</Option>
</Select>
</Form.Item>
{manualDeviceSource ? (
<>
<Form.Item
name="serialNumber"
label="设备序列号"
rules={[{ required: true, message: '请输入设备序列号' }]}
>
<Input placeholder="请输入设备序列号" />
</Form.Item>
<Form.Item
name="deviceName"
label="设备名称"
rules={[{ required: false, message: '请输入设备名称(选填)' }]}
>
<Input placeholder="请输入设备名称(选填)" />
</Form.Item>
</>
) : (
<Form.Item
name="deviceId"
label="关联设备"
rules={[{ required: true, message: '请选择关联设备' }]}
>
<Select
placeholder="输入关键词搜索设备(序列号/名称/IP等)"
showSearch
allowClear
loading={deviceSearching}
filterOption={false}
onSearch={handleDeviceSearch}
notFoundContent={deviceSearching ? '搜索中...' : '请输入关键词搜索'}
onDropdownVisibleChange={open => {
if (open && devices.length === 0) {
fetchDevices();
}
}}
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
{device.name} {device.serialNumber ? `- ${device.serialNumber}` : ''}
</Option>
))}
</Select>
</Form.Item>
)}
</React.Fragment>
);
}
return items;
}, [
ticketFields,
devices,
deviceSearching,
handleDeviceSearch,
fetchDevices,
editingTicket,
renderFormItem,
manualDeviceSource,
]);
const getActionItems = useCallback(
record => (
<Menu
items={[
{
key: 'view',
icon: <EyeOutlined />,
label: '查看详情',
onClick: () => fetchTicketDetail(record.ticketId),
},
{
key: 'process',
icon: <ToolOutlined />,
label: '处理工单',
disabled: record.status === 'closed' || record.status === 'completed',
onClick: () => handleProcess(record),
},
{ type: 'divider' },
{
key: 'pending',
label: '标记为待处理',
disabled: record.status !== 'pending',
onClick: () => handleStatusChange(record.ticketId, 'pending'),
},
{
key: 'in_progress',
label: '标记为处理中',
disabled: record.status !== 'pending',
onClick: () => handleStatusChange(record.ticketId, 'in_progress'),
},
{
key: 'completed',
label: '标记为已完成',
disabled: record.status === 'completed' || record.status === 'closed',
onClick: () => handleStatusChange(record.ticketId, 'completed'),
},
{
key: 'closed',
label: '标记为已关闭',
disabled: record.status === 'closed',
onClick: () => handleStatusChange(record.ticketId, 'closed'),
},
{ type: 'divider' },
{
key: 'delete',
icon: <DeleteOutlined />,
label: '删除工单',
danger: true,
onClick: () => handleDelete(record.ticketId),
},
]}
/>
),
[fetchTicketDetail, handleProcess, handleStatusChange, handleDelete]
);
return (
<div style={{ padding: 24 }}>
<Card
title="工单管理"
extra={
<Space>
<Button icon={<ExportOutlined />} onClick={() => setExportModalVisible(true)}>
导出
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
创建工单
</Button>
</Space>
}
>
<Form
form={searchForm}
layout="inline"
onFinish={handleSearch}
style={{ marginBottom: 16 }}
>
<Form.Item name="keyword" label="关键词">
<Input placeholder="标题/设备/描述" allowClear style={{ width: 200 }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select placeholder="选择状态" allowClear style={{ width: 120 }}>
<Option value="pending">待处理</Option>
<Option value="in_progress">处理中</Option>
<Option value="completed">已完成</Option>
<Option value="closed">已关闭</Option>
</Select>
</Form.Item>
<Form.Item name="priority" label="优先级">
<Select placeholder="选择优先级" allowClear style={{ width: 100 }}>
<Option value="low"></Option>
<Option value="medium"></Option>
<Option value="high"></Option>
<Option value="urgent">紧急</Option>
</Select>
</Form.Item>
<Form.Item name="faultCategory" label="故障分类">
<Select placeholder="选择分类" allowClear style={{ width: 150 }}>
{categories.map(cat => (
<Option key={cat.categoryId} value={cat.name}>
{cat.name}
</Option>
))}
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
搜索
</Button>
<Button style={{ marginLeft: 8 }} onClick={handleReset}>
重置
</Button>
</Form.Item>
</Form>
<Table
columns={tableColumns}
dataSource={tickets}
rowKey="ticketId"
pagination={pagination}
loading={loading}
onChange={handleTableChange}
scroll={{ x: 1200 }}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
columnsState={{
onChange: ({ visibleColumns }) => {
secureStorage.set(TICKET_COLUMNS_KEY, visibleColumns);
},
}}
/>
</Card>
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 16,
}}
>
<PlusOutlined />
</div>
<span style={{ fontWeight: 600, fontSize: 18 }}>
{editingTicket ? '编辑工单' : '创建工单'}
</span>
</div>
}
open={modalVisible}
closeIcon={<CloseButton />}
onCancel={handleCancel}
footer={null}
width={800}
destroyOnClose
style={{ top: 40 }}
bodyStyle={{ padding: '24px 24px 8px 24px' }}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
requiredMark="optional"
style={{ marginBottom: 16 }}
>
{!editingTicket && (
<div
style={{
background: 'linear-gradient(135deg, #f0f4ff 0%, #fafbff 100%)',
border: '1px solid #e8eaff',
borderRadius: 12,
padding: '12px 16px',
marginBottom: 20,
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 8,
background: '#667eea',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 14,
fontWeight: 600,
}}
>
TKT
</div>
<div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 2 }}>
工单编号自动生成
</div>
<Form.Item name="ticketId" noStyle>
<Input
disabled
placeholder="点击创建后自动生成"
style={{
background: 'transparent',
border: 'none',
padding: 0,
fontWeight: 600,
color: '#333',
}}
/>
</Form.Item>
</div>
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0 24px',
}}
>
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span
style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}}
/>
设备信息
</div>
</div>
{(() => {
const hasDeviceIdField = ticketFields.some(f => f.fieldName === 'deviceId');
const hasDeviceNameField = ticketFields.some(f => f.fieldName === 'deviceName');
const renderDeviceSection = () => (
<React.Fragment>
<div style={{ gridColumn: '1 / -1', marginBottom: 16 }}>
<Form.Item
label={<span style={{ fontWeight: 500 }}>设备来源</span>}
name="deviceSource"
initialValue="select"
>
<Select
value={manualDeviceSource ? 'manual' : 'select'}
onChange={val => setManualDeviceSource(val === 'manual')}
style={{ width: '100%' }}
size="large"
>
<Option value="select">从设备列表选择</Option>
<Option value="manual">手动输入序列号</Option>
</Select>
</Form.Item>
</div>
{manualDeviceSource ? (
<>
<div>
<Form.Item
name="serialNumber"
label={
<span style={{ fontWeight: 500 }}>
设备序列号 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入设备序列号' }]}
>
<Input placeholder="请输入设备序列号" size="large" />
</Form.Item>
</div>
<div>
<Form.Item
name="deviceName"
label={<span style={{ fontWeight: 500 }}>设备名称</span>}
>
<Input placeholder="请输入设备名称(选填)" size="large" />
</Form.Item>
</div>
</>
) : (
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="deviceId"
label={
<span style={{ fontWeight: 500 }}>
关联设备 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择关联设备' }]}
>
<Select
placeholder="输入关键词搜索设备(序列号/名称/IP等)"
showSearch
allowClear
loading={deviceSearching}
filterOption={false}
onSearch={handleDeviceSearch}
notFoundContent={deviceSearching ? '搜索中...' : '请输入关键词搜索'}
onDropdownVisibleChange={open => {
if (open && devices.length === 0) {
fetchDevices();
}
}}
size="large"
>
{devices.map(device => (
<Option key={device.deviceId} value={device.deviceId}>
{device.name} {device.serialNumber ? `- ${device.serialNumber}` : ''}
</Option>
))}
</Select>
</Form.Item>
</div>
)}
</React.Fragment>
);
if (hasDeviceIdField) {
return renderDeviceSection();
} else if (hasDeviceNameField) {
if (!ticketFields.some(f => f.fieldName === 'deviceId')) {
return <div style={{ gridColumn: '1 / -1' }}>{renderDeviceSection()}</div>;
}
} else {
return renderDeviceSection();
}
return null;
})()}
<div style={{ gridColumn: '1 / -1', marginBottom: 8, marginTop: 8 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span
style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}}
/>
工单信息
</div>
</div>
{ticketFields
.filter(
f =>
![
'ticketId',
'deviceId',
'deviceName',
'serialNumber',
'expectedCompletionDate',
'resolution',
'notes',
'description',
].includes(f.fieldName)
)
.map(field => (
<div key={field.fieldName}>{renderFormItem(field)}</div>
))}
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="title"
label={
<span style={{ fontWeight: 500 }}>
工单标题 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入工单标题' }]}
>
<Input placeholder="请输入工单标题" size="large" />
</Form.Item>
</div>
<div>
<Form.Item
name="faultCategory"
label={
<span style={{ fontWeight: 500 }}>
故障分类 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择故障分类' }]}
>
<Select placeholder="请选择故障分类" size="large">
{categories.map(cat => (
<Option key={cat.categoryId} value={cat.name}>
{cat.name}
</Option>
))}
</Select>
</Form.Item>
</div>
<div>
<Form.Item
name="priority"
label={
<span style={{ fontWeight: 500 }}>
优先级 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择优先级' }]}
initialValue="medium"
>
<Select placeholder="请选择优先级" size="large">
<Option value="low">
<Tag color="green" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="medium">
<Tag color="orange" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="high">
<Tag color="red" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="urgent">
<Tag color="magenta" style={{ margin: 0 }}>
紧急
</Tag>
</Option>
</Select>
</Form.Item>
</div>
<div>
<Form.Item
name="expectedCompletionDate"
label={<span style={{ fontWeight: 500 }}>期望完成时间</span>}
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm"
style={{ width: '100%' }}
size="large"
placeholder="选择期望完成时间"
/>
</Form.Item>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="description"
label={
<span style={{ fontWeight: 500 }}>
故障描述 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入故障描述' }]}
>
<TextArea
rows={4}
placeholder="请详细描述故障现象、发生时间、影响范围等信息"
showCount
maxLength={500}
/>
</Form.Item>
</div>
<div style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<Form.Item name="notes" label={<span style={{ fontWeight: 500 }}>备注信息</span>}>
<TextArea
rows={2}
placeholder="补充说明或其他相关信息(选填)"
showCount
maxLength={200}
/>
</Form.Item>
</div>
</div>
<div
style={{
borderTop: '1px solid #f0f0f0',
marginTop: 24,
paddingTop: 20,
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
}}
>
<Button onClick={handleCancel} size="large" style={{ minWidth: 100 }}>
取消
</Button>
<Button
type="primary"
htmlType="submit"
size="large"
style={{
minWidth: 120,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
}}
>
{editingTicket ? '更新工单' : '创建工单'}
</Button>
</div>
</Form>
</Modal>
<Modal
title="处理工单"
open={processingModalVisible}
closeIcon={<CloseButton />}
onCancel={() => setProcessingModalVisible(false)}
footer={null}
width={600}
>
<Form form={processForm} layout="vertical" onFinish={handleProcessSubmit}>
<Descriptions bordered column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="工单编号">{selectedTicket?.ticketId}</Descriptions.Item>
<Descriptions.Item label="标题">{selectedTicket?.title}</Descriptions.Item>
<Descriptions.Item label="设备">{selectedTicket?.deviceName}</Descriptions.Item>
<Descriptions.Item label="故障描述">{selectedTicket?.description}</Descriptions.Item>
</Descriptions>
<Form.Item name="solution" label="处理方案" rules={[{ required: true }]}>
<TextArea rows={4} placeholder="请详细描述处理方案和步骤" />
</Form.Item>
<Form.Item name="result" label="处理结果" rules={[{ required: true }]}>
<Select placeholder="选择处理结果">
<Option value="resolved">问题已解决</Option>
<Option value="partially_resolved">部分解决</Option>
<Option value="unresolved">未解决</Option>
<Option value="escalated">需升级处理</Option>
</Select>
</Form.Item>
<Form.Item name="notes" label="备注">
<TextArea rows={2} placeholder="其他补充说明" />
</Form.Item>
<Form.Item name="usedParts" label="使用备件">
<Input placeholder="使用的备件信息(名称、数量)" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<CheckCircleOutlined />}>
提交处理结果
</Button>
<Button onClick={() => setProcessingModalVisible(false)}>取消</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={`工单详情 - ${selectedTicket?.ticketId}`}
open={detailModalVisible}
closeIcon={<CloseButton />}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>
关闭
</Button>,
selectedTicket?.status !== 'closed' && selectedTicket?.status !== 'completed' && (
<Button
key="process"
type="primary"
icon={<ToolOutlined />}
onClick={() => {
setDetailModalVisible(false);
handleProcess(selectedTicket);
}}
>
处理工单
</Button>
),
]}
width={900}
>
{selectedTicket && (
<Tabs defaultActiveKey="info">
<TabPane
tab={
<span>
<TagOutlined /> 基本信息
</span>
}
key="info"
>
<Descriptions bordered column={2}>
<Descriptions.Item label="工单编号">{selectedTicket.ticketId}</Descriptions.Item>
<Descriptions.Item label="标题">{selectedTicket.title}</Descriptions.Item>
<Descriptions.Item label="设备名称">{selectedTicket.deviceName}</Descriptions.Item>
<Descriptions.Item label="设备序列号">
{selectedTicket.serialNumber}
</Descriptions.Item>
<Descriptions.Item label="故障分类">
{selectedTicket.faultCategory}
</Descriptions.Item>
<Descriptions.Item label="优先级">
<Tag color={getPriorityColor(selectedTicket.priority)}>
{getPriorityText(selectedTicket.priority)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={getStatusColor(selectedTicket.status)}>
{getStatusText(selectedTicket.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="报告人">{selectedTicket.reporterName}</Descriptions.Item>
<Descriptions.Item label="创建时间">
{selectedTicket.createdAt
? dayjs(selectedTicket.createdAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="期望完成时间">
{selectedTicket.expectedCompletionDate
? dayjs(selectedTicket.expectedCompletionDate).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="完成时间" span={2}>
{selectedTicket.completionDate
? dayjs(selectedTicket.completionDate).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="故障描述" span={2}>
{selectedTicket.description || '-'}
</Descriptions.Item>
<Descriptions.Item label="解决方案" span={2}>
{selectedTicket.resolution || '-'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{selectedTicket.notes || '-'}
</Descriptions.Item>
</Descriptions>
</TabPane>
{selectedTicket?.Device && (
<TabPane
tab={
<span>
<CloudServerOutlined /> 设备信息
</span>
}
key="device"
>
<div style={{ padding: '16px 0' }}>
{/* 设备基本信息卡片 */}
<Card title="基本信息" style={{ marginBottom: 16 }} size="small">
<Row gutter={[24, 16]}>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>设备ID</div>
<div style={{ fontWeight: 500 }}>{selectedTicket.Device.deviceId}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>设备名称</div>
<div style={{ fontWeight: 500 }}>{selectedTicket.Device.name}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>状态</div>
<Tag
color={
selectedTicket.Device.status === 'running'
? 'green'
: selectedTicket.Device.status === 'maintenance'
? 'orange'
: selectedTicket.Device.status === 'fault'
? 'red'
: 'default'
}
>
{selectedTicket.Device.status === 'running'
? '运行中'
: selectedTicket.Device.status === 'maintenance'
? '维护中'
: selectedTicket.Device.status === 'fault'
? '故障'
: selectedTicket.Device.status === 'offline'
? '离线'
: selectedTicket.Device.status || '-'}
</Tag>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>设备类型</div>
<div>{selectedTicket.Device.type}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>型号</div>
<div>{selectedTicket.Device.model || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>序列号</div>
<div>{selectedTicket.Device.serialNumber || '-'}</div>
</Col>
</Row>
</Card>
{/* 位置信息卡片 */}
<Card title="位置信息" style={{ marginBottom: 16 }} size="small">
<Row gutter={[24, 16]}>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>所在机房</div>
<div>{selectedTicket.Device.roomName || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>所在机柜</div>
<div>{selectedTicket.Device.rackName || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>IP地址</div>
<div>{selectedTicket.Device.ipAddress || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>位置(U)</div>
<div>{selectedTicket.Device.position || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>高度(U)</div>
<div>{selectedTicket.Device.height || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>功耗(W)</div>
<div>{selectedTicket.Device.powerConsumption || '-'}</div>
</Col>
</Row>
</Card>
{/* 维保信息卡片 */}
<Card title="维保信息" style={{ marginBottom: 16 }} size="small">
<Row gutter={[24, 16]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>购买日期</div>
<div>
{selectedTicket.Device.purchaseDate
? dayjs(selectedTicket.Device.purchaseDate).format('YYYY-MM-DD')
: '-'}
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>保修到期</div>
<div>
{selectedTicket.Device.warrantyExpiry
? dayjs(selectedTicket.Device.warrantyExpiry).format('YYYY-MM-DD')
: '-'}
</div>
</Col>
</Row>
</Card>
{/* 描述信息 */}
{selectedTicket.Device.description && (
<Card title="描述" style={{ marginBottom: 16 }} size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>
{selectedTicket.Device.description}
</div>
</Card>
)}
{/* 自定义字段卡片 */}
{selectedTicket.Device.customFields &&
Object.keys(selectedTicket.Device.customFields).length > 0 && (
<Card title="自定义字段" size="small">
<Row gutter={[24, 16]}>
{Object.entries(selectedTicket.Device.customFields).map(
([key, value]) => {
// 从 deviceFields 中查找对应的中文显示名称
const fieldConfig = deviceFields.find(f => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>
{displayName}
</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
}
)}
</Row>
</Card>
)}
</div>
</TabPane>
)}
<TabPane
tab={
<span>
<ClockCircleOutlined /> 操作记录 ({operationRecords.length})
</span>
}
key="operations"
>
<div style={{ padding: '16px 0' }}>
{operationRecords.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#888' }}>
<ClockCircleOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<p>暂无操作记录</p>
</div>
) : (
<div style={{ position: 'relative' }}>
{/* 时间线轴线 */}
<div
style={{
position: 'absolute',
left: '20px',
top: '0',
bottom: '0',
width: '2px',
backgroundColor: '#e8e8e8',
}}
/>
{operationRecords.map((record, index) => {
const getOperationInfo = type => {
switch (type) {
case 'create':
return { color: '#52c41a', bgColor: '#f6ffed', label: '创建工单' };
case 'complete':
return { color: '#1890ff', bgColor: '#e6f7ff', label: '完成工单' };
case 'close':
return { color: '#8c8c8c', bgColor: '#f5f5f5', label: '关闭工单' };
case 'assign':
return { color: '#722ed1', bgColor: '#f9f0ff', label: '分配工单' };
case 'update':
return { color: '#fa8c16', bgColor: '#fff7e6', label: '更新工单' };
default:
return { color: '#fa8c16', bgColor: '#fff7e6', label: type };
}
};
const info = getOperationInfo(record.operationType);
return (
<div
key={index}
style={{
position: 'relative',
paddingLeft: '48px',
marginBottom: '24px',
}}
>
{/* 时间点圆点 */}
<div
style={{
position: 'absolute',
left: '12px',
top: '4px',
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: info.color,
border: '3px solid #fff',
boxShadow: '0 0 0 2px ' + info.color + '40',
}}
/>
{/* 操作卡片 */}
<Card
size="small"
style={{
backgroundColor: info.bgColor,
border: 'none',
borderRadius: '8px',
}}
bodyStyle={{ padding: '12px 16px' }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '8px',
}}
>
<Tag
color={info.color}
style={{
fontSize: '12px',
fontWeight: 500,
border: 'none',
margin: 0,
}}
>
{info.label}
</Tag>
<span style={{ color: '#666', fontSize: '12px' }}>
{dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</span>
</div>
<div style={{ marginBottom: '4px' }}>
<span style={{ color: '#666' }}>操作人</span>
<span style={{ fontWeight: 500 }}>{record.operatorName || '-'}</span>
</div>
{record.operationDescription && (
<div
style={{
marginTop: '8px',
padding: '8px 12px',
backgroundColor: '#fff',
borderRadius: '4px',
fontSize: '13px',
color: '#333',
lineHeight: '1.5',
}}
>
{record.operationDescription}
</div>
)}
</Card>
</div>
);
})}
</div>
)}
</div>
</TabPane>
</Tabs>
)}
</Modal>
<TicketExportModal
visible={exportModalVisible}
onExport={handleExport}
onCancel={() => setExportModalVisible(false)}
selectedCount={selectedRowKeys.length}
currentPageCount={tickets.length}
totalCount={pagination.total}
/>
</div>
);
}
export default React.memo(TicketManagement);