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
+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