feat(authentication): 实现用户空闲超时自动登出功能

This commit is contained in:
zhang1106
2026-02-11 10:21:32 +08:00
parent e55031f8cc
commit 7e8ba6fb60
6 changed files with 263 additions and 4 deletions
+28 -1
View File
@@ -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,
+3 -1
View File
@@ -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 },
+7
View File
@@ -0,0 +1,7 @@
# 用户空闲超时配置(毫秒)
# 默认30分钟 = 30 * 60 * 1000 = 1800000
VITE_IDLE_TIMEOUT=1800000
# 超时前警告时间(毫秒)
# 默认1分钟 = 60 * 1000 = 60000
VITE_IDLE_WARNING_TIME=60000
+35 -1
View File
@@ -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 }) => (
</PrivateRoute>
);
// 默认空闲超时配置
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('已退出登录');
+189
View File
@@ -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;
+1 -1
View File
@@ -368,7 +368,7 @@ const SystemSettings = () => {
'site_logo',
'timezone',
'date_format',
'session_timeout',
'idle_timeout',
'max_login_attempts',
'maintenance_mode',
];