feat(设备管理): 添加设备工单关联功能

实现设备与工单的关联功能,包括:
1. 后端添加设备工单列表接口
2. 前端API添加设备工单相关方法
3. 设备详情页增加工单标签页
4. 设备管理页添加查看/创建工单按钮
5. 工单管理页支持从设备跳转查看/创建工单
This commit is contained in:
zhang1106
2026-02-12 13:27:32 +08:00
parent 43af020877
commit 0b00bf6f3d
5 changed files with 428 additions and 9 deletions
+38
View File
@@ -950,6 +950,44 @@ router.get('/:deviceId', async (req, res) => {
}
});
// 获取设备的工单列表
router.get('/:deviceId/tickets', async (req, res) => {
try {
const { deviceId } = req.params;
const { status, page = 1, pageSize = 10 } = req.query;
const offset = (page - 1) * pageSize;
// 检查设备是否存在
const device = await Device.findByPk(deviceId);
if (!device) {
return res.status(404).json({ error: '设备不存在' });
}
// 构建查询条件
const where = { deviceId };
if (status && status !== 'all') {
where.status = status;
}
// 查询工单列表
const { count, rows: tickets } = await Ticket.findAndCountAll({
where,
order: [['createdAt', 'DESC']],
offset: parseInt(offset),
limit: parseInt(pageSize)
});
res.json({
data: tickets,
total: count,
page: parseInt(page),
pageSize: parseInt(pageSize)
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 更新设备
router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
try {
+9
View File
@@ -125,6 +125,15 @@ export const operationLogAPI = {
clear: data => api.delete('/operation-logs', { data }),
};
export const deviceAPI = {
list: params => api.get('/devices', { params }),
get: deviceId => api.get(`/devices/${deviceId}`),
create: data => api.post('/devices', data),
update: (deviceId, data) => api.put(`/devices/${deviceId}`, data),
delete: deviceId => api.delete(`/devices/${deviceId}`),
getTickets: (deviceId, params) => api.get(`/devices/${deviceId}/tickets`, { params }),
};
export const ticketAPI = {
list: params => api.get('/tickets', { params }),
get: ticketId => api.get(`/tickets/${ticketId}`),
+153 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Drawer,
Tabs,
@@ -10,6 +10,8 @@ import {
Tooltip,
Button,
Popconfirm,
Table,
Badge,
} from 'antd';
import {
ApiOutlined,
@@ -18,8 +20,12 @@ import {
EditOutlined,
PlusCircleOutlined,
DeleteOutlined,
FileTextOutlined,
ToolOutlined,
EyeOutlined,
} from '@ant-design/icons';
import NetworkCardPanel from './NetworkCardPanel';
import { deviceAPI } from '../api';
const { Text, Title } = Typography;
@@ -49,8 +55,121 @@ function DeviceDetailDrawer({
onDeleteCable,
tooltipFields,
refreshTrigger,
onViewTicket,
onCreateTicket,
}) {
const [activeTab, setActiveTab] = useState('ports');
const [tickets, setTickets] = useState([]);
const [ticketsLoading, setTicketsLoading] = useState(false);
const [ticketsPagination, setTicketsPagination] = useState({
current: 1,
pageSize: 5,
total: 0,
});
// 获取设备关联的工单列表
const fetchDeviceTickets = useCallback(async (page = 1, pageSize = 5) => {
if (!device?.deviceId) return;
setTicketsLoading(true);
try {
const response = await deviceAPI.getTickets(device.deviceId, {
page,
pageSize,
});
setTickets(response.data || []);
setTicketsPagination({
current: response.page || 1,
pageSize: response.pageSize || 5,
total: response.total || 0,
});
} catch (error) {
console.error('获取设备工单失败:', error);
} finally {
setTicketsLoading(false);
}
}, [device?.deviceId]);
// 当设备变化或标签页切换到工单时,加载工单数据
useEffect(() => {
if (visible && device?.deviceId && activeTab === 'tickets') {
fetchDeviceTickets(1, 5);
}
}, [visible, device?.deviceId, activeTab, fetchDeviceTickets]);
// 工单表格列定义
const ticketColumns = useMemo(() => [
{
title: '工单编号',
dataIndex: 'ticketId',
key: 'ticketId',
width: 120,
render: (text) => <Text code>{text}</Text>,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status) => {
const statusConfig = {
pending: { color: 'warning', text: '待处理' },
processing: { color: 'processing', text: '处理中' },
completed: { color: 'success', text: '已完成' },
closed: { color: 'default', text: '已关闭' },
};
const config = statusConfig[status] || { color: 'default', text: status };
return <Badge status={config.color} text={config.text} />;
},
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 80,
render: (priority) => {
const priorityConfig = {
low: { color: 'success', text: '低' },
medium: { color: 'warning', text: '中' },
high: { color: 'error', text: '高' },
critical: { color: 'purple', text: '紧急' },
};
const config = priorityConfig[priority] || { color: 'default', text: priority };
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 150,
render: (date) => {
if (!date) return '-';
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
},
},
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => onViewTicket?.(record)}
>
查看
</Button>
),
},
], [onViewTicket]);
const deviceCables = useMemo(() => {
if (!device || !cables) return [];
@@ -249,6 +368,39 @@ function DeviceDetailDrawer({
</div>
),
},
{
key: 'tickets',
label: (
<span>
<FileTextOutlined />
工单记录 ({ticketsPagination.total})
</span>
),
children: (
<div className="tickets-panel">
<div style={{ marginBottom: designTokens.spacing.md }}>
<Button
type="primary"
icon={<ToolOutlined />}
onClick={() => onCreateTicket?.(device)}
>
创建工单
</Button>
</div>
<Table
columns={ticketColumns}
dataSource={tickets}
rowKey="ticketId"
loading={ticketsLoading}
pagination={{
...ticketsPagination,
onChange: (page, pageSize) => fetchDeviceTickets(page, pageSize),
}}
size="small"
/>
</div>
),
},
];
if (!device) return null;
+30
View File
@@ -40,6 +40,7 @@ import {
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { designTokens } from '../config/theme';
import {
PAGINATION_CONFIG,
@@ -221,6 +222,7 @@ const ResizableTitle = props => {
};
function DeviceManagement() {
const navigate = useNavigate();
const [devices, setDevices] = useState([]);
const [allDevices, setAllDevices] = useState([]);
const [racks, setRacks] = useState([]);
@@ -756,6 +758,16 @@ function DeviceManagement() {
setDetailModalVisible(true);
};
// 查看设备关联的工单
const handleViewDeviceTickets = device => {
navigate(`/tickets?deviceId=${device.deviceId}&deviceName=${encodeURIComponent(device.name)}&serialNumber=${encodeURIComponent(device.serialNumber || '')}&view=true`);
};
// 为设备创建工单
const handleCreateTicketForDevice = device => {
navigate(`/tickets?deviceId=${device.deviceId}&deviceName=${encodeURIComponent(device.name)}&serialNumber=${encodeURIComponent(device.serialNumber || '')}&create=true`);
};
// 打开批量状态变更模态框
const showBatchStatusModal = () => {
if (selectedDevices.length === 0) {
@@ -2163,6 +2175,24 @@ function DeviceManagement() {
>
关闭
</Button>,
<Button
key="viewTickets"
onClick={() => {
handleViewDeviceTickets(selectedDevice);
}}
style={secondaryActionStyle}
>
查看工单
</Button>,
<Button
key="createTicket"
onClick={() => {
handleCreateTicketForDevice(selectedDevice);
}}
style={secondaryActionStyle}
>
创建工单
</Button>,
<Button
key="edit"
type="primary"
+198 -8
View File
@@ -35,9 +35,14 @@ import {
ClockCircleOutlined,
CloseCircleOutlined,
SettingOutlined,
CloudServerOutlined,
DatabaseOutlined,
EnvironmentOutlined,
TagOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useSearchParams } from 'react-router-dom';
// 安全地从 localStorage 获取用户信息
const getUserFromStorage = () => {
@@ -225,6 +230,7 @@ const generateTicketId = () => {
};
function TicketManagement() {
const [searchParams] = useSearchParams();
const [tickets, setTickets] = useState([]);
const [devices, setDevices] = useState([]);
const [categories, setCategories] = useState([]);
@@ -239,6 +245,13 @@ function TicketManagement() {
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,
@@ -291,7 +304,7 @@ function TicketManagement() {
const fetchDevices = useCallback(async () => {
try {
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
const response = await axios.get('/api/devices', { params: { pageSize: 100 } });
setDevices(response.data.devices || []);
} catch (error) {
console.error('获取设备列表失败:', error);
@@ -333,9 +346,46 @@ function TicketManagement() {
useEffect(() => {
fetchTickets();
fetchDevices();
fetchCategories();
fetchTicketFields();
}, [fetchTickets, fetchDevices, fetchCategories, fetchTicketFields]);
fetchTicketFields().then(() => {
// 在 ticketFields 加载完成后再加载分类数据
fetchCategories();
});
}, [fetchTickets, fetchDevices, fetchTicketFields, fetchCategories]);
// 处理从设备详情页跳转过来创建工单的情况
useEffect(() => {
if (urlDeviceId && devices.length > 0 && urlCreate === 'true') {
// 自动打开创建工单弹窗
setEditingTicket(null);
setDeviceSource('select');
setModalVisible(true);
// 填充设备信息 - 使用 setTimeout 确保弹窗打开后再设置表单值
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 => {
@@ -740,9 +790,35 @@ function TicketManagement() {
</Form.Item>
);
}
// 检查 ticketFields 是否包含 deviceId
const hasDeviceIdField = ticketFields.some(f => f.fieldName === 'deviceId');
const hasDeviceNameField = ticketFields.some(f => f.fieldName === 'deviceName');
ticketFields.forEach(field => {
if (field.fieldName === 'ticketId' || field.fieldName === 'deviceId') {
if (field.fieldName === 'deviceId') {
if (field.fieldName === 'ticketId') {
// 已处理
} else if (field.fieldName === 'deviceId') {
// 渲染设备选择器
items.push(
<React.Fragment key="deviceSource">
<Form.Item label="设备来源" required>
<Select
value={deviceSource}
onChange={handleDeviceSourceChange}
style={{ width: 200 }}
>
<Option value="select">从设备管理选择</Option>
<Option value="manual">手动输入</Option>
</Select>
</Form.Item>
{renderDeviceFormItems()}
</React.Fragment>
);
} else if (field.fieldName === 'deviceName' || field.fieldName === 'serialNumber') {
// 如果存在 deviceId 字段,这些字段会在 renderDeviceFormItems 中处理
// 如果不存在 deviceId 字段但存在 deviceName,则显示手动输入模式
if (!hasDeviceIdField && field.fieldName === 'deviceName') {
items.push(
<React.Fragment key="deviceSource">
<Form.Item label="设备来源" required>
@@ -763,6 +839,26 @@ function TicketManagement() {
items.push(renderFormItem(field));
}
});
// 如果 ticketFields 中既没有 deviceId 也没有 deviceName,则手动添加设备选择器
if (!hasDeviceIdField && !hasDeviceNameField) {
items.push(
<React.Fragment key="deviceSource">
<Form.Item label="设备来源" required>
<Select
value={deviceSource}
onChange={handleDeviceSourceChange}
style={{ width: 200 }}
>
<Option value="select">从设备管理选择</Option>
<Option value="manual">手动输入</Option>
</Select>
</Form.Item>
{renderDeviceFormItems()}
</React.Fragment>
);
}
return items;
}, [
ticketFields,
@@ -993,7 +1089,14 @@ function TicketManagement() {
>
{selectedTicket && (
<Tabs defaultActiveKey="info">
<TabPane tab="基本信息" key="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>
@@ -1042,7 +1145,94 @@ function TicketManagement() {
</Descriptions>
</TabPane>
<TabPane tab={`操作记录 (${operationRecords.length})`} key="operations">
{selectedTicket?.Device && (
<TabPane
tab={
<span>
<CloudServerOutlined /> 设备信息
</span>
}
key="device"
>
<Descriptions bordered column={2}>
<Descriptions.Item label="设备ID">
{selectedTicket.Device.deviceId}
</Descriptions.Item>
<Descriptions.Item label="设备名称">
{selectedTicket.Device.name}
</Descriptions.Item>
<Descriptions.Item label="设备类型">
{selectedTicket.Device.type}
</Descriptions.Item>
<Descriptions.Item label="型号">
{selectedTicket.Device.model || '-'}
</Descriptions.Item>
<Descriptions.Item label="序列号">
{selectedTicket.Device.serialNumber || '-'}
</Descriptions.Item>
<Descriptions.Item label="品牌">
{selectedTicket.Device.brand || '-'}
</Descriptions.Item>
<Descriptions.Item label="所在机房">
{selectedTicket.Device.roomName || '-'}
</Descriptions.Item>
<Descriptions.Item label="所在机柜">
{selectedTicket.Device.rackName || '-'}
</Descriptions.Item>
<Descriptions.Item label="位置(U)">
{selectedTicket.Device.position || '-'}
</Descriptions.Item>
<Descriptions.Item label="高度(U)">
{selectedTicket.Device.height || '-'}
</Descriptions.Item>
<Descriptions.Item label="IP地址">
{selectedTicket.Device.ipAddress || '-'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<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>
</Descriptions.Item>
<Descriptions.Item label="购买日期">
{selectedTicket.Device.purchaseDate
? dayjs(selectedTicket.Device.purchaseDate).format('YYYY-MM-DD')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="保修到期">
{selectedTicket.Device.warrantyDate
? dayjs(selectedTicket.Device.warrantyDate).format('YYYY-MM-DD')
: '-'}
</Descriptions.Item>
</Descriptions>
</TabPane>
)}
<TabPane
tab={
<span>
<ClockCircleOutlined /> 操作记录 ({operationRecords.length})
</span>
}
key="operations"
>
<Timeline mode="left">
{operationRecords.map((record, index) => (
<Timeline.Item