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/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/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', ];