feat: 添加工单统计自动刷新功能并优化设备选择逻辑
refactor(工单管理): 重构设备选择表单并优化UI体验 fix(工单统计): 修复每日完成工单统计不准确的问题 perf(后端): 添加批量导出接口优化大数据量查询性能 chore: 添加react-transition-group依赖支持动画效果 feat(设备管理): 添加设备全量查询接口支持导出功能 style(工单管理): 优化表单布局和样式提升用户体验 refactor(工单字段): 优化字段管理组件处理空值情况 fix(机柜管理): 修复机柜列表查询接口参数问题 docs: 移除不再使用的工单字段管理页面
This commit is contained in:
@@ -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) => {
|
router.post('/', async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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的辅助函数
|
// 生成设备ID的辅助函数
|
||||||
async function generateDeviceId() {
|
async function generateDeviceId() {
|
||||||
// 获取当前最大的设备ID序号
|
// 获取当前最大的设备ID序号
|
||||||
|
|||||||
@@ -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 参数
|
// 导出机柜导入模板 - 必须放在 /:rackId 路由之前,避免被当作 rackId 参数
|
||||||
router.get('/import-template', async (req, res) => {
|
router.get('/import-template', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
const Sequelize = require('sequelize');
|
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.count({ where }),
|
||||||
Ticket.findAll({
|
Ticket.findAll({
|
||||||
where,
|
where,
|
||||||
@@ -68,11 +68,29 @@ router.get('/stats', async (req, res) => {
|
|||||||
Ticket.findAll({
|
Ticket.findAll({
|
||||||
where,
|
where,
|
||||||
attributes: [
|
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']
|
[Sequelize.fn('COUNT', '*'), 'created']
|
||||||
],
|
],
|
||||||
group: ['date'],
|
group: ['date'],
|
||||||
order: [['date', 'ASC']]
|
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: ''
|
deviceType: ''
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const trend = dailyStats.map(d => ({
|
const createdMap = {};
|
||||||
date: d.dataValues.date,
|
dailyCreatedStats.forEach(d => {
|
||||||
created: d.dataValues.created,
|
createdMap[d.dataValues.date] = d.dataValues.created;
|
||||||
completed: 0,
|
});
|
||||||
|
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,
|
closed: 0,
|
||||||
inProgress: 0,
|
inProgress: 0,
|
||||||
pending: 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({
|
res.json({
|
||||||
total,
|
total,
|
||||||
@@ -127,7 +171,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
inProgress,
|
inProgress,
|
||||||
completed,
|
completed,
|
||||||
closed,
|
closed,
|
||||||
avgProcessingTime,
|
avgProcessingTime: parseFloat(avgProcessingTime),
|
||||||
byStatus,
|
byStatus,
|
||||||
byPriority,
|
byPriority,
|
||||||
byCategory,
|
byCategory,
|
||||||
|
|||||||
Generated
+27
@@ -19,6 +19,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.15.0",
|
||||||
|
"react-transition-group": "^4.4.5",
|
||||||
"styled-components": "^6.3.9",
|
"styled-components": "^6.3.9",
|
||||||
"swr": "^2.4.0",
|
"swr": "^2.4.0",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
@@ -3698,6 +3699,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/draco3d": {
|
||||||
"version": "1.5.7",
|
"version": "1.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||||
@@ -6975,6 +6986,22 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/react-use-measure": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.15.0",
|
||||||
|
"react-transition-group": "^4.4.5",
|
||||||
"styled-components": "^6.3.9",
|
"styled-components": "^6.3.9",
|
||||||
"swr": "^2.4.0",
|
"swr": "^2.4.0",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ const Login = lazy(() => import('./pages/Login'));
|
|||||||
const TicketManagement = lazy(() => import('./pages/TicketManagement'));
|
const TicketManagement = lazy(() => import('./pages/TicketManagement'));
|
||||||
const TicketCategoryManagement = lazy(() => import('./pages/TicketCategoryManagement'));
|
const TicketCategoryManagement = lazy(() => import('./pages/TicketCategoryManagement'));
|
||||||
const TicketStatistics = lazy(() => import('./pages/TicketStatistics'));
|
const TicketStatistics = lazy(() => import('./pages/TicketStatistics'));
|
||||||
const TicketFieldManagement = lazy(() => import('./pages/TicketFieldManagement'));
|
|
||||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'));
|
const SystemSettings = lazy(() => import('./pages/SystemSettings'));
|
||||||
const CableManagement = lazy(() => import('./pages/CableManagement'));
|
const CableManagement = lazy(() => import('./pages/CableManagement'));
|
||||||
const PortManagement = lazy(() => import('./pages/PortManagement'));
|
const PortManagement = lazy(() => import('./pages/PortManagement'));
|
||||||
@@ -321,11 +320,6 @@ const AppLayout = ({ children }) => {
|
|||||||
icon: <BarChartOutlined style={{ fontSize: '16px' }} />,
|
icon: <BarChartOutlined style={{ fontSize: '16px' }} />,
|
||||||
label: <Link to="/ticket-statistics">统计报表</Link>,
|
label: <Link to="/ticket-statistics">统计报表</Link>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'ticket-fields',
|
|
||||||
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
|
||||||
label: <Link to="/ticket-fields">字段管理</Link>,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -599,7 +593,6 @@ const routeConfig = [
|
|||||||
{ path: '/tickets', component: TicketManagement },
|
{ path: '/tickets', component: TicketManagement },
|
||||||
{ path: '/ticket-categories', component: TicketCategoryManagement },
|
{ path: '/ticket-categories', component: TicketCategoryManagement },
|
||||||
{ path: '/ticket-statistics', component: TicketStatistics },
|
{ path: '/ticket-statistics', component: TicketStatistics },
|
||||||
{ path: '/ticket-fields', component: TicketFieldManagement },
|
|
||||||
{ path: '/settings', component: SystemSettings },
|
{ path: '/settings', component: SystemSettings },
|
||||||
{ path: '/cables', component: CableManagement },
|
{ path: '/cables', component: CableManagement },
|
||||||
{ path: '/inventory', component: InventoryManagement },
|
{ path: '/inventory', component: InventoryManagement },
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Modal, Button, Card, Row, Col, Tag } from 'antd';
|
import { Modal, Button, Row, Col, Tag, Progress } from 'antd';
|
||||||
import { AppstoreOutlined } from '@ant-design/icons';
|
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 { designTokens } from '../../config/theme';
|
||||||
import { getStatusConfig, getTypeLabel, getDeviceTypeIcon } from '../../utils/deviceUtils.jsx';
|
import { getStatusConfig, getTypeLabel, getDeviceTypeIcon } from '../../utils/deviceUtils.jsx';
|
||||||
|
|
||||||
const modalHeaderStyle = {
|
const { colors, shadows, borderRadius, transitions, spacing } = designTokens;
|
||||||
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 DeviceDetailModal = ({
|
const DeviceDetailModal = ({
|
||||||
visible,
|
visible,
|
||||||
@@ -28,225 +30,723 @@ const DeviceDetailModal = ({
|
|||||||
onViewTickets,
|
onViewTickets,
|
||||||
onCreateTicket,
|
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;
|
if (!device) return null;
|
||||||
|
|
||||||
|
const getStatusIcon = (status) => {
|
||||||
|
const iconMap = {
|
||||||
|
running: <SyncOutlined spin style={{ color: colors.status.running }} />,
|
||||||
|
maintenance: <ClockCircleOutlined style={{ color: colors.status.maintenance }} />,
|
||||||
|
offline: <StopOutlined style={{ color: colors.status.offline }} />,
|
||||||
|
fault: <ExclamationCircleOutlined style={{ color: colors.status.fault }} />,
|
||||||
|
idle: <CheckCircleOutlined style={{ color: '#36cfc9' }} />,
|
||||||
|
};
|
||||||
|
return iconMap[status] || <DesktopOutlined style={{ color: colors.text.tertiary }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = '' }) => (
|
||||||
|
<div
|
||||||
|
className={`info-card ${className}`}
|
||||||
|
style={{
|
||||||
|
background: colors.background.primary,
|
||||||
|
borderRadius: borderRadius.large,
|
||||||
|
border: `1px solid ${colors.border.light}`,
|
||||||
|
boxShadow: shadows.small,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: `all ${transitions.normal}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderBottom: `1px solid ${colors.border.light}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: `linear-gradient(180deg, ${colors.background.secondary} 0%, ${colors.background.primary} 100%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: colors.primary.main, fontSize: '16px' }}>{icon}</span>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '15px', color: colors.text.primary }}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '20px' }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InfoItem = ({ label, value, status, copyable = false, fullWidth = false }) => {
|
||||||
|
const renderValue = () => {
|
||||||
|
if (status === 'warning') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: colors.warning.main,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExclamationCircleOutlined />
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'danger') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: colors.error.main,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseCircleOutlined />
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span style={{ fontWeight: 500 }}>{value || '-'}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
paddingBottom: '16px',
|
||||||
|
borderBottom: `1px dashed ${colors.border.light}`,
|
||||||
|
}}
|
||||||
|
className={fullWidth ? 'info-item-full' : ''}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.text.tertiary,
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderValue()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ key: 'basic', label: '基本信息' },
|
||||||
|
{ key: 'location', label: '位置信息' },
|
||||||
|
{ key: 'maintenance', label: '维保信息' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
|
||||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
|
||||||
<AppstoreOutlined style={{ color: '#667eea' }} />
|
|
||||||
设备详情
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
open={visible}
|
open={visible}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={[
|
footer={null}
|
||||||
<Button key="close" onClick={onClose} style={secondaryActionStyle}>
|
width={800}
|
||||||
关闭
|
destroyOnClose
|
||||||
</Button>,
|
centered
|
||||||
<Button key="viewTickets" onClick={() => onViewTickets(device)} style={secondaryActionStyle}>
|
className="device-detail-modal"
|
||||||
查看工单
|
|
||||||
</Button>,
|
|
||||||
<Button key="createTicket" onClick={() => onCreateTicket(device)} style={secondaryActionStyle}>
|
|
||||||
创建工单
|
|
||||||
</Button>,
|
|
||||||
<Button
|
|
||||||
key="edit"
|
|
||||||
type="primary"
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
onEdit(device);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: '40px',
|
|
||||||
borderRadius: designTokens.borderRadius.small,
|
|
||||||
background: designTokens.colors.primary.gradient,
|
|
||||||
border: 'none',
|
|
||||||
color: '#ffffff',
|
|
||||||
boxShadow: designTokens.shadows.small,
|
|
||||||
fontWeight: '500',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
width={700}
|
|
||||||
destroyOnHidden
|
|
||||||
styles={{
|
styles={{
|
||||||
header: {
|
mask: {
|
||||||
borderBottom: '1px solid #f0f0f0',
|
backdropFilter: 'blur(4px)',
|
||||||
padding: '16px 24px',
|
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||||
position: 'relative',
|
},
|
||||||
|
content: {
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: shadows.xl,
|
||||||
},
|
},
|
||||||
body: { padding: '0', overflow: 'auto' },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<style>{`
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .info-card:hover {
|
||||||
|
box-shadow: ${shadows.md};
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .info-item:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .tab-btn {
|
||||||
|
transition: all ${transitions.fast};
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .tab-btn:hover {
|
||||||
|
color: ${colors.primary.main};
|
||||||
|
background: ${colors.primary.bg};
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .tab-btn.active {
|
||||||
|
color: ${colors.primary.main};
|
||||||
|
border-bottom-color: ${colors.primary.main};
|
||||||
|
background: ${colors.primary.bg};
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .action-btn {
|
||||||
|
transition: all ${transitions.fast};
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .action-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: ${shadows.md};
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .device-icon-wrapper {
|
||||||
|
transition: all ${transitions.normal};
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .device-icon-wrapper:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail-modal .progress-bar {
|
||||||
|
transition: all ${transitions.slow};
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.device-detail-modal .ant-modal {
|
||||||
|
max-width: 95vw !important;
|
||||||
|
margin: 10px auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${colors.primary.main} 0%, ${colors.primary.dark} 50%, ${colors.purple.main} 100%)`,
|
||||||
|
padding: '28px 32px',
|
||||||
|
color: '#fff',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '24px',
|
position: 'absolute',
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
top: 0,
|
||||||
color: '#fff',
|
right: 0,
|
||||||
|
width: '200px',
|
||||||
|
height: '200px',
|
||||||
|
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%)',
|
||||||
|
borderRadius: '0 0 0 100%',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
position: 'absolute',
|
||||||
width: '64px',
|
bottom: '-50px',
|
||||||
height: '64px',
|
left: '20%',
|
||||||
borderRadius: '12px',
|
width: '100px',
|
||||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
height: '100px',
|
||||||
display: 'flex',
|
background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%)',
|
||||||
alignItems: 'center',
|
borderRadius: '50%',
|
||||||
justifyContent: 'center',
|
}}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '20px', position: 'relative', zIndex: 1 }}>
|
||||||
|
<div
|
||||||
|
className="device-icon-wrapper"
|
||||||
|
style={{
|
||||||
|
width: '72px',
|
||||||
|
height: '72px',
|
||||||
|
borderRadius: borderRadius.large,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: `0 8px 32px rgba(0, 0, 0, 0.2)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '32px', color: '#fff' }}>
|
||||||
{getDeviceTypeIcon(device.type)}
|
{getDeviceTypeIcon(device.type)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
</div>
|
||||||
<div style={{ fontSize: '24px', fontWeight: 600, marginBottom: '8px' }}>
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#fff',
|
||||||
|
textShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{device.name}
|
{device.name}
|
||||||
</div>
|
</h2>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', opacity: 0.9 }}>
|
{device.status && (
|
||||||
<span>{getTypeLabel(device.type)}</span>
|
|
||||||
<span>|</span>
|
|
||||||
<span>{device.deviceId}</span>
|
|
||||||
<span>|</span>
|
|
||||||
<Tag
|
<Tag
|
||||||
color={device.status ? getStatusConfig(device.status).badgeColor : 'default'}
|
style={{
|
||||||
style={{ margin: 0 }}
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: borderRadius.round,
|
||||||
|
padding: '2px 12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{device.status ? getStatusConfig(device.status).text : '-'}
|
{getStatusIcon(device.status)}
|
||||||
|
{getStatusConfig(device.status).text}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '16px',
|
||||||
|
opacity: 0.9,
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: borderRadius.round,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppstoreOutlined style={{ fontSize: '12px' }} />
|
||||||
|
{getTypeLabel(device.type)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: borderRadius.round,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '13px' }}>{device.deviceId}</span>
|
||||||
|
</span>
|
||||||
|
{device.model && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: borderRadius.round,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '20px 24px' }}>
|
<div style={{ padding: '0 24px', backgroundColor: colors.background.secondary }}>
|
||||||
<Card
|
<div
|
||||||
size="small"
|
style={{
|
||||||
title={<span style={{ fontWeight: 600 }}>基本信息</span>}
|
display: 'flex',
|
||||||
style={{ marginBottom: '16px', borderRadius: '8px' }}
|
gap: '8px',
|
||||||
>
|
paddingTop: '16px',
|
||||||
<Row gutter={[24, 16]}>
|
}}
|
||||||
<Col span={8}>
|
>
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>设备型号</div>
|
{tabItems.map((tab) => (
|
||||||
<div style={{ fontWeight: 500 }}>{device.model || '-'}</div>
|
<button
|
||||||
</Col>
|
key={tab.key}
|
||||||
<Col span={8}>
|
onClick={() => setActiveTab(tab.key)}
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>序列号</div>
|
className={`tab-btn ${activeTab === tab.key ? 'active' : ''}`}
|
||||||
<div style={{ fontWeight: 500 }}>{device.serialNumber || '-'}</div>
|
style={{
|
||||||
</Col>
|
padding: '10px 20px',
|
||||||
<Col span={8}>
|
border: 'none',
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>IP地址</div>
|
background: 'transparent',
|
||||||
<div style={{ fontWeight: 500 }}>{device.ipAddress || '-'}</div>
|
cursor: 'pointer',
|
||||||
</Col>
|
fontSize: '14px',
|
||||||
<Col span={8}>
|
fontWeight: 500,
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机房</div>
|
color: activeTab === tab.key ? colors.primary.main : colors.text.secondary,
|
||||||
<div style={{ fontWeight: 500 }}>{device.Rack?.Room?.name || '-'}</div>
|
borderRadius: borderRadius.medium,
|
||||||
</Col>
|
display: 'flex',
|
||||||
<Col span={8}>
|
alignItems: 'center',
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机柜</div>
|
gap: '8px',
|
||||||
<div style={{ fontWeight: 500 }}>{device.Rack?.name || '-'}</div>
|
}}
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>位置(U)</div>
|
|
||||||
<div style={{ fontWeight: 500 }}>U{device.position || '-'}</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>高度</div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{device.height ? `${device.height}U` : '-'}</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>功率</div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{device.powerConsumption ? `${device.powerConsumption}W` : '-'}</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>状态</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: 500,
|
|
||||||
color: device.status ? getStatusConfig(device.status).color : '#666',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{device.status ? getStatusConfig(device.status).text : '-'}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={<span style={{ fontWeight: 600 }}>维保信息</span>}
|
|
||||||
style={{ marginBottom: '16px', borderRadius: '8px' }}
|
|
||||||
>
|
|
||||||
<Row gutter={[24, 16]}>
|
|
||||||
<Col span={12}>
|
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>购买日期</div>
|
|
||||||
<div style={{ fontWeight: 500 }}>
|
|
||||||
{device.purchaseDate
|
|
||||||
? new Date(device.purchaseDate).toLocaleDateString('zh-CN')
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>保修到期</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight:
|
|
||||||
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
|
|
||||||
? 600
|
|
||||||
: 500,
|
|
||||||
color:
|
|
||||||
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
|
|
||||||
? '#d93025'
|
|
||||||
: '#333',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{device.warrantyExpiry
|
|
||||||
? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN')
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{device.description && (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={<span style={{ fontWeight: 600 }}>描述</span>}
|
|
||||||
style={{ marginBottom: '16px', borderRadius: '8px' }}
|
|
||||||
>
|
>
|
||||||
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{device.description}</div>
|
{tab.label}
|
||||||
</Card>
|
{activeTab === tab.key && (
|
||||||
)}
|
<span
|
||||||
|
style={{
|
||||||
|
width: '4px',
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{device.customFields && Object.keys(device.customFields).length > 0 && (
|
<div style={{ padding: '24px 32px', maxHeight: '480px', overflowY: 'auto' }}>
|
||||||
<Card
|
{activeTab === 'basic' && (
|
||||||
size="small"
|
<InfoCard
|
||||||
title={<span style={{ fontWeight: 600 }}>自定义字段</span>}
|
title="基本信息"
|
||||||
style={{ borderRadius: '8px' }}
|
icon={<AppstoreOutlined />}
|
||||||
|
style={{ animation: 'fadeInUp 0.3s ease-out' }}
|
||||||
|
>
|
||||||
|
<Row gutter={[24, 16]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="设备ID" value={device.deviceId} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="设备名称" value={device.name} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="设备类型" value={getTypeLabel(device.type)} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="设备型号" value={device.model} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="序列号" value={device.serialNumber} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="IP地址" value={device.ipAddress} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem
|
||||||
|
label="设备状态"
|
||||||
|
value={device.status ? getStatusConfig(device.status).text : '-'}
|
||||||
|
status={
|
||||||
|
device.status === 'fault'
|
||||||
|
? 'danger'
|
||||||
|
: device.status === 'maintenance'
|
||||||
|
? 'warning'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="功率消耗" value={device.powerConsumption ? `${device.powerConsumption}W` : '-'} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="设备高度" value={device.height ? `${device.height}U` : '-'} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{device.description && (
|
||||||
|
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: `1px dashed ${colors.border.light}` }}>
|
||||||
|
<InfoItem label="设备描述" value={device.description} fullWidth />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{device.customFields && Object.keys(device.customFields).length > 0 && (
|
||||||
|
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: `1px dashed ${colors.border.light}` }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.text.tertiary,
|
||||||
|
marginBottom: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
自定义字段
|
||||||
|
</div>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Object.entries(device.customFields).map(([key, value]) => {
|
||||||
|
const fieldConfig = deviceFields.find((f) => f.fieldName === key);
|
||||||
|
const displayName = fieldConfig?.displayName || key;
|
||||||
|
return (
|
||||||
|
<Col span={8} key={key}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: colors.background.secondary,
|
||||||
|
borderRadius: borderRadius.medium,
|
||||||
|
border: `1px solid ${colors.border.light}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.text.tertiary,
|
||||||
|
marginBottom: '4px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.text.primary,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'location' && (
|
||||||
|
<InfoCard
|
||||||
|
title="位置信息"
|
||||||
|
icon={<EnvironmentOutlined />}
|
||||||
|
style={{ animation: 'fadeInUp 0.3s ease-out' }}
|
||||||
|
>
|
||||||
|
<Row gutter={[24, 16]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="所在机房" value={device.Rack?.Room?.name} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="所在机柜" value={device.Rack?.name} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="机柜编号" value={device.Rack?.rackId} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<InfoItem label="安装位置" value={device.position ? `U${device.position}` : '-'} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</InfoCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'maintenance' && (
|
||||||
|
<>
|
||||||
|
<InfoCard
|
||||||
|
title="购买与维保信息"
|
||||||
|
icon={<CalendarOutlined />}
|
||||||
|
style={{ animation: 'fadeInUp 0.3s ease-out', marginBottom: '20px' }}
|
||||||
>
|
>
|
||||||
<Row gutter={[24, 16]}>
|
<Row gutter={[24, 16]}>
|
||||||
{Object.entries(device.customFields).map(([key, value]) => {
|
<Col span={8}>
|
||||||
const fieldConfig = deviceFields.find((f) => f.fieldName === key);
|
<InfoItem label="购买日期" value={device.purchaseDate ? new Date(device.purchaseDate).toLocaleDateString('zh-CN') : '-'} />
|
||||||
const displayName = fieldConfig?.displayName || key;
|
</Col>
|
||||||
return (
|
<Col span={8}>
|
||||||
<Col span={8} key={key}>
|
<InfoItem
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
|
label="保修到期"
|
||||||
{displayName}
|
value={device.warrantyExpiry ? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN') : '-'}
|
||||||
</div>
|
status={isWarrantyExpired ? 'danger' : undefined}
|
||||||
<div style={{ fontWeight: 500 }}>{String(value)}</div>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
);
|
{warrantyDaysLeft !== null && warrantyDaysLeft > 0 && (
|
||||||
})}
|
<Col span={24}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.text.tertiary,
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保修剩余天数
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(Math.round((warrantyDaysLeft / 365) * 100), 100)}
|
||||||
|
format={() => `${warrantyDaysLeft} 天`}
|
||||||
|
strokeColor={warrantyDaysLeft < 30 ? colors.error.main : colors.primary.main}
|
||||||
|
trailColor={colors.border.light}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
{isWarrantyExpired && (
|
||||||
)}
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: colors.error.bg,
|
||||||
|
borderRadius: borderRadius.medium,
|
||||||
|
border: `1px solid ${colors.error.main}30`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExclamationCircleOutlined style={{ color: colors.error.main, fontSize: '18px' }} />
|
||||||
|
<span style={{ color: colors.error.main, fontSize: '13px', fontWeight: 500 }}>
|
||||||
|
设备已过保,建议续保
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoCard>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px 32px',
|
||||||
|
borderTop: `1px solid ${colors.border.light}`,
|
||||||
|
background: colors.background.primary,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<Button
|
||||||
|
className="action-btn"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={() => onViewTickets(device)}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: borderRadius.medium,
|
||||||
|
border: `1px solid ${colors.border.light}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看工单
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="action-btn"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => onCreateTicket(device)}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: borderRadius.medium,
|
||||||
|
background: colors.primary.gradient,
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontWeight: 500,
|
||||||
|
boxShadow: shadows.small,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
创建工单
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: borderRadius.medium,
|
||||||
|
border: `1px solid ${colors.border.light}`,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="action-btn"
|
||||||
|
type="primary"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onEdit(device);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: borderRadius.medium,
|
||||||
|
background: colors.primary.gradient,
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontWeight: 500,
|
||||||
|
boxShadow: shadows.small,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑设备
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -347,8 +347,8 @@ function ConsumableManagement() {
|
|||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/consumables', {
|
const response = await axios.get('/api/consumables/export', {
|
||||||
params: { keyword, category, status, pageSize: 1000 },
|
params: { keyword, category, status },
|
||||||
});
|
});
|
||||||
const consumables = response.data.consumables;
|
const consumables = response.data.consumables;
|
||||||
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
|
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
|||||||
@@ -193,9 +193,7 @@ function DeviceManagement() {
|
|||||||
|
|
||||||
const fetchRacks = async () => {
|
const fetchRacks = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/racks', {
|
const response = await axios.get('/api/racks/all');
|
||||||
params: { pageSize: 1000 },
|
|
||||||
});
|
|
||||||
setRacks(response.data.racks || []);
|
setRacks(response.data.racks || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取机柜列表失败');
|
message.error('获取机柜列表失败');
|
||||||
|
|||||||
@@ -18,17 +18,19 @@ import CloseButton from '../components/CloseButton';
|
|||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
const OptionsEditor = ({ value = [], onChange }) => {
|
const OptionsEditor = ({ value, onChange }) => {
|
||||||
|
const options = Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
onChange([...value, { value: '', label: '' }]);
|
onChange([...options, { value: '', label: '' }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = index => {
|
const handleRemove = index => {
|
||||||
onChange(value.filter((_, i) => i !== index));
|
onChange(options.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = (index, field, fieldValue) => {
|
const handleUpdate = (index, field, fieldValue) => {
|
||||||
const newOptions = value.map((opt, i) =>
|
const newOptions = options.map((opt, i) =>
|
||||||
i === index ? { ...opt, [field]: fieldValue } : opt
|
i === index ? { ...opt, [field]: fieldValue } : opt
|
||||||
);
|
);
|
||||||
onChange(newOptions);
|
onChange(newOptions);
|
||||||
@@ -62,7 +64,7 @@ const OptionsEditor = ({ value = [], onChange }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{value.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
@@ -153,7 +155,7 @@ const OptionsEditor = ({ value = [], onChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{value.length > 0 && (
|
{options.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="dashed"
|
||||||
icon={<PlusCircleOutlined />}
|
icon={<PlusCircleOutlined />}
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ const DEFAULT_TICKET_FIELDS = [
|
|||||||
{ value: 'urgent', 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',
|
fieldName: 'description',
|
||||||
displayName: '故障描述',
|
displayName: '故障描述',
|
||||||
@@ -255,7 +269,7 @@ function TicketManagement() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [searchFilters, setSearchFilters] = useState({});
|
const [searchFilters, setSearchFilters] = useState({});
|
||||||
const [deviceSource, setDeviceSource] = useState('select');
|
const [manualDeviceSource, setManualDeviceSource] = useState(false);
|
||||||
const [ticketFields, setTicketFields] = useState(DEFAULT_TICKET_FIELDS);
|
const [ticketFields, setTicketFields] = useState(DEFAULT_TICKET_FIELDS);
|
||||||
const [loadingFields, setLoadingFields] = useState(true);
|
const [loadingFields, setLoadingFields] = useState(true);
|
||||||
const [deviceFields, setDeviceFields] = useState([]);
|
const [deviceFields, setDeviceFields] = useState([]);
|
||||||
@@ -302,11 +316,11 @@ function TicketManagement() {
|
|||||||
const fetchDevices = useCallback(async (keyword = '') => {
|
const fetchDevices = useCallback(async (keyword = '') => {
|
||||||
try {
|
try {
|
||||||
setDeviceSearching(true);
|
setDeviceSearching(true);
|
||||||
const params = { pageSize: 50 };
|
const params = {};
|
||||||
if (keyword && keyword.trim()) {
|
if (keyword && keyword.trim()) {
|
||||||
params.keyword = keyword.trim();
|
params.keyword = keyword.trim();
|
||||||
}
|
}
|
||||||
const response = await axios.get('/api/devices', { params });
|
const response = await axios.get('/api/devices/all', { params });
|
||||||
setDevices(response.data.devices || []);
|
setDevices(response.data.devices || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取设备列表失败:', error);
|
console.error('获取设备列表失败:', error);
|
||||||
@@ -344,8 +358,34 @@ function TicketManagement() {
|
|||||||
try {
|
try {
|
||||||
setLoadingFields(true);
|
setLoadingFields(true);
|
||||||
const response = await axios.get('/api/ticket-fields');
|
const response = await axios.get('/api/ticket-fields');
|
||||||
const sortedFields = response.data.sort((a, b) => a.order - b.order);
|
const dbFields = response.data.sort((a, b) => a.order - b.order);
|
||||||
setTicketFields(sortedFields);
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('获取工单字段配置失败:', error);
|
console.error('获取工单字段配置失败:', error);
|
||||||
setTicketFields(DEFAULT_TICKET_FIELDS);
|
setTicketFields(DEFAULT_TICKET_FIELDS);
|
||||||
@@ -378,12 +418,9 @@ function TicketManagement() {
|
|||||||
// 处理从设备详情页跳转过来创建工单的情况
|
// 处理从设备详情页跳转过来创建工单的情况
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlDeviceId && devices.length > 0 && urlCreate === 'true') {
|
if (urlDeviceId && devices.length > 0 && urlCreate === 'true') {
|
||||||
// 自动打开创建工单弹窗
|
|
||||||
setEditingTicket(null);
|
setEditingTicket(null);
|
||||||
setDeviceSource('select');
|
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
|
|
||||||
// 填充设备信息 - 使用 setTimeout 确保弹窗打开后再设置表单值
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
deviceId: urlDeviceId,
|
deviceId: urlDeviceId,
|
||||||
@@ -418,34 +455,35 @@ function TicketManagement() {
|
|||||||
let formItem;
|
let formItem;
|
||||||
switch (fieldType) {
|
switch (fieldType) {
|
||||||
case 'string':
|
case 'string':
|
||||||
formItem = <Input placeholder={placeholder || `请输入${displayName}`} />;
|
formItem = <Input placeholder={placeholder || `请输入${displayName}`} size="large" />;
|
||||||
break;
|
break;
|
||||||
case 'number':
|
case 'number':
|
||||||
formItem = (
|
formItem = (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
placeholder={placeholder || `请输入${displayName}`}
|
placeholder={placeholder || `请输入${displayName}`}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
size="large"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
formItem = (
|
formItem = (
|
||||||
<Input.TextArea rows={3} placeholder={placeholder || `请输入${displayName}`} />
|
<Input.TextArea rows={3} placeholder={placeholder || `请输入${displayName}`} showCount />
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
formItem = <Switch />;
|
formItem = <Switch />;
|
||||||
break;
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
formItem = <DatePicker style={{ width: '100%' }} />;
|
formItem = <DatePicker style={{ width: '100%' }} size="large" />;
|
||||||
break;
|
break;
|
||||||
case 'datetime':
|
case 'datetime':
|
||||||
formItem = <DatePicker showTime style={{ width: '100%' }} />;
|
formItem = <DatePicker showTime style={{ width: '100%' }} size="large" />;
|
||||||
break;
|
break;
|
||||||
case 'select':
|
case 'select':
|
||||||
const selectOptions = options && Array.isArray(options) ? options : [];
|
const selectOptions = options && Array.isArray(options) ? options : [];
|
||||||
formItem = (
|
formItem = (
|
||||||
<Select placeholder={placeholder || `请选择${displayName}`}>
|
<Select placeholder={placeholder || `请选择${displayName}`} size="large">
|
||||||
{selectOptions.map((opt, idx) => (
|
{selectOptions.map((opt, idx) => (
|
||||||
<Option key={idx} value={opt.value}>
|
<Option key={idx} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -469,6 +507,7 @@ function TicketManagement() {
|
|||||||
fetchDevices();
|
fetchDevices();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
size="large"
|
||||||
>
|
>
|
||||||
{devices.map(device => (
|
{devices.map(device => (
|
||||||
<Option key={device.deviceId} value={device.deviceId}>
|
<Option key={device.deviceId} value={device.deviceId}>
|
||||||
@@ -479,11 +518,17 @@ function TicketManagement() {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
formItem = <Input placeholder={placeholder || `请输入${displayName}`} />;
|
formItem = <Input placeholder={placeholder || `请输入${displayName}`} size="large" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item key={fieldName} name={fieldName} label={displayName} rules={rules}>
|
<Form.Item
|
||||||
|
key={fieldName}
|
||||||
|
name={fieldName}
|
||||||
|
label={<span style={{ fontWeight: 500 }}>{displayName}</span>}
|
||||||
|
rules={rules}
|
||||||
|
valuePropName={fieldType === 'boolean' ? 'checked' : undefined}
|
||||||
|
>
|
||||||
{formItem}
|
{formItem}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
@@ -596,6 +641,7 @@ function TicketManagement() {
|
|||||||
const showModal = useCallback((ticket = null) => {
|
const showModal = useCallback((ticket = null) => {
|
||||||
setEditingTicket(ticket);
|
setEditingTicket(ticket);
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
|
setManualDeviceSource(!ticket.deviceId);
|
||||||
const ticketData = { ...ticket };
|
const ticketData = { ...ticket };
|
||||||
if (ticketData.expectedCompletionDate) {
|
if (ticketData.expectedCompletionDate) {
|
||||||
ticketData.expectedCompletionDate = dayjs(ticketData.expectedCompletionDate);
|
ticketData.expectedCompletionDate = dayjs(ticketData.expectedCompletionDate);
|
||||||
@@ -603,14 +649,6 @@ function TicketManagement() {
|
|||||||
if (ticketData.completionDate) {
|
if (ticketData.completionDate) {
|
||||||
ticketData.completionDate = dayjs(ticketData.completionDate);
|
ticketData.completionDate = dayjs(ticketData.completionDate);
|
||||||
}
|
}
|
||||||
if (ticket.deviceId) {
|
|
||||||
setDeviceSource('select');
|
|
||||||
ticketData.deviceId = ticket.deviceId;
|
|
||||||
} else {
|
|
||||||
setDeviceSource('manual');
|
|
||||||
ticketData.deviceName = ticket.deviceName;
|
|
||||||
ticketData.serialNumber = ticket.serialNumber;
|
|
||||||
}
|
|
||||||
if (ticket.metadata && typeof ticket.metadata === 'object') {
|
if (ticket.metadata && typeof ticket.metadata === 'object') {
|
||||||
Object.entries(ticket.metadata).forEach(([key, value]) => {
|
Object.entries(ticket.metadata).forEach(([key, value]) => {
|
||||||
ticketData[key] = value;
|
ticketData[key] = value;
|
||||||
@@ -618,8 +656,8 @@ function TicketManagement() {
|
|||||||
}
|
}
|
||||||
form.setFieldsValue(ticketData);
|
form.setFieldsValue(ticketData);
|
||||||
} else {
|
} else {
|
||||||
|
setManualDeviceSource(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setDeviceSource('select');
|
|
||||||
const newTicketId = generateTicketId();
|
const newTicketId = generateTicketId();
|
||||||
form.setFieldsValue({ ticketId: newTicketId });
|
form.setFieldsValue({ ticketId: newTicketId });
|
||||||
}
|
}
|
||||||
@@ -654,9 +692,9 @@ function TicketManagement() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deviceSource === 'manual') {
|
if (manualDeviceSource) {
|
||||||
ticketData.deviceId = null;
|
ticketData.deviceId = null;
|
||||||
ticketData.deviceName = values.deviceName;
|
ticketData.deviceName = values.deviceName || '未知设备';
|
||||||
ticketData.serialNumber = values.serialNumber;
|
ticketData.serialNumber = values.serialNumber;
|
||||||
} else {
|
} else {
|
||||||
const selectedDevice = devices.find(d => d.deviceId === values.deviceId);
|
const selectedDevice = devices.find(d => d.deviceId === values.deviceId);
|
||||||
@@ -680,13 +718,12 @@ function TicketManagement() {
|
|||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
fetchTickets();
|
fetchTickets();
|
||||||
setEditingTicket(null);
|
setEditingTicket(null);
|
||||||
setDeviceSource('select');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(editingTicket ? '工单更新失败' : '工单创建失败');
|
message.error(editingTicket ? '工单更新失败' : '工单创建失败');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editingTicket, fetchTickets, deviceSource, ticketFields, devices]
|
[editingTicket, fetchTickets, ticketFields, devices, manualDeviceSource]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -779,57 +816,6 @@ function TicketManagement() {
|
|||||||
[fetchTickets, searchFilters]
|
[fetchTickets, searchFilters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeviceSourceChange = useCallback(value => {
|
|
||||||
setDeviceSource(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderDeviceFormItems = useCallback(() => {
|
|
||||||
if (deviceSource === 'select') {
|
|
||||||
return (
|
|
||||||
<Form.Item name="deviceId" label="关联设备" rules={[{ required: false }]}>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
name="deviceName"
|
|
||||||
label="设备名称"
|
|
||||||
rules={[{ required: true, message: '请输入设备名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入设备名称" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="serialNumber"
|
|
||||||
label="设备序列号"
|
|
||||||
rules={[{ required: true, message: '请输入设备序列号' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入设备序列号" />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, [deviceSource, devices, deviceSearching, handleDeviceSearch, fetchDevices]);
|
|
||||||
|
|
||||||
const renderFormItems = useCallback(() => {
|
const renderFormItems = useCallback(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
if (!editingTicket) {
|
if (!editingTicket) {
|
||||||
@@ -840,48 +826,101 @@ function TicketManagement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 ticketFields 是否包含 deviceId
|
|
||||||
const hasDeviceIdField = ticketFields.some(f => f.fieldName === 'deviceId');
|
const hasDeviceIdField = ticketFields.some(f => f.fieldName === 'deviceId');
|
||||||
const hasDeviceNameField = ticketFields.some(f => f.fieldName === 'deviceName');
|
const hasDeviceNameField = ticketFields.some(f => f.fieldName === 'deviceName');
|
||||||
|
|
||||||
ticketFields.forEach(field => {
|
ticketFields.forEach(field => {
|
||||||
if (field.fieldName === 'ticketId') {
|
if (field.fieldName === 'ticketId') {
|
||||||
// 已处理
|
|
||||||
} else if (field.fieldName === 'deviceId') {
|
} else if (field.fieldName === 'deviceId') {
|
||||||
// 渲染设备选择器
|
|
||||||
items.push(
|
items.push(
|
||||||
<React.Fragment key="deviceSource">
|
<React.Fragment key="deviceSource">
|
||||||
<Form.Item label="设备来源" required>
|
<Form.Item label="设备来源" required>
|
||||||
<Select
|
<Select
|
||||||
value={deviceSource}
|
value={manualDeviceSource ? 'manual' : 'select'}
|
||||||
onChange={handleDeviceSourceChange}
|
onChange={val => setManualDeviceSource(val === 'manual')}
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
>
|
>
|
||||||
<Option value="select">从设备管理选择</Option>
|
<Option value="select">从设备列表选择</Option>
|
||||||
<Option value="manual">手动输入</Option>
|
<Option value="manual">手动输入序列号</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{renderDeviceFormItems()}
|
{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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
} else if (field.fieldName === 'deviceName' || field.fieldName === 'serialNumber') {
|
} else if (field.fieldName === 'deviceName' || field.fieldName === 'serialNumber') {
|
||||||
// 如果存在 deviceId 字段,这些字段会在 renderDeviceFormItems 中处理
|
|
||||||
// 如果不存在 deviceId 字段但存在 deviceName,则显示手动输入模式
|
|
||||||
if (!hasDeviceIdField && field.fieldName === 'deviceName') {
|
if (!hasDeviceIdField && field.fieldName === 'deviceName') {
|
||||||
items.push(
|
items.push(
|
||||||
<React.Fragment key="deviceSource">
|
<Form.Item
|
||||||
<Form.Item label="设备来源" required>
|
key="deviceId"
|
||||||
<Select
|
name="deviceId"
|
||||||
value={deviceSource}
|
label="关联设备"
|
||||||
onChange={handleDeviceSourceChange}
|
rules={[{ required: true, message: '请选择关联设备' }]}
|
||||||
style={{ width: 200 }}
|
>
|
||||||
>
|
<Select
|
||||||
<Option value="select">从设备管理选择</Option>
|
placeholder="输入关键词搜索设备(序列号/名称/IP等)"
|
||||||
<Option value="manual">手动输入</Option>
|
showSearch
|
||||||
</Select>
|
allowClear
|
||||||
</Form.Item>
|
loading={deviceSearching}
|
||||||
{renderDeviceFormItems()}
|
filterOption={false}
|
||||||
</React.Fragment>
|
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 {
|
} else {
|
||||||
@@ -889,21 +928,64 @@ function TicketManagement() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果 ticketFields 中既没有 deviceId 也没有 deviceName,则手动添加设备选择器
|
|
||||||
if (!hasDeviceIdField && !hasDeviceNameField) {
|
if (!hasDeviceIdField && !hasDeviceNameField) {
|
||||||
items.push(
|
items.push(
|
||||||
<React.Fragment key="deviceSource">
|
<React.Fragment key="deviceSource">
|
||||||
<Form.Item label="设备来源" required>
|
<Form.Item label="设备来源" required>
|
||||||
<Select
|
<Select
|
||||||
value={deviceSource}
|
value={manualDeviceSource ? 'manual' : 'select'}
|
||||||
onChange={handleDeviceSourceChange}
|
onChange={val => setManualDeviceSource(val === 'manual')}
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
>
|
>
|
||||||
<Option value="select">从设备管理选择</Option>
|
<Option value="select">从设备列表选择</Option>
|
||||||
<Option value="manual">手动输入</Option>
|
<Option value="manual">手动输入序列号</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{renderDeviceFormItems()}
|
{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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -911,12 +993,13 @@ function TicketManagement() {
|
|||||||
return items;
|
return items;
|
||||||
}, [
|
}, [
|
||||||
ticketFields,
|
ticketFields,
|
||||||
deviceSource,
|
|
||||||
devices,
|
devices,
|
||||||
handleDeviceSourceChange,
|
deviceSearching,
|
||||||
renderDeviceFormItems,
|
handleDeviceSearch,
|
||||||
renderFormItem,
|
fetchDevices,
|
||||||
editingTicket,
|
editingTicket,
|
||||||
|
renderFormItem,
|
||||||
|
manualDeviceSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getActionItems = useCallback(
|
const getActionItems = useCallback(
|
||||||
@@ -1046,23 +1129,354 @@ function TicketManagement() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingTicket ? '编辑工单' : '创建工单'}
|
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}
|
open={modalVisible}
|
||||||
closeIcon={<CloseButton />}
|
closeIcon={<CloseButton />}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={700}
|
width={800}
|
||||||
|
destroyOnClose
|
||||||
|
style={{ top: 40 }}
|
||||||
|
bodyStyle={{ padding: '24px 24px 8px 24px' }}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form
|
||||||
{renderFormItems()}
|
form={form}
|
||||||
<Form.Item>
|
layout="vertical"
|
||||||
<Space>
|
onFinish={handleSubmit}
|
||||||
<Button type="primary" htmlType="submit">
|
requiredMark="optional"
|
||||||
{editingTicket ? '更新' : '创建'}
|
style={{ marginBottom: 16 }}
|
||||||
</Button>
|
>
|
||||||
<Button onClick={handleCancel}>取消</Button>
|
{!editingTicket && (
|
||||||
</Space>
|
<div style={{
|
||||||
</Form.Item>
|
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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message } from 'antd';
|
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message, Button, Switch, Tooltip } from 'antd';
|
||||||
import {
|
import {
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SyncOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -15,6 +17,8 @@ import dayjs from 'dayjs';
|
|||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = 30000;
|
||||||
|
|
||||||
const getStatusColor = status => {
|
const getStatusColor = status => {
|
||||||
const colors = {
|
const colors = {
|
||||||
pending: 'orange',
|
pending: 'orange',
|
||||||
@@ -59,7 +63,9 @@ const getPriorityText = priority => {
|
|||||||
|
|
||||||
function TicketStatistics() {
|
function TicketStatistics() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
|
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
|
||||||
|
const [lastUpdateTime, setLastUpdateTime] = useState(null);
|
||||||
const [statistics, setStatistics] = useState({
|
const [statistics, setStatistics] = useState({
|
||||||
total: 0,
|
total: 0,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
@@ -74,16 +80,21 @@ function TicketStatistics() {
|
|||||||
trend: [],
|
trend: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchStatistics = useCallback(async () => {
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const fetchStatistics = useCallback(async (isManual = false) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
if (isManual) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
const params = {
|
const params = {
|
||||||
startDate: dateRange[0].format('YYYY-MM-DD'),
|
startDate: dateRange[0].format('YYYY-MM-DD'),
|
||||||
endDate: dateRange[1].format('YYYY-MM-DD'),
|
endDate: dateRange[1].format('YYYY-MM-DD'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.get('/api/tickets/statistics', { params });
|
const response = await axios.get('/api/tickets/stats', { params });
|
||||||
setStatistics(response.data);
|
setStatistics(response.data);
|
||||||
|
setLastUpdateTime(new Date());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取统计数据失败');
|
message.error('获取统计数据失败');
|
||||||
console.error('获取统计数据失败:', error);
|
console.error('获取统计数据失败:', error);
|
||||||
@@ -96,12 +107,34 @@ function TicketStatistics() {
|
|||||||
fetchStatistics();
|
fetchStatistics();
|
||||||
}, [fetchStatistics]);
|
}, [fetchStatistics]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
fetchStatistics();
|
||||||
|
}, REFRESH_INTERVAL);
|
||||||
|
} else {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoRefresh, fetchStatistics]);
|
||||||
|
|
||||||
const handleDateChange = useCallback(dates => {
|
const handleDateChange = useCallback(dates => {
|
||||||
if (dates) {
|
if (dates) {
|
||||||
setDateRange(dates);
|
setDateRange(dates);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
fetchStatistics(true);
|
||||||
|
}, [fetchStatistics]);
|
||||||
|
|
||||||
const getStatusColor = status => {
|
const getStatusColor = status => {
|
||||||
const colors = {
|
const colors = {
|
||||||
pending: 'orange',
|
pending: 'orange',
|
||||||
@@ -296,6 +329,23 @@ function TicketStatistics() {
|
|||||||
title="工单统计报表"
|
title="工单统计报表"
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
|
<Tooltip title={lastUpdateTime ? `最后更新: ${dayjs(lastUpdateTime).format('HH:mm:ss')}` : '尚未更新'}>
|
||||||
|
<span style={{ fontSize: 12, color: '#888', marginRight: 8 }}>
|
||||||
|
{lastUpdateTime && `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="自动刷新(每30秒)">
|
||||||
|
<Switch
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={setAutoRefresh}
|
||||||
|
checkedChildren={<SyncOutlined spin={autoRefresh} />}
|
||||||
|
unCheckedChildren={<SyncOutlined />}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleManualRefresh} size="small">
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
<RangePicker value={dateRange} onChange={handleDateChange} allowClear={false} />
|
<RangePicker value={dateRange} onChange={handleDateChange} allowClear={false} />
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user