yxh
yxh
6 天以前 09dc74e264b6262cbe41c9c8476e24705871d4a6
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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
    }
    void doIdleLogout()
  })
}
 
/**
 * 实际执行空闲登出:**只清本地凭证并跳转登录页,不释放工位**。
 *
 * 之所以不调用 userStore.loginOut()(那是"主动退出"):
 *   · 那个接口会请求后端注销 token — 对本场景没必要,且可能因网络问题卡住;
 *   · 空闲登出只需保证本地凭证清干净 + 回到登录页。
 *
 * 【重要变更(本次)】空闲超时**不再释放诊室工位**。
 * ---------------------------------------------------------------
 * 原先本函数会先调 `releaseBedOnExit('idle-timeout')`,把 `clinic_room.doc_id` 清空。
 * 实测问题:医生离开电脑 30 分钟以上(例如去做检查、离开 1 小时回来),
 * 回来时工位已经被释放 → 工作台只能只读、并提示"请重新就坐",
 * 而医生本人并没有离座。这不符合实际使用场景。
 *
 * 现在的口径:
 *   · 空闲超时 → **只要求重新输入密码**(token 清掉、回登录页),**工位保留**;
 *   · 重新登录后 `roomStore.fetchMySeat()` 会自动恢复「我的工位」,可直接继续使用;
 *   · 工位的释放只发生在**用户主动操作**:手动「离座」、手动「退出登录」。
 *   · 医生真的下班忘离座的兜底:① 闭诊时系统统一清空当班工位;② 管理员可在
 *     诊室管理页「清空工位」。
 */
async function doIdleLogout(): Promise<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)
    })
}