feat(设备管理): 添加设备工单关联功能
实现设备与工单的关联功能,包括: 1. 后端添加设备工单列表接口 2. 前端API添加设备工单相关方法 3. 设备详情页增加工单标签页 4. 设备管理页添加查看/创建工单按钮 5. 工单管理页支持从设备跳转查看/创建工单
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user