From 4e8d9be864d0b7adf140b2d76dee744677c0630d Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Tue, 24 Mar 2026 17:10:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E8=AE=BE=E5=A4=87=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(工单管理): 重构设备选择表单并优化UI体验 fix(工单统计): 修复每日完成工单统计不准确的问题 perf(后端): 添加批量导出接口优化大数据量查询性能 chore: 添加react-transition-group依赖支持动画效果 feat(设备管理): 添加设备全量查询接口支持导出功能 style(工单管理): 优化表单布局和样式提升用户体验 refactor(工单字段): 优化字段管理组件处理空值情况 fix(机柜管理): 修复机柜列表查询接口参数问题 docs: 移除不再使用的工单字段管理页面 --- backend/routes/consumables.js | 49 + backend/routes/devices.js | 61 ++ backend/routes/racks.js | 53 + backend/routes/tickets.js | 60 +- frontend/package-lock.json | 27 + frontend/package.json | 1 + frontend/src/App.jsx | 7 - .../components/device/DeviceDetailModal.jsx | 916 ++++++++++++++---- frontend/src/pages/ConsumableManagement.jsx | 4 +- frontend/src/pages/DeviceManagement.jsx | 4 +- frontend/src/pages/TicketFieldManagement.jsx | 14 +- frontend/src/pages/TicketManagement.jsx | 670 ++++++++++--- frontend/src/pages/TicketStatistics.jsx | 60 +- 13 files changed, 1559 insertions(+), 367 deletions(-) diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index fbd50de..e3d9524 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -60,6 +60,55 @@ router.get('/', async (req, res) => { } }); +const MAX_EXPORT_SIZE = 50000; + +router.get('/export', async (req, res) => { + try { + const { keyword, category, status } = req.query; + + const where = {}; + + if (keyword) { + where[Op.or] = [ + { consumableId: { [Op.like]: `%${keyword}%` } }, + { name: { [Op.like]: `%${keyword}%` } }, + { category: { [Op.like]: `%${keyword}%` } }, + { supplier: { [Op.like]: `%${keyword}%` } }, + { location: { [Op.like]: `%${keyword}%` } } + ]; + } + + if (category && category !== 'all') { + where.category = category; + } + + if (status && status !== 'all') { + where.status = status; + } + + const consumables = await Consumable.findAll({ + where, + limit: MAX_EXPORT_SIZE, + order: [['createdAt', 'DESC']] + }); + + const result = consumables.map(item => { + const data = item.toJSON(); + if (!Array.isArray(data.snList)) { + data.snList = []; + } + return data; + }); + + res.json({ + consumables: result, + total: result.length + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + router.post('/', async (req, res) => { const transaction = await sequelize.transaction(); try { diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 7fdd146..6cc42be 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -584,6 +584,67 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => { } }); +const MAX_EXPORT_SIZE = 50000; + +router.get('/all', async (req, res) => { + try { + const { keyword, status, type, rackId, roomId } = req.query; + + const where = {}; + + if (keyword) { + const escapedKeyword = keyword.replace(/'/g, "''"); + where[Op.or] = [ + { deviceId: { [Op.like]: `%${escapedKeyword}%` } }, + { name: { [Op.like]: `%${escapedKeyword}%` } }, + { type: { [Op.like]: `%${escapedKeyword}%` } }, + { model: { [Op.like]: `%${escapedKeyword}%` } }, + { serialNumber: { [Op.like]: `%${escapedKeyword}%` } }, + { ipAddress: { [Op.like]: `%${escapedKeyword}%` } } + ]; + } + + if (status && status !== 'all') { + where.status = status; + } + + if (type && type !== 'all') { + where.type = type; + } + + if (rackId) { + where.rackId = rackId; + } + + if (roomId && roomId !== 'all') { + where['$Rack.roomId$'] = roomId; + } + + const devices = await Device.findAll({ + where, + include: [ + { + model: Rack, + include: [{ model: Room }], + separate: false + } + ], + limit: MAX_EXPORT_SIZE, + order: [['createdAt', 'DESC']], + distinct: true, + subQuery: false + }); + + res.json({ + devices, + total: devices.length + }); + } catch (error) { + console.error('获取设备列表失败:', error); + res.status(500).json({ error: error.message }); + } +}); + // 生成设备ID的辅助函数 async function generateDeviceId() { // 获取当前最大的设备ID序号 diff --git a/backend/routes/racks.js b/backend/routes/racks.js index de124fc..17fd601 100644 --- a/backend/routes/racks.js +++ b/backend/routes/racks.js @@ -79,6 +79,59 @@ router.get('/', async (req, res) => { } }); +const MAX_EXPORT_SIZE = 50000; + +router.get('/all', async (req, res) => { + try { + const { roomId, status, keyword } = req.query; + + const where = {}; + if (roomId && roomId !== 'all') { + where.roomId = roomId; + } + if (status && status !== 'all') { + where.status = status; + } + if (keyword) { + where[require('sequelize').Op.or] = [ + { rackId: { [require('sequelize').Op.like]: `%${keyword}%` } }, + { name: { [require('sequelize').Op.like]: `%${keyword}%` } } + ]; + } + + const racks = await Rack.findAll({ + where, + include: [{ model: Room, separate: false }], + limit: MAX_EXPORT_SIZE + }); + + const rackIds = racks.map(r => r.rackId); + const devices = await Device.findAll({ + where: { rackId: rackIds }, + attributes: ['deviceId', 'rackId', 'name', 'powerConsumption', 'height'] + }); + + const deviceMap = {}; + devices.forEach(d => { + if (!deviceMap[d.rackId]) { + deviceMap[d.rackId] = []; + } + deviceMap[d.rackId].push(d); + }); + + racks.forEach(rack => { + rack.dataValues.Devices = deviceMap[rack.rackId] || []; + }); + + res.json({ + racks, + total: racks.length + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // 导出机柜导入模板 - 必须放在 /:rackId 路由之前,避免被当作 rackId 参数 router.get('/import-template', async (req, res) => { try { diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index 0f660d5..f3bcabc 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -23,7 +23,7 @@ router.get('/stats', async (req, res) => { const Sequelize = require('sequelize'); - const [total, statusStats, priorityStats, categoryStats, monthlyStats, deviceStats, dailyStats] = await Promise.all([ + const [total, statusStats, priorityStats, categoryStats, monthlyStats, deviceStats, dailyCreatedStats, dailyCompletedStats] = await Promise.all([ Ticket.count({ where }), Ticket.findAll({ where, @@ -68,11 +68,29 @@ router.get('/stats', async (req, res) => { Ticket.findAll({ where, attributes: [ - [Sequelize.fn('DATE', Sequelize.col('createdAt')), 'date'], + [dbDialect === 'mysql' + ? Sequelize.fn('DATE_FORMAT', Sequelize.col('createdAt'), '%Y-%m-%d') + : Sequelize.fn('date', Sequelize.col('createdAt')), + 'date'], [Sequelize.fn('COUNT', '*'), 'created'] ], group: ['date'], order: [['date', 'ASC']] + }), + Ticket.findAll({ + where: { + ...where, + status: 'completed' + }, + attributes: [ + [dbDialect === 'mysql' + ? Sequelize.fn('DATE_FORMAT', Sequelize.col('updatedAt'), '%Y-%m-%d') + : Sequelize.fn('date', Sequelize.col('updatedAt')), + 'date'], + [Sequelize.fn('COUNT', '*'), 'completed'] + ], + group: ['date'], + order: [['date', 'ASC']] }) ]); @@ -110,16 +128,42 @@ router.get('/stats', async (req, res) => { deviceType: '' })); - const trend = dailyStats.map(d => ({ - date: d.dataValues.date, - created: d.dataValues.created, - completed: 0, + const createdMap = {}; + dailyCreatedStats.forEach(d => { + createdMap[d.dataValues.date] = d.dataValues.created; + }); + const completedMap = {}; + dailyCompletedStats.forEach(d => { + completedMap[d.dataValues.date] = d.dataValues.completed; + }); + + const allDates = [...new Set([...Object.keys(createdMap), ...Object.keys(completedMap)])].sort(); + const trend = allDates.map(date => ({ + date, + created: createdMap[date] || 0, + completed: completedMap[date] || 0, closed: 0, inProgress: 0, pending: 0 })); - const avgProcessingTime = 0; + const completedTickets = await Ticket.findAll({ + where: { + ...where, + status: 'completed' + }, + attributes: ['createdAt', 'updatedAt'] + }); + + let avgProcessingTime = 0; + if (completedTickets.length > 0) { + const totalProcessingTime = completedTickets.reduce((sum, ticket) => { + const created = new Date(ticket.createdAt); + const updated = new Date(ticket.updatedAt); + return sum + (updated - created); + }, 0); + avgProcessingTime = (totalProcessingTime / completedTickets.length / (1000 * 60 * 60)).toFixed(1); + } res.json({ total, @@ -127,7 +171,7 @@ router.get('/stats', async (req, res) => { inProgress, completed, closed, - avgProcessingTime, + avgProcessingTime: parseFloat(avgProcessingTime), byStatus, byPriority, byCategory, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c01e31d..2cbb06c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", + "react-transition-group": "^4.4.5", "styled-components": "^6.3.9", "swr": "^2.4.0", "three": "^0.183.2", @@ -3698,6 +3699,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -6975,6 +6986,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/react-use-measure": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5c0a394..975f50e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", + "react-transition-group": "^4.4.5", "styled-components": "^6.3.9", "swr": "^2.4.0", "three": "^0.183.2", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5137d73..508d9d0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -70,7 +70,6 @@ const Login = lazy(() => import('./pages/Login')); const TicketManagement = lazy(() => import('./pages/TicketManagement')); const TicketCategoryManagement = lazy(() => import('./pages/TicketCategoryManagement')); const TicketStatistics = lazy(() => import('./pages/TicketStatistics')); -const TicketFieldManagement = lazy(() => import('./pages/TicketFieldManagement')); const SystemSettings = lazy(() => import('./pages/SystemSettings')); const CableManagement = lazy(() => import('./pages/CableManagement')); const PortManagement = lazy(() => import('./pages/PortManagement')); @@ -321,11 +320,6 @@ const AppLayout = ({ children }) => { icon: , label: 统计报表, }, - { - key: 'ticket-fields', - icon: , - label: 字段管理, - }, ], }, { @@ -599,7 +593,6 @@ const routeConfig = [ { path: '/tickets', component: TicketManagement }, { path: '/ticket-categories', component: TicketCategoryManagement }, { path: '/ticket-statistics', component: TicketStatistics }, - { path: '/ticket-fields', component: TicketFieldManagement }, { path: '/settings', component: SystemSettings }, { path: '/cables', component: CableManagement }, { path: '/inventory', component: InventoryManagement }, diff --git a/frontend/src/components/device/DeviceDetailModal.jsx b/frontend/src/components/device/DeviceDetailModal.jsx index 73754f5..040f5bb 100644 --- a/frontend/src/components/device/DeviceDetailModal.jsx +++ b/frontend/src/components/device/DeviceDetailModal.jsx @@ -1,23 +1,25 @@ -import React from 'react'; -import { Modal, Button, Card, Row, Col, Tag } from 'antd'; -import { AppstoreOutlined } from '@ant-design/icons'; +import React, { useEffect, useState } from 'react'; +import { Modal, Button, Row, Col, Tag, Progress } from 'antd'; +import { + AppstoreOutlined, + EnvironmentOutlined, + ThunderboltOutlined, + CalendarOutlined, + ExclamationCircleOutlined, + ClockCircleOutlined, + EditOutlined, + FileTextOutlined, + PlusOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, + StopOutlined, + DesktopOutlined, +} from '@ant-design/icons'; import { designTokens } from '../../config/theme'; import { getStatusConfig, getTypeLabel, getDeviceTypeIcon } from '../../utils/deviceUtils.jsx'; -const modalHeaderStyle = { - display: 'flex', - alignItems: 'center', - gap: '8px', - fontSize: '18px', - fontWeight: 600, -}; - -const secondaryActionStyle = { - height: '40px', - borderRadius: designTokens.borderRadius.small, - border: `1px solid ${designTokens.colors.border.light}`, - fontWeight: '500', -}; +const { colors, shadows, borderRadius, transitions, spacing } = designTokens; const DeviceDetailModal = ({ visible, @@ -28,225 +30,723 @@ const DeviceDetailModal = ({ onViewTickets, onCreateTicket, }) => { + const [isVisible, setIsVisible] = useState(false); + const [activeTab, setActiveTab] = useState('basic'); + + useEffect(() => { + if (visible) { + setIsVisible(true); + } else { + const timer = setTimeout(() => setIsVisible(false), 300); + return () => clearTimeout(timer); + } + }, [visible]); + if (!device) return null; + const getStatusIcon = (status) => { + const iconMap = { + running: , + maintenance: , + offline: , + fault: , + idle: , + }; + return iconMap[status] || ; + }; + + const getDeviceTypeColor = (type) => { + return colors.device[type] || colors.device.other; + }; + + const isWarrantyExpired = device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date(); + const warrantyDaysLeft = device.warrantyExpiry + ? Math.ceil((new Date(device.warrantyExpiry) - new Date()) / (1000 * 60 * 60 * 24)) + : null; + + const InfoCard = ({ title, icon, children, className = '' }) => ( +
+
+ {icon} + + {title} + +
+
{children}
+
+ ); + + const InfoItem = ({ label, value, status, copyable = false, fullWidth = false }) => { + const renderValue = () => { + if (status === 'warning') { + return ( + + + {value} + + ); + } + if (status === 'danger') { + return ( + + + {value} + + ); + } + return {value || '-'}; + }; + + return ( +
+
+ {label} +
+
+ {renderValue()} +
+
+ ); + }; + + const tabItems = [ + { key: 'basic', label: '基本信息' }, + { key: 'location', label: '位置信息' }, + { key: 'maintenance', label: '维保信息' }, + ]; + return ( - - 设备详情 - - } open={visible} onCancel={onClose} - footer={[ - , - , - , - , - ]} - width={700} - destroyOnHidden + footer={null} + width={800} + destroyOnClose + centered + className="device-detail-modal" styles={{ - header: { - borderBottom: '1px solid #f0f0f0', - padding: '16px 24px', - position: 'relative', + mask: { + backdropFilter: 'blur(4px)', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + }, + content: { + padding: 0, + borderRadius: borderRadius.xl, + overflow: 'hidden', + boxShadow: shadows.xl, }, - body: { padding: '0', overflow: 'auto' }, }} > -
+ + +
-
-
+ /> +
+ +
+
+
{getDeviceTypeIcon(device.type)}
-
-
+
+ +
+
+

{device.name} -

-
- {getTypeLabel(device.type)} - | - {device.deviceId} - | + + {device.status && ( - {device.status ? getStatusConfig(device.status).text : '-'} + {getStatusIcon(device.status)} + {getStatusConfig(device.status).text} -
+ )} +
+ +
+ + + {getTypeLabel(device.type)} + + + {device.deviceId} + + {device.model && ( + + {device.model} + + )}
+
-
- 基本信息} - style={{ marginBottom: '16px', borderRadius: '8px' }} - > - - -
设备型号
-
{device.model || '-'}
- - -
序列号
-
{device.serialNumber || '-'}
- - -
IP地址
-
{device.ipAddress || '-'}
- - -
所在机房
-
{device.Rack?.Room?.name || '-'}
- - -
所在机柜
-
{device.Rack?.name || '-'}
- - -
位置(U)
-
U{device.position || '-'}
- - -
高度
-
{device.height ? `${device.height}U` : '-'}
- - -
功率
-
{device.powerConsumption ? `${device.powerConsumption}W` : '-'}
- - -
状态
-
- {device.status ? getStatusConfig(device.status).text : '-'} -
- -
-
- - 维保信息} - style={{ marginBottom: '16px', borderRadius: '8px' }} - > - - -
购买日期
-
- {device.purchaseDate - ? new Date(device.purchaseDate).toLocaleDateString('zh-CN') - : '-'} -
- - -
保修到期
-
- {device.warrantyExpiry - ? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN') - : '-'} -
- -
-
- - {device.description && ( - 描述} - style={{ marginBottom: '16px', borderRadius: '8px' }} +
+
+ {tabItems.map((tab) => ( + + ))} +
+
- {device.customFields && Object.keys(device.customFields).length > 0 && ( - 自定义字段} - style={{ borderRadius: '8px' }} +
+ {activeTab === 'basic' && ( + } + style={{ animation: 'fadeInUp 0.3s ease-out' }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {device.description && ( +
+ +
+ )} + {device.customFields && Object.keys(device.customFields).length > 0 && ( +
+
+ 自定义字段 +
+ + {Object.entries(device.customFields).map(([key, value]) => { + const fieldConfig = deviceFields.find((f) => f.fieldName === key); + const displayName = fieldConfig?.displayName || key; + return ( + +
+
+ {displayName} +
+
+ {String(value)} +
+
+ + ); + })} +
+
+ )} +
+ )} + + {activeTab === 'location' && ( + } + style={{ animation: 'fadeInUp 0.3s ease-out' }} + > + + + + + + + + + + + + + + + + )} + + {activeTab === 'maintenance' && ( + <> + } + style={{ animation: 'fadeInUp 0.3s ease-out', marginBottom: '20px' }} > - {Object.entries(device.customFields).map(([key, value]) => { - const fieldConfig = deviceFields.find((f) => f.fieldName === key); - const displayName = fieldConfig?.displayName || key; - return ( - -
- {displayName} -
-
{String(value)}
- - ); - })} + + + + + + + {warrantyDaysLeft !== null && warrantyDaysLeft > 0 && ( + +
+ 保修剩余天数 +
+ `${warrantyDaysLeft} 天`} + strokeColor={warrantyDaysLeft < 30 ? colors.error.main : colors.primary.main} + trailColor={colors.border.light} + size="small" + /> + + )}
- - )} + {isWarrantyExpired && ( +
+ + + 设备已过保,建议续保 + +
+ )} +
+ + )} +
+ +
+
+ + +
+
+ +
diff --git a/frontend/src/pages/ConsumableManagement.jsx b/frontend/src/pages/ConsumableManagement.jsx index 2a08c3b..b4a6b49 100644 --- a/frontend/src/pages/ConsumableManagement.jsx +++ b/frontend/src/pages/ConsumableManagement.jsx @@ -347,8 +347,8 @@ function ConsumableManagement() { const handleExport = async () => { try { - const response = await axios.get('/api/consumables', { - params: { keyword, category, status, pageSize: 1000 }, + const response = await axios.get('/api/consumables/export', { + params: { keyword, category, status }, }); const consumables = response.data.consumables; exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`); diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx index 8d9d1d2..48fed98 100644 --- a/frontend/src/pages/DeviceManagement.jsx +++ b/frontend/src/pages/DeviceManagement.jsx @@ -193,9 +193,7 @@ function DeviceManagement() { const fetchRacks = async () => { try { - const response = await axios.get('/api/racks', { - params: { pageSize: 1000 }, - }); + const response = await axios.get('/api/racks/all'); setRacks(response.data.racks || []); } catch (error) { message.error('获取机柜列表失败'); diff --git a/frontend/src/pages/TicketFieldManagement.jsx b/frontend/src/pages/TicketFieldManagement.jsx index 8aa8d0c..2e1669f 100644 --- a/frontend/src/pages/TicketFieldManagement.jsx +++ b/frontend/src/pages/TicketFieldManagement.jsx @@ -18,17 +18,19 @@ import CloseButton from '../components/CloseButton'; const { Option } = Select; -const OptionsEditor = ({ value = [], onChange }) => { +const OptionsEditor = ({ value, onChange }) => { + const options = Array.isArray(value) ? value : []; + const handleAdd = () => { - onChange([...value, { value: '', label: '' }]); + onChange([...options, { value: '', label: '' }]); }; const handleRemove = index => { - onChange(value.filter((_, i) => i !== index)); + onChange(options.filter((_, i) => i !== index)); }; const handleUpdate = (index, field, fieldValue) => { - const newOptions = value.map((opt, i) => + const newOptions = options.map((opt, i) => i === index ? { ...opt, [field]: fieldValue } : opt ); onChange(newOptions); @@ -62,7 +64,7 @@ const OptionsEditor = ({ value = [], onChange }) => {
- {value.length === 0 ? ( + {options.length === 0 ? (
{
)} - {value.length > 0 && ( + {options.length > 0 && (
+ } open={modalVisible} closeIcon={} onCancel={handleCancel} footer={null} - width={700} + width={800} + destroyOnClose + style={{ top: 40 }} + bodyStyle={{ padding: '24px 24px 8px 24px' }} > -
- {renderFormItems()} - - - - - - + + {!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: '请输入故障描述' }]} + > +