yxh
2026-09-29 6ab4b1e0b68e4c347110e37018fa005603f53904
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { watch } from 'vue'
import { useIdle } from '@vueuse/core'
import { ElMessage } from 'element-plus'
 
/**
 * 空闲超时自动退出(需求:30 分钟不操作才要求重新输入密码)。
 *
 * 背景
 * ----
 * 后端 token 是**固定有效期**(`system_oauth2_client.access_token_validity_seconds`,
 * 当前 default 客户端 = 3600 秒),与"用户是否有操作"无关。
 * 因此会出现两种都不理想的情况:
 *   · 页面挂着不动 → 到点被踢(用户觉得"我没停过怎么又要登录");
 *   · 一直操作 → token 到期也会被踢(期望是"持续操作就不该重登")。
 *
 * 本模块在前端补上**真正的空闲检测**:
 *   · 有任何鼠标/键盘/触摸/滚动操作 → 计时器重置;
 *   · 连续 30 分钟**无任何操作** → 主动清理登录态并跳转登录页(要求重新输密码);
 *   · 有操作时不主动登出(token 自身的续期由 axios 拦截器的 refresh-token 负责)。
 *
 * 用法(在 permission.ts 中调用一次即可,内部自带幂等保护):
 *   setupIdleLogout()
 */
 
/** 空闲阈值:30 分钟(需求指定) */
export const IDLE_TIMEOUT_MINUTES = 30
const IDLE_TIMEOUT_MS = IDLE_TIMEOUT_MINUTES * 60 * 1000
 
/**
 * 视为"有操作"的事件。
 * 刻意**不**包含 mousemove 的高频节流问题:useIdle 内部已做节流,
 * 且 mousemove 是判断"人还在电脑前"的最可靠信号(例如只是阅读页面时会移动鼠标)。
 *
 * 注意:诊间屏/叫号屏是长时间无人操作的展示页面,但那些页面在路由白名单里
 * (见 permission.ts 的 whiteList),不会进入本逻辑。
 */
const ACTIVITY_EVENTS = [
  'mousemove',
  'mousedown',
  'mouseup',
  'wheel',
  'keydown',
  'keyup',
  'touchstart',
  'touchend',
  'click',
  'scroll'
] as const
 
let started = false
let idleWatcher: (() => void) | null = null
 
/** 是否已启动(避免重复注册) */
export function isIdleLogoutStarted(): boolean {
  return started
}
 
/** 清理(一般不需要调用;登出后若再登录,用 resetIdleLogout 重置) */
export function stopIdleLogout(): void {
  if (idleWatcher) {
    idleWatcher()
    idleWatcher = null
  }
  started = false
}
 
/**
 * 启动空闲登出检测。幂等:重复调用只生效一次。
 *
 * @param onTimeout 自定义超时动作(默认:清凭证 + 跳登录页)
 */
export function setupIdleLogout(onTimeout?: () => void): void {
  if (started) return
  started = true
 
  const { idle, lastActive, stop } = useIdle(IDLE_TIMEOUT_MS, {
    events: ACTIVITY_EVENTS as unknown as string[],
    listen: true
  })
 
  // 暴露 stop 便于外部停止
  idleWatcher = stop
 
  // useIdle 的 idle 是基于"距上次活动是否超过阈值"的响应式布尔值。
  // 这里用 watch 而不是 interval,避免轮询开销。
  watch(idle, (isIdle: boolean) => {
    if (!isIdle) return
    // 双保险:再次核对距上次活动确实已超过阈值
    const inactiveMs = Date.now() - (lastActive.value || 0)
    if (inactiveMs < IDLE_TIMEOUT_MS) return
 
    if (onTimeout) {
      onTimeout()
      return
    }
    doIdleLogout()
  })
}
 
/**
 * 实际执行空闲登出:清理本地凭证并跳转登录页。
 *
 * 之所以不调用 userStore.loginOut()(那是"主动退出"):
 *   · 那个接口会请求后端注销 token — 对本场景没必要,且可能因网络问题卡住;
 *   · 空闲登出只需保证本地凭证清干净 + 回到登录页。
 */
function doIdleLogout(): void {
  ElMessage.warning(`已超过 ${IDLE_TIMEOUT_MINUTES} 分钟未操作,请重新登录`)
 
  cleanupCredentials()
 
  // 用 replace 避免用户按"返回"回到已登出的页面
  const redirect = encodeURIComponent(window.location.pathname + window.location.search)
  setTimeout(() => {
    window.location.replace(`/login?redirect=${redirect}`)
  }, 800)
}
 
/**
 * 清理登录凭证与用户缓存。
 * 用动态 import 避免与 permission.ts 形成循环依赖。
 */
function cleanupCredentials(): void {
  import('@/utils/auth')
    .then(({ removeToken }) => {
      removeToken()
      return import('@/hooks/web/useCache')
    })
    .then(({ deleteUserCache }) => {
      deleteUserCache()
    })
    .catch((e) => {
      // 兜底:即使 import 失败也直接清 localStorage,保证不会带着旧 token 回到首页
      try {
        localStorage.removeItem('ACCESS_TOKEN')
        localStorage.removeItem('REFRESH_TOKEN')
      } catch {
        /* ignore */
      }
      console.error('[idle-logout] 清理凭证失败', e)
    })
}