diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js
index fbd50de..e3d9524 100644
--- a/backend/routes/consumables.js
+++ b/backend/routes/consumables.js
@@ -60,6 +60,55 @@ router.get('/', async (req, res) => {
}
});
+const MAX_EXPORT_SIZE = 50000;
+
+router.get('/export', async (req, res) => {
+ try {
+ const { keyword, category, status } = req.query;
+
+ const where = {};
+
+ if (keyword) {
+ where[Op.or] = [
+ { consumableId: { [Op.like]: `%${keyword}%` } },
+ { name: { [Op.like]: `%${keyword}%` } },
+ { category: { [Op.like]: `%${keyword}%` } },
+ { supplier: { [Op.like]: `%${keyword}%` } },
+ { location: { [Op.like]: `%${keyword}%` } }
+ ];
+ }
+
+ if (category && category !== 'all') {
+ where.category = category;
+ }
+
+ if (status && status !== 'all') {
+ where.status = status;
+ }
+
+ const consumables = await Consumable.findAll({
+ where,
+ limit: MAX_EXPORT_SIZE,
+ order: [['createdAt', 'DESC']]
+ });
+
+ const result = consumables.map(item => {
+ const data = item.toJSON();
+ if (!Array.isArray(data.snList)) {
+ data.snList = [];
+ }
+ return data;
+ });
+
+ res.json({
+ consumables: result,
+ total: result.length
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
router.post('/', async (req, res) => {
const transaction = await sequelize.transaction();
try {
diff --git a/backend/routes/devices.js b/backend/routes/devices.js
index 7fdd146..6cc42be 100644
--- a/backend/routes/devices.js
+++ b/backend/routes/devices.js
@@ -584,6 +584,67 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
}
});
+const MAX_EXPORT_SIZE = 50000;
+
+router.get('/all', async (req, res) => {
+ try {
+ const { keyword, status, type, rackId, roomId } = req.query;
+
+ const where = {};
+
+ if (keyword) {
+ const escapedKeyword = keyword.replace(/'/g, "''");
+ where[Op.or] = [
+ { deviceId: { [Op.like]: `%${escapedKeyword}%` } },
+ { name: { [Op.like]: `%${escapedKeyword}%` } },
+ { type: { [Op.like]: `%${escapedKeyword}%` } },
+ { model: { [Op.like]: `%${escapedKeyword}%` } },
+ { serialNumber: { [Op.like]: `%${escapedKeyword}%` } },
+ { ipAddress: { [Op.like]: `%${escapedKeyword}%` } }
+ ];
+ }
+
+ if (status && status !== 'all') {
+ where.status = status;
+ }
+
+ if (type && type !== 'all') {
+ where.type = type;
+ }
+
+ if (rackId) {
+ where.rackId = rackId;
+ }
+
+ if (roomId && roomId !== 'all') {
+ where['$Rack.roomId$'] = roomId;
+ }
+
+ const devices = await Device.findAll({
+ where,
+ include: [
+ {
+ model: Rack,
+ include: [{ model: Room }],
+ separate: false
+ }
+ ],
+ limit: MAX_EXPORT_SIZE,
+ order: [['createdAt', 'DESC']],
+ distinct: true,
+ subQuery: false
+ });
+
+ res.json({
+ devices,
+ total: devices.length
+ });
+ } catch (error) {
+ console.error('获取设备列表失败:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
// 生成设备ID的辅助函数
async function generateDeviceId() {
// 获取当前最大的设备ID序号
diff --git a/backend/routes/racks.js b/backend/routes/racks.js
index de124fc..17fd601 100644
--- a/backend/routes/racks.js
+++ b/backend/routes/racks.js
@@ -79,6 +79,59 @@ router.get('/', async (req, res) => {
}
});
+const MAX_EXPORT_SIZE = 50000;
+
+router.get('/all', async (req, res) => {
+ try {
+ const { roomId, status, keyword } = req.query;
+
+ const where = {};
+ if (roomId && roomId !== 'all') {
+ where.roomId = roomId;
+ }
+ if (status && status !== 'all') {
+ where.status = status;
+ }
+ if (keyword) {
+ where[require('sequelize').Op.or] = [
+ { rackId: { [require('sequelize').Op.like]: `%${keyword}%` } },
+ { name: { [require('sequelize').Op.like]: `%${keyword}%` } }
+ ];
+ }
+
+ const racks = await Rack.findAll({
+ where,
+ include: [{ model: Room, separate: false }],
+ limit: MAX_EXPORT_SIZE
+ });
+
+ const rackIds = racks.map(r => r.rackId);
+ const devices = await Device.findAll({
+ where: { rackId: rackIds },
+ attributes: ['deviceId', 'rackId', 'name', 'powerConsumption', 'height']
+ });
+
+ const deviceMap = {};
+ devices.forEach(d => {
+ if (!deviceMap[d.rackId]) {
+ deviceMap[d.rackId] = [];
+ }
+ deviceMap[d.rackId].push(d);
+ });
+
+ racks.forEach(rack => {
+ rack.dataValues.Devices = deviceMap[rack.rackId] || [];
+ });
+
+ res.json({
+ racks,
+ total: racks.length
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// 导出机柜导入模板 - 必须放在 /:rackId 路由之前,避免被当作 rackId 参数
router.get('/import-template', async (req, res) => {
try {
diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js
index 0f660d5..f3bcabc 100644
--- a/backend/routes/tickets.js
+++ b/backend/routes/tickets.js
@@ -23,7 +23,7 @@ router.get('/stats', async (req, res) => {
const Sequelize = require('sequelize');
- const [total, statusStats, priorityStats, categoryStats, monthlyStats, deviceStats, dailyStats] = await Promise.all([
+ const [total, statusStats, priorityStats, categoryStats, monthlyStats, deviceStats, dailyCreatedStats, dailyCompletedStats] = await Promise.all([
Ticket.count({ where }),
Ticket.findAll({
where,
@@ -68,11 +68,29 @@ router.get('/stats', async (req, res) => {
Ticket.findAll({
where,
attributes: [
- [Sequelize.fn('DATE', Sequelize.col('createdAt')), 'date'],
+ [dbDialect === 'mysql'
+ ? Sequelize.fn('DATE_FORMAT', Sequelize.col('createdAt'), '%Y-%m-%d')
+ : Sequelize.fn('date', Sequelize.col('createdAt')),
+ 'date'],
[Sequelize.fn('COUNT', '*'), 'created']
],
group: ['date'],
order: [['date', 'ASC']]
+ }),
+ Ticket.findAll({
+ where: {
+ ...where,
+ status: 'completed'
+ },
+ attributes: [
+ [dbDialect === 'mysql'
+ ? Sequelize.fn('DATE_FORMAT', Sequelize.col('updatedAt'), '%Y-%m-%d')
+ : Sequelize.fn('date', Sequelize.col('updatedAt')),
+ 'date'],
+ [Sequelize.fn('COUNT', '*'), 'completed']
+ ],
+ group: ['date'],
+ order: [['date', 'ASC']]
})
]);
@@ -110,16 +128,42 @@ router.get('/stats', async (req, res) => {
deviceType: ''
}));
- const trend = dailyStats.map(d => ({
- date: d.dataValues.date,
- created: d.dataValues.created,
- completed: 0,
+ const createdMap = {};
+ dailyCreatedStats.forEach(d => {
+ createdMap[d.dataValues.date] = d.dataValues.created;
+ });
+ const completedMap = {};
+ dailyCompletedStats.forEach(d => {
+ completedMap[d.dataValues.date] = d.dataValues.completed;
+ });
+
+ const allDates = [...new Set([...Object.keys(createdMap), ...Object.keys(completedMap)])].sort();
+ const trend = allDates.map(date => ({
+ date,
+ created: createdMap[date] || 0,
+ completed: completedMap[date] || 0,
closed: 0,
inProgress: 0,
pending: 0
}));
- const avgProcessingTime = 0;
+ const completedTickets = await Ticket.findAll({
+ where: {
+ ...where,
+ status: 'completed'
+ },
+ attributes: ['createdAt', 'updatedAt']
+ });
+
+ let avgProcessingTime = 0;
+ if (completedTickets.length > 0) {
+ const totalProcessingTime = completedTickets.reduce((sum, ticket) => {
+ const created = new Date(ticket.createdAt);
+ const updated = new Date(ticket.updatedAt);
+ return sum + (updated - created);
+ }, 0);
+ avgProcessingTime = (totalProcessingTime / completedTickets.length / (1000 * 60 * 60)).toFixed(1);
+ }
res.json({
total,
@@ -127,7 +171,7 @@ router.get('/stats', async (req, res) => {
inProgress,
completed,
closed,
- avgProcessingTime,
+ avgProcessingTime: parseFloat(avgProcessingTime),
byStatus,
byPriority,
byCategory,
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c01e31d..2cbb06c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -19,6 +19,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
+ "react-transition-group": "^4.4.5",
"styled-components": "^6.3.9",
"swr": "^2.4.0",
"three": "^0.183.2",
@@ -3698,6 +3699,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/draco3d": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
@@ -6975,6 +6986,22 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5c0a394..975f50e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,6 +24,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
+ "react-transition-group": "^4.4.5",
"styled-components": "^6.3.9",
"swr": "^2.4.0",
"three": "^0.183.2",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5137d73..508d9d0 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -70,7 +70,6 @@ const Login = lazy(() => import('./pages/Login'));
const TicketManagement = lazy(() => import('./pages/TicketManagement'));
const TicketCategoryManagement = lazy(() => import('./pages/TicketCategoryManagement'));
const TicketStatistics = lazy(() => import('./pages/TicketStatistics'));
-const TicketFieldManagement = lazy(() => import('./pages/TicketFieldManagement'));
const SystemSettings = lazy(() => import('./pages/SystemSettings'));
const CableManagement = lazy(() => import('./pages/CableManagement'));
const PortManagement = lazy(() => import('./pages/PortManagement'));
@@ -321,11 +320,6 @@ const AppLayout = ({ children }) => {
icon: ,
label: 统计报表,
},
- {
- key: 'ticket-fields',
- icon: ,
- label: 字段管理,
- },
],
},
{
@@ -599,7 +593,6 @@ const routeConfig = [
{ path: '/tickets', component: TicketManagement },
{ path: '/ticket-categories', component: TicketCategoryManagement },
{ path: '/ticket-statistics', component: TicketStatistics },
- { path: '/ticket-fields', component: TicketFieldManagement },
{ path: '/settings', component: SystemSettings },
{ path: '/cables', component: CableManagement },
{ path: '/inventory', component: InventoryManagement },
diff --git a/frontend/src/components/device/DeviceDetailModal.jsx b/frontend/src/components/device/DeviceDetailModal.jsx
index 73754f5..040f5bb 100644
--- a/frontend/src/components/device/DeviceDetailModal.jsx
+++ b/frontend/src/components/device/DeviceDetailModal.jsx
@@ -1,23 +1,25 @@
-import React from 'react';
-import { Modal, Button, Card, Row, Col, Tag } from 'antd';
-import { AppstoreOutlined } from '@ant-design/icons';
+import React, { useEffect, useState } from 'react';
+import { Modal, Button, Row, Col, Tag, Progress } from 'antd';
+import {
+ AppstoreOutlined,
+ EnvironmentOutlined,
+ ThunderboltOutlined,
+ CalendarOutlined,
+ ExclamationCircleOutlined,
+ ClockCircleOutlined,
+ EditOutlined,
+ FileTextOutlined,
+ PlusOutlined,
+ CheckCircleOutlined,
+ CloseCircleOutlined,
+ SyncOutlined,
+ StopOutlined,
+ DesktopOutlined,
+} from '@ant-design/icons';
import { designTokens } from '../../config/theme';
import { getStatusConfig, getTypeLabel, getDeviceTypeIcon } from '../../utils/deviceUtils.jsx';
-const modalHeaderStyle = {
- display: 'flex',
- alignItems: 'center',
- gap: '8px',
- fontSize: '18px',
- fontWeight: 600,
-};
-
-const secondaryActionStyle = {
- height: '40px',
- borderRadius: designTokens.borderRadius.small,
- border: `1px solid ${designTokens.colors.border.light}`,
- fontWeight: '500',
-};
+const { colors, shadows, borderRadius, transitions, spacing } = designTokens;
const DeviceDetailModal = ({
visible,
@@ -28,225 +30,723 @@ const DeviceDetailModal = ({
onViewTickets,
onCreateTicket,
}) => {
+ const [isVisible, setIsVisible] = useState(false);
+ const [activeTab, setActiveTab] = useState('basic');
+
+ useEffect(() => {
+ if (visible) {
+ setIsVisible(true);
+ } else {
+ const timer = setTimeout(() => setIsVisible(false), 300);
+ return () => clearTimeout(timer);
+ }
+ }, [visible]);
+
if (!device) return null;
+ const getStatusIcon = (status) => {
+ const iconMap = {
+ running: ,
+ maintenance: ,
+ offline: ,
+ fault: ,
+ idle: ,
+ };
+ return iconMap[status] || ;
+ };
+
+ const getDeviceTypeColor = (type) => {
+ return colors.device[type] || colors.device.other;
+ };
+
+ const isWarrantyExpired = device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date();
+ const warrantyDaysLeft = device.warrantyExpiry
+ ? Math.ceil((new Date(device.warrantyExpiry) - new Date()) / (1000 * 60 * 60 * 24))
+ : null;
+
+ const InfoCard = ({ title, icon, children, className = '' }) => (
+
+
+ {icon}
+
+ {title}
+
+
+
{children}
+
+ );
+
+ const InfoItem = ({ label, value, status, copyable = false, fullWidth = false }) => {
+ const renderValue = () => {
+ if (status === 'warning') {
+ return (
+
+
+ {value}
+
+ );
+ }
+ if (status === 'danger') {
+ return (
+
+
+ {value}
+
+ );
+ }
+ return {value || '-'};
+ };
+
+ return (
+
+
+ {label}
+
+
+ {renderValue()}
+
+
+ );
+ };
+
+ const tabItems = [
+ { key: 'basic', label: '基本信息' },
+ { key: 'location', label: '位置信息' },
+ { key: 'maintenance', label: '维保信息' },
+ ];
+
return (
-
- 设备详情
-
- }
open={visible}
onCancel={onClose}
- footer={[
- ,
- ,
- ,
- ,
- ]}
- width={700}
- destroyOnHidden
+ footer={null}
+ width={800}
+ destroyOnClose
+ centered
+ className="device-detail-modal"
styles={{
- header: {
- borderBottom: '1px solid #f0f0f0',
- padding: '16px 24px',
- position: 'relative',
+ mask: {
+ backdropFilter: 'blur(4px)',
+ backgroundColor: 'rgba(0, 0, 0, 0.45)',
+ },
+ content: {
+ padding: 0,
+ borderRadius: borderRadius.xl,
+ overflow: 'hidden',
+ boxShadow: shadows.xl,
},
- body: { padding: '0', overflow: 'auto' },
}}
>
-
+
+
+
-
-
+ />
+
+
+
+
+
{getDeviceTypeIcon(device.type)}
-
-
+
+
+
+
+
{device.name}
-
-
- {getTypeLabel(device.type)}
- |
- {device.deviceId}
- |
+
+ {device.status && (
- {device.status ? getStatusConfig(device.status).text : '-'}
+ {getStatusIcon(device.status)}
+ {getStatusConfig(device.status).text}
-
+ )}
+
+
+
+
+
+ {getTypeLabel(device.type)}
+
+
+ {device.deviceId}
+
+ {device.model && (
+
+ {device.model}
+
+ )}
+
-
-
基本信息}
- style={{ marginBottom: '16px', borderRadius: '8px' }}
- >
-
-
- 设备型号
- {device.model || '-'}
-
-
- 序列号
- {device.serialNumber || '-'}
-
-
- IP地址
- {device.ipAddress || '-'}
-
-
- 所在机房
- {device.Rack?.Room?.name || '-'}
-
-
- 所在机柜
- {device.Rack?.name || '-'}
-
-
- 位置(U)
- U{device.position || '-'}
-
-
- 高度
- {device.height ? `${device.height}U` : '-'}
-
-
- 功率
- {device.powerConsumption ? `${device.powerConsumption}W` : '-'}
-
-
- 状态
-
- {device.status ? getStatusConfig(device.status).text : '-'}
-
-
-
-
-
-
维保信息}
- style={{ marginBottom: '16px', borderRadius: '8px' }}
- >
-
-
- 购买日期
-
- {device.purchaseDate
- ? new Date(device.purchaseDate).toLocaleDateString('zh-CN')
- : '-'}
-
-
-
- 保修到期
-
- {device.warrantyExpiry
- ? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN')
- : '-'}
-
-
-
-
-
- {device.description && (
-
描述}
- style={{ marginBottom: '16px', borderRadius: '8px' }}
+
+
+ {tabItems.map((tab) => (
+
+ ))}
+
+
- {device.customFields && Object.keys(device.customFields).length > 0 && (
- 自定义字段}
- style={{ borderRadius: '8px' }}
+
+ {activeTab === 'basic' && (
+
}
+ style={{ animation: 'fadeInUp 0.3s ease-out' }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {device.description && (
+
+
+
+ )}
+ {device.customFields && Object.keys(device.customFields).length > 0 && (
+
+
+ 自定义字段
+
+
+ {Object.entries(device.customFields).map(([key, value]) => {
+ const fieldConfig = deviceFields.find((f) => f.fieldName === key);
+ const displayName = fieldConfig?.displayName || key;
+ return (
+
+
+
+ {displayName}
+
+
+ {String(value)}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ )}
+
+ {activeTab === 'location' && (
+
}
+ style={{ animation: 'fadeInUp 0.3s ease-out' }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'maintenance' && (
+ <>
+
}
+ style={{ animation: 'fadeInUp 0.3s ease-out', marginBottom: '20px' }}
>
- {Object.entries(device.customFields).map(([key, value]) => {
- const fieldConfig = deviceFields.find((f) => f.fieldName === key);
- const displayName = fieldConfig?.displayName || key;
- return (
-
-
- {displayName}
-
- {String(value)}
-
- );
- })}
+
+
+
+
+
+
+ {warrantyDaysLeft !== null && warrantyDaysLeft > 0 && (
+
+
+ 保修剩余天数
+
+
-
- )}
+ {isWarrantyExpired && (
+
+
+
+ 设备已过保,建议续保
+
+
+ )}
+
+ >
+ )}
+
+
+
+
+ }
+ onClick={() => onViewTickets(device)}
+ style={{
+ height: '40px',
+ borderRadius: borderRadius.medium,
+ border: `1px solid ${colors.border.light}`,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ fontWeight: 500,
+ }}
+ >
+ 查看工单
+
+ }
+ 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,
+ }}
+ >
+ 创建工单
+
+
+
+
+ }
+ 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,
+ }}
+ >
+ 编辑设备
+
diff --git a/frontend/src/pages/ConsumableManagement.jsx b/frontend/src/pages/ConsumableManagement.jsx
index 2a08c3b..b4a6b49 100644
--- a/frontend/src/pages/ConsumableManagement.jsx
+++ b/frontend/src/pages/ConsumableManagement.jsx
@@ -347,8 +347,8 @@ function ConsumableManagement() {
const handleExport = async () => {
try {
- const response = await axios.get('/api/consumables', {
- params: { keyword, category, status, pageSize: 1000 },
+ const response = await axios.get('/api/consumables/export', {
+ params: { keyword, category, status },
});
const consumables = response.data.consumables;
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx
index 8d9d1d2..48fed98 100644
--- a/frontend/src/pages/DeviceManagement.jsx
+++ b/frontend/src/pages/DeviceManagement.jsx
@@ -193,9 +193,7 @@ function DeviceManagement() {
const fetchRacks = async () => {
try {
- const response = await axios.get('/api/racks', {
- params: { pageSize: 1000 },
- });
+ const response = await axios.get('/api/racks/all');
setRacks(response.data.racks || []);
} catch (error) {
message.error('获取机柜列表失败');
diff --git a/frontend/src/pages/TicketFieldManagement.jsx b/frontend/src/pages/TicketFieldManagement.jsx
index 8aa8d0c..2e1669f 100644
--- a/frontend/src/pages/TicketFieldManagement.jsx
+++ b/frontend/src/pages/TicketFieldManagement.jsx
@@ -18,17 +18,19 @@ import CloseButton from '../components/CloseButton';
const { Option } = Select;
-const OptionsEditor = ({ value = [], onChange }) => {
+const OptionsEditor = ({ value, onChange }) => {
+ const options = Array.isArray(value) ? value : [];
+
const handleAdd = () => {
- onChange([...value, { value: '', label: '' }]);
+ onChange([...options, { value: '', label: '' }]);
};
const handleRemove = index => {
- onChange(value.filter((_, i) => i !== index));
+ onChange(options.filter((_, i) => i !== index));
};
const handleUpdate = (index, field, fieldValue) => {
- const newOptions = value.map((opt, i) =>
+ const newOptions = options.map((opt, i) =>
i === index ? { ...opt, [field]: fieldValue } : opt
);
onChange(newOptions);
@@ -62,7 +64,7 @@ const OptionsEditor = ({ value = [], onChange }) => {
- {value.length === 0 ? (
+ {options.length === 0 ? (
{
)}
- {value.length > 0 && (
+ {options.length > 0 && (
}
diff --git a/frontend/src/pages/TicketManagement.jsx b/frontend/src/pages/TicketManagement.jsx
index c2e1fe7..2fc2f95 100644
--- a/frontend/src/pages/TicketManagement.jsx
+++ b/frontend/src/pages/TicketManagement.jsx
@@ -139,6 +139,20 @@ const DEFAULT_TICKET_FIELDS = [
{ 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',
displayName: '故障描述',
@@ -255,7 +269,7 @@ function TicketManagement() {
});
const [searchFilters, setSearchFilters] = useState({});
- const [deviceSource, setDeviceSource] = useState('select');
+ const [manualDeviceSource, setManualDeviceSource] = useState(false);
const [ticketFields, setTicketFields] = useState(DEFAULT_TICKET_FIELDS);
const [loadingFields, setLoadingFields] = useState(true);
const [deviceFields, setDeviceFields] = useState([]);
@@ -302,11 +316,11 @@ function TicketManagement() {
const fetchDevices = useCallback(async (keyword = '') => {
try {
setDeviceSearching(true);
- const params = { pageSize: 50 };
+ const params = {};
if (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 || []);
} catch (error) {
console.error('获取设备列表失败:', error);
@@ -344,8 +358,34 @@ function TicketManagement() {
try {
setLoadingFields(true);
const response = await axios.get('/api/ticket-fields');
- const sortedFields = response.data.sort((a, b) => a.order - b.order);
- setTicketFields(sortedFields);
+ const dbFields = response.data.sort((a, b) => a.order - b.order);
+
+ 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) {
console.error('获取工单字段配置失败:', error);
setTicketFields(DEFAULT_TICKET_FIELDS);
@@ -378,12 +418,9 @@ function TicketManagement() {
// 处理从设备详情页跳转过来创建工单的情况
useEffect(() => {
if (urlDeviceId && devices.length > 0 && urlCreate === 'true') {
- // 自动打开创建工单弹窗
setEditingTicket(null);
- setDeviceSource('select');
setModalVisible(true);
- // 填充设备信息 - 使用 setTimeout 确保弹窗打开后再设置表单值
setTimeout(() => {
form.setFieldsValue({
deviceId: urlDeviceId,
@@ -418,34 +455,35 @@ function TicketManagement() {
let formItem;
switch (fieldType) {
case 'string':
- formItem =
;
+ formItem =
;
break;
case 'number':
formItem = (
);
break;
case 'textarea':
formItem = (
-
+
);
break;
case 'boolean':
formItem =
;
break;
case 'date':
- formItem =
;
+ formItem =
;
break;
case 'datetime':
- formItem =
;
+ formItem =
;
break;
case 'select':
const selectOptions = options && Array.isArray(options) ? options : [];
formItem = (
-
+ }
open={modalVisible}
closeIcon={
}
onCancel={handleCancel}
footer={null}
- width={700}
+ width={800}
+ destroyOnClose
+ style={{ top: 40 }}
+ bodyStyle={{ padding: '24px 24px 8px 24px' }}
>
-
-
-
-
-
-
+
diff --git a/frontend/src/pages/TicketStatistics.jsx b/frontend/src/pages/TicketStatistics.jsx
index 1423f21..3acbcfe 100644
--- a/frontend/src/pages/TicketStatistics.jsx
+++ b/frontend/src/pages/TicketStatistics.jsx
@@ -1,5 +1,5 @@
-import React, { useState, useEffect, useCallback, useMemo } from 'react';
-import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message } from 'antd';
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message, Button, Switch, Tooltip } from 'antd';
import {
BarChartOutlined,
PieChartOutlined,
@@ -8,6 +8,8 @@ import {
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
+ ReloadOutlined,
+ SyncOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
@@ -15,6 +17,8 @@ import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
const { Option } = Select;
+const REFRESH_INTERVAL = 30000;
+
const getStatusColor = status => {
const colors = {
pending: 'orange',
@@ -59,7 +63,9 @@ const getPriorityText = priority => {
function TicketStatistics() {
const [loading, setLoading] = useState(true);
+ const [autoRefresh, setAutoRefresh] = useState(false);
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
+ const [lastUpdateTime, setLastUpdateTime] = useState(null);
const [statistics, setStatistics] = useState({
total: 0,
pending: 0,
@@ -74,16 +80,21 @@ function TicketStatistics() {
trend: [],
});
- const fetchStatistics = useCallback(async () => {
+ const timerRef = useRef(null);
+
+ const fetchStatistics = useCallback(async (isManual = false) => {
try {
- setLoading(true);
+ if (isManual) {
+ setLoading(true);
+ }
const params = {
startDate: dateRange[0].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);
+ setLastUpdateTime(new Date());
} catch (error) {
message.error('获取统计数据失败');
console.error('获取统计数据失败:', error);
@@ -96,12 +107,34 @@ function TicketStatistics() {
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 => {
if (dates) {
setDateRange(dates);
}
}, []);
+ const handleManualRefresh = useCallback(() => {
+ fetchStatistics(true);
+ }, [fetchStatistics]);
+
const getStatusColor = status => {
const colors = {
pending: 'orange',
@@ -296,6 +329,23 @@ function TicketStatistics() {
title="工单统计报表"
extra={
+
+
+ {lastUpdateTime && `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
+
+
+
+ }
+ unCheckedChildren={}
+ size="small"
+ />
+
+ } onClick={handleManualRefresh} size="small">
+ 刷新
+
}