From b84b8f75947d877e41de5a3a1631e5af87dc1905 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Wed, 11 Feb 2026 10:21:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=87=BD=E6=95=B0=E6=9B=BF=E4=BB=A3=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E8=A1=A8=E8=8E=B7=E5=8F=96=E8=AE=BE=E5=A4=87=E5=92=8C?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/systemSettings.js | 29 +++- backend/scripts/init-database.js | 4 +- backend/server.js | 38 +++++ frontend/.env.example | 7 + frontend/src/App.jsx | 36 ++++- frontend/src/hooks/useIdleTimeout.js | 189 ++++++++++++++++++++++++ frontend/src/pages/DeviceManagement.jsx | 6 +- frontend/src/pages/SystemSettings.jsx | 2 +- 8 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/hooks/useIdleTimeout.js diff --git a/backend/routes/systemSettings.js b/backend/routes/systemSettings.js index e158f51..2230333 100644 --- a/backend/routes/systemSettings.js +++ b/backend/routes/systemSettings.js @@ -13,7 +13,9 @@ const initDefaultSettings = async () => { { settingKey: 'site_logo', settingValue: JSON.stringify(''), settingType: 'string', category: 'general', description: '网站Logo URL', isEditable: true }, { settingKey: 'timezone', settingValue: JSON.stringify('Asia/Shanghai'), settingType: 'string', category: 'general', description: '时区设置', isEditable: true }, { settingKey: 'date_format', settingValue: JSON.stringify('YYYY-MM-DD'), settingType: 'string', category: 'general', description: '日期格式', isEditable: true }, - { settingKey: 'session_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '会话超时时间(分钟)', isEditable: true }, + { settingKey: 'session_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '登录有效期(分钟)', isEditable: true }, + { settingKey: 'idle_timeout', settingValue: JSON.stringify(10), settingType: 'number', category: 'general', description: '用户空闲超时时间(分钟)', isEditable: true }, + { settingKey: 'idle_warning_time', settingValue: JSON.stringify(10), settingType: 'number', category: 'general', description: '空闲超时前警告时间(秒)', isEditable: false }, { settingKey: 'max_login_attempts', settingValue: JSON.stringify(5), settingType: 'number', category: 'general', description: '最大登录尝试次数', isEditable: true }, { settingKey: 'maintenance_mode', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'general', description: '维护模式', isEditable: true }, @@ -97,6 +99,29 @@ router.get('/', async (req, res) => { } }); +// 获取用户空闲超时配置(公开接口,供前端使用) +// 注意:此路由必须在 /:key 之前定义,否则会被当作 key 参数处理 +router.get('/idle-timeout', async (req, res) => { + try { + const timeoutSetting = await SystemSetting.findByPk('idle_timeout'); + + // 默认配置 + const defaultTimeout = 10; // 10分钟 + const fixedWarningTime = 10; // 固定10秒警告时间 + + const timeout = timeoutSetting ? JSON.parse(timeoutSetting.settingValue) : defaultTimeout; + + res.json({ + timeout: timeout * 60 * 1000, // 转换为毫秒 + warningTime: fixedWarningTime * 1000, // 固定10秒(转换为毫秒) + timeoutMinutes: timeout, + warningTimeSeconds: fixedWarningTime + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // 获取单个设置 router.get('/:key', async (req, res) => { try { @@ -238,6 +263,8 @@ router.post('/reset/:key', async (req, res) => { timezone: 'Asia/Shanghai', date_format: 'YYYY-MM-DD', session_timeout: 30, + idle_timeout: 10, + idle_warning_time: 10, max_login_attempts: 5, maintenance_mode: false, frontend_port: 3000, diff --git a/backend/scripts/init-database.js b/backend/scripts/init-database.js index 0a9e5be..774fbfa 100644 --- a/backend/scripts/init-database.js +++ b/backend/scripts/init-database.js @@ -17,7 +17,9 @@ const defaultSettings = [ { settingKey: 'site_logo', settingValue: JSON.stringify(''), settingType: 'string', category: 'general', description: '网站Logo URL', isEditable: true }, { settingKey: 'timezone', settingValue: JSON.stringify('Asia/Shanghai'), settingType: 'string', category: 'general', description: '时区设置', isEditable: true }, { settingKey: 'date_format', settingValue: JSON.stringify('YYYY-MM-DD'), settingType: 'string', category: 'general', description: '日期格式', isEditable: true }, - { settingKey: 'session_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '会话超时时间(分钟)', isEditable: true }, + { settingKey: 'session_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '登录有效期(分钟)', isEditable: true }, + { settingKey: 'idle_timeout', settingValue: JSON.stringify(10), settingType: 'number', category: 'general', description: '用户空闲超时时间(分钟)', isEditable: true }, + { settingKey: 'idle_warning_time', settingValue: JSON.stringify(10), settingType: 'number', category: 'general', description: '空闲超时前警告时间(秒)', isEditable: false }, { settingKey: 'max_login_attempts', settingValue: JSON.stringify(5), settingType: 'number', category: 'general', description: '最大登录尝试次数', isEditable: true }, { settingKey: 'maintenance_mode', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'general', description: '维护模式', isEditable: true }, diff --git a/backend/server.js b/backend/server.js index 63e31e3..abe5ca3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -53,6 +53,44 @@ sequelize.authenticate() .then(() => { console.log('系统设置初始化完成'); }) + .then(() => { + // 初始化故障分类数据 + console.log('开始初始化故障分类...'); + const FaultCategory = require('./models/FaultCategory'); + const defaultCategories = [ + { name: '系统故障', description: '操作系统、应用程序等系统软件的故障问题', priority: 1, defaultPriority: 'high' }, + { name: '硬件故障', description: '物理设备、服务器、存储等硬件设备的故障问题', priority: 2, defaultPriority: 'high' }, + { name: '网络故障', description: '网络连接、交换机、路由器等网络相关故障', priority: 3, defaultPriority: 'high' }, + { name: '软件故障', description: '应用程序错误、软件兼容性等问题', priority: 4, defaultPriority: 'medium' }, + { name: '安全事件', description: '安全漏洞、入侵检测、权限异常等安全问题', priority: 5, defaultPriority: 'urgent' }, + { name: '性能问题', description: '系统响应慢、资源利用率高等性能问题', priority: 6, defaultPriority: 'medium' }, + { name: '配置变更', description: '系统配置、软件配置等变更需求', priority: 7, defaultPriority: 'low' }, + { name: '例行维护', description: '定期维护、巡检、更新等计划性工作', priority: 8, defaultPriority: 'low' }, + { name: '数据问题', description: '数据错误、数据丢失、数据同步等数据相关问题', priority: 9, defaultPriority: 'high' }, + { name: '其他问题', description: '无法归类的其他问题', priority: 99, defaultPriority: 'medium' } + ]; + + return Promise.all( + defaultCategories.map(async (cat) => { + const existing = await FaultCategory.findOne({ where: { name: cat.name } }); + if (!existing) { + const categoryId = `CAT${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; + await FaultCategory.create({ + categoryId, + ...cat, + expectedDuration: 120, + solutions: [], + isSystem: true, + isActive: true + }); + console.log(`创建故障分类: ${cat.name}`); + } + }) + ); + }) + .then(() => { + console.log('故障分类初始化完成'); + }) .catch(err => console.error('数据库操作失败:', err)); // 导入路由 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8d5a4fb --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# 用户空闲超时配置(毫秒) +# 默认30分钟 = 30 * 60 * 1000 = 1800000 +VITE_IDLE_TIMEOUT=1800000 + +# 超时前警告时间(毫秒) +# 默认1分钟 = 60 * 1000 = 60000 +VITE_IDLE_WARNING_TIME=60000 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f67a18c..a2fc39c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, Suspense, lazy } from 'react'; +import React, { useState, useEffect, Suspense, lazy } from 'react'; import { Layout, Menu, @@ -48,6 +48,8 @@ import { useAuth } from './context/AuthContext'; import { ConfigProvider, useConfig } from './context/ConfigContext'; import { Scene3DProvider } from './context/Scene3DContext'; import { useDesignTokens } from './hooks/useDesignTokens'; +import useIdleTimeout from './hooks/useIdleTimeout'; +import axios from 'axios'; import { Spin } from 'antd'; const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -127,15 +129,47 @@ const ProtectedRoute = ({ component: Component }) => ( ); +// 默认空闲超时配置 +const DEFAULT_IDLE_CONFIG = { + timeout: 10 * 60 * 1000, // 10分钟 + warningTime: 30 * 1000, // 30秒 +}; + const AppLayout = ({ children }) => { const [collapsed, setCollapsed] = useState(false); const [activeKey, setActiveKey] = useState('dashboard'); + const [idleConfig, setIdleConfig] = useState(DEFAULT_IDLE_CONFIG); const { user, logout } = useAuth(); const { config } = useConfig(); const navigate = useNavigate(); const location = useLocation(); const designTokens = useDesignTokens(); + // 获取空闲超时配置 + useEffect(() => { + const fetchIdleConfig = async () => { + try { + const response = await axios.get('/api/system-settings/idle-timeout'); + setIdleConfig({ + timeout: response.data.timeout, + warningTime: response.data.warningTime, + }); + } catch (error) { + console.warn('获取空闲超时配置失败,使用默认配置:', error); + } + }; + + fetchIdleConfig(); + }, []); + + // 启用空闲超时检测 + useIdleTimeout({ + timeout: idleConfig.timeout, + warningTime: idleConfig.warningTime, + onLogout: logout, + enabled: true, + }); + const handleLogout = () => { logout(); message.success('已退出登录'); diff --git a/frontend/src/hooks/useIdleTimeout.js b/frontend/src/hooks/useIdleTimeout.js new file mode 100644 index 0000000..14d9602 --- /dev/null +++ b/frontend/src/hooks/useIdleTimeout.js @@ -0,0 +1,189 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { message, Modal } from 'antd'; + +/** + * 用户空闲超时检测 Hook + * @param {Object} options - 配置选项 + * @param {number} options.timeout - 超时时间(毫秒),默认30分钟 + * @param {number} options.warningTime - 警告提示时间(毫秒),默认超时前1分钟 + * @param {Function} options.onLogout - 登出回调函数 + * @param {boolean} options.enabled - 是否启用,默认true + */ +const useIdleTimeout = ({ + timeout = 30 * 60 * 1000, // 默认30分钟 + warningTime = 60 * 1000, // 默认提前1分钟警告 + onLogout, + enabled = true, +} = {}) => { + const navigate = useNavigate(); + const timerRef = useRef(null); + const warningTimerRef = useRef(null); + const modalRef = useRef(null); + const lastActivityRef = useRef(Date.now()); + + // 清除所有定时器 + const clearTimers = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (warningTimerRef.current) { + clearTimeout(warningTimerRef.current); + warningTimerRef.current = null; + } + if (modalRef.current) { + modalRef.current.destroy(); + modalRef.current = null; + } + }, []); + + // 执行登出 + const handleLogout = useCallback(() => { + clearTimers(); + + // 清除登录状态 + localStorage.removeItem('token'); + localStorage.removeItem('user'); + + // 执行自定义登出回调 + if (onLogout) { + onLogout(); + } + + message.warning('由于长时间未操作,您已自动退出登录', 3); + navigate('/login'); + }, [clearTimers, navigate, onLogout]); + + // 显示超时警告 + const showWarning = useCallback(() => { + let countdown = Math.ceil(warningTime / 1000); + + modalRef.current = Modal.warning({ + title: '即将超时退出', + content: `由于长时间未操作,系统将在 ${countdown} 秒后自动退出登录,请重新登录。`, + okText: '保持登录', + onOk: () => { + // 用户点击保持登录,重置计时器 + resetTimer(); + }, + maskClosable: false, + keyboard: false, + }); + + // 更新倒计时 + const countdownInterval = setInterval(() => { + countdown -= 1; + if (modalRef.current && countdown > 0) { + modalRef.current.update({ + content: `由于长时间未操作,系统将在 ${countdown} 秒后自动退出登录,请重新登录。`, + }); + } + if (countdown <= 0) { + clearInterval(countdownInterval); + } + }, 1000); + + // 警告时间结束后自动登出 + warningTimerRef.current = setTimeout(() => { + clearInterval(countdownInterval); + handleLogout(); + }, warningTime); + }, [warningTime, handleLogout]); + + // 重置计时器 + const resetTimer = useCallback(() => { + if (!enabled) return; + + clearTimers(); + lastActivityRef.current = Date.now(); + + // 设置警告定时器 + if (warningTime > 0 && timeout > warningTime) { + warningTimerRef.current = setTimeout(() => { + showWarning(); + }, timeout - warningTime); + } + + // 设置超时定时器 + timerRef.current = setTimeout(() => { + handleLogout(); + }, timeout); + }, [enabled, timeout, warningTime, clearTimers, showWarning, handleLogout]); + + // 监听用户活动事件 + useEffect(() => { + if (!enabled) { + clearTimers(); + return; + } + + // 定义需要监听的事件 + const events = [ + 'mousedown', + 'mousemove', + 'keydown', + 'scroll', + 'touchstart', + 'click', + 'wheel', + ]; + + // 事件处理函数 + const handleActivity = () => { + // 防抖:避免频繁重置 + const now = Date.now(); + if (now - lastActivityRef.current < 1000) { + return; + } + resetTimer(); + }; + + // 绑定事件监听 + events.forEach(event => { + document.addEventListener(event, handleActivity, { passive: true }); + }); + + // 初始化计时器 + resetTimer(); + + // 清理函数 + return () => { + events.forEach(event => { + document.removeEventListener(event, handleActivity); + }); + clearTimers(); + }; + }, [enabled, resetTimer, clearTimers]); + + // 页面可见性变化处理 + useEffect(() => { + if (!enabled) return; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // 页面重新可见时检查是否已超时 + const idleTime = Date.now() - lastActivityRef.current; + if (idleTime >= timeout) { + handleLogout(); + } else { + resetTimer(); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, timeout, resetTimer, handleLogout]); + + return { + resetTimer, + clearTimers, + getIdleTime: () => Date.now() - lastActivityRef.current, + }; +}; + +export default useIdleTimeout; diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx index 18e11e6..78f5c19 100644 --- a/frontend/src/pages/DeviceManagement.jsx +++ b/frontend/src/pages/DeviceManagement.jsx @@ -2222,7 +2222,7 @@ function DeviceManagement() { {selectedDevice.type ? ( {getDeviceTypeIcon(selectedDevice.type)} - {typeMap[selectedDevice.type] || selectedDevice.type} + {getTypeLabel(selectedDevice.type)} ) : ( '-' @@ -2283,13 +2283,13 @@ function DeviceManagement() { style={{ marginLeft: 8, color: selectedDevice.status - ? statusMap[selectedDevice.status]?.color + ? getStatusConfig(selectedDevice.status).color : '#666', fontWeight: '600', }} > {selectedDevice.status - ? statusMap[selectedDevice.status]?.text || selectedDevice.status + ? getStatusConfig(selectedDevice.status).text || selectedDevice.status : '-'} diff --git a/frontend/src/pages/SystemSettings.jsx b/frontend/src/pages/SystemSettings.jsx index 9e43a4c..09d6140 100644 --- a/frontend/src/pages/SystemSettings.jsx +++ b/frontend/src/pages/SystemSettings.jsx @@ -368,7 +368,7 @@ const SystemSettings = () => { 'site_logo', 'timezone', 'date_format', - 'session_timeout', + 'idle_timeout', 'max_login_attempts', 'maintenance_mode', ];