182 lines
5.4 KiB
TypeScript
182 lines
5.4 KiB
TypeScript
import router from '@/router'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import { useRbacUserStore } from '@/store/modules/rbac-user'
|
||
import { resolvePostLogoutLogin } from '@/utils/login-portal'
|
||
import { watch } from 'vue'
|
||
|
||
// 用户最后一次交互起,1小时后进入“过期/跳登录”
|
||
const KEEP_ALIVE_IDLE_MS = 60 * 60 * 1000
|
||
// 提前 1 分钟续期(尽量减少 accessToken 刚好到期导致的 401)
|
||
const RENEW_BEFORE_MS = 60 * 1000
|
||
// mousemove 等高频事件做节流,避免频繁重置计时器
|
||
const ACTION_THROTTLE_MS = 1000
|
||
|
||
let started = false
|
||
let lastActionAt = 0
|
||
|
||
let refreshTimer: ReturnType<typeof window.setTimeout> | null = null
|
||
let logoutTimer: ReturnType<typeof window.setTimeout> | null = null
|
||
|
||
function clearTimers() {
|
||
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||
if (logoutTimer) window.clearTimeout(logoutTimer)
|
||
refreshTimer = null
|
||
logoutTimer = null
|
||
}
|
||
|
||
function scheduleLogoutAt(ts: number) {
|
||
if (!ts || ts <= 0) return
|
||
|
||
const delay = ts - Date.now()
|
||
if (delay <= 0) {
|
||
void doLogout()
|
||
return
|
||
}
|
||
|
||
logoutTimer = window.setTimeout(() => {
|
||
void doLogout()
|
||
}, delay)
|
||
}
|
||
|
||
async function doLogout() {
|
||
clearTimers()
|
||
const userStore = useRbacUserStore()
|
||
const loginType = userStore.resolveLoginTypeForRedirect()
|
||
const currentPath = router.currentRoute.value.fullPath
|
||
|
||
if (userStore.isLoggedIn) {
|
||
userStore.disableKeepAlive()
|
||
userStore.logout()
|
||
}
|
||
|
||
Message.error('登录已过期,请重新登录')
|
||
const target = resolvePostLogoutLogin({
|
||
loginType,
|
||
path: currentPath,
|
||
})
|
||
if (target.path) {
|
||
router.replace({
|
||
path: target.path,
|
||
query: target.query,
|
||
})
|
||
return
|
||
}
|
||
router.replace({
|
||
name: target.name,
|
||
query: target.query,
|
||
})
|
||
}
|
||
|
||
function scheduleProactiveRefreshAt(expectedKeepAliveUntil: number) {
|
||
if (!expectedKeepAliveUntil || expectedKeepAliveUntil <= 0) return
|
||
|
||
const refreshAt = expectedKeepAliveUntil - RENEW_BEFORE_MS
|
||
const delay = refreshAt - Date.now()
|
||
|
||
if (delay <= 0) {
|
||
// 到点了就立即尝试续期
|
||
void (async () => {
|
||
await tryRefresh(expectedKeepAliveUntil)
|
||
})()
|
||
return
|
||
}
|
||
|
||
refreshTimer = window.setTimeout(() => {
|
||
void tryRefresh(expectedKeepAliveUntil)
|
||
}, delay)
|
||
}
|
||
|
||
async function tryRefresh(expectedKeepAliveUntil: number) {
|
||
const userStore = useRbacUserStore()
|
||
|
||
// 如果期间用户又做了交互,倒计时已被重置,则旧定时器作废
|
||
if (userStore.getKeepAliveUntil() !== expectedKeepAliveUntil) return
|
||
|
||
if (!userStore.isKeepAliveActive()) return
|
||
const success = await userStore.refreshAccessToken()
|
||
if (!success) {
|
||
// 刷新失败时立即兜底登出,避免仅清空 token 但不跳转
|
||
void doLogout()
|
||
}
|
||
}
|
||
|
||
function onUserActivity() {
|
||
const now = Date.now()
|
||
if (now - lastActionAt < ACTION_THROTTLE_MS) return
|
||
lastActionAt = now
|
||
|
||
const userStore = useRbacUserStore()
|
||
if (!userStore.isLoggedIn) return
|
||
|
||
// 登录页不做保活
|
||
if (router.currentRoute.value.name === 'login' || router.currentRoute.value.name === 'adminLogin') return
|
||
|
||
// 开启并重置保活倒计时
|
||
userStore.enableKeepAlive()
|
||
|
||
clearTimers()
|
||
|
||
const newKeepAliveUntil = userStore.getKeepAliveUntil() || (now + KEEP_ALIVE_IDLE_MS)
|
||
scheduleLogoutAt(newKeepAliveUntil)
|
||
scheduleProactiveRefreshAt(newKeepAliveUntil)
|
||
}
|
||
|
||
function attachListeners() {
|
||
// 仅使用 window 监听,避免 window+document 重复监听导致的双倍事件开销
|
||
const capture = true
|
||
|
||
const passiveCapture: AddEventListenerOptions = { capture: true, passive: true }
|
||
|
||
// Window
|
||
window.addEventListener('click', onUserActivity, capture)
|
||
window.addEventListener('mousemove', onUserActivity, capture)
|
||
window.addEventListener('keydown', onUserActivity, capture)
|
||
window.addEventListener('scroll', onUserActivity, capture)
|
||
window.addEventListener('wheel', onUserActivity, passiveCapture)
|
||
window.addEventListener('touchstart', onUserActivity, capture)
|
||
window.addEventListener('touchmove', onUserActivity, passiveCapture)
|
||
window.addEventListener('pointerdown', onUserActivity, capture)
|
||
window.addEventListener('pointermove', onUserActivity, capture)
|
||
window.addEventListener('focusin', onUserActivity, capture)
|
||
window.addEventListener('input', onUserActivity, capture)
|
||
}
|
||
|
||
export function initAuthKeepAlive() {
|
||
if (started) return
|
||
started = true
|
||
|
||
const userStore = useRbacUserStore()
|
||
clearTimers()
|
||
|
||
// 未发生交互前:若已从 storage 恢复保活窗,则按 keepAliveUntil 调度;否则仅 access 到期登出
|
||
attachListeners()
|
||
|
||
// 路由切换也视为一次用户活动(兜底:至少点菜单/跳转会续期)
|
||
// 使用 setTimeout(0) 延后执行,避免阻塞路由切换完成;若近期已有交互(如点击菜单)则跳过
|
||
router.afterEach(() => {
|
||
if (Date.now() - lastActionAt < 500) return
|
||
setTimeout(() => onUserActivity(), 0)
|
||
})
|
||
|
||
watch(
|
||
() => userStore.isLoggedIn,
|
||
(isLoggedIn) => {
|
||
clearTimers()
|
||
if (!isLoggedIn) return
|
||
|
||
if (userStore.isKeepAliveActive()) {
|
||
const until = userStore.getKeepAliveUntil()
|
||
scheduleLogoutAt(until)
|
||
scheduleProactiveRefreshAt(until)
|
||
return
|
||
}
|
||
|
||
// 登录后的“无操作兜底”:未交互前不续期,直接在 accessToken 真实过期时跳登录
|
||
const accessTokenRealExpiresAt = userStore.getAccessTokenRealExpiresAt()
|
||
scheduleLogoutAt(accessTokenRealExpiresAt)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
}
|
||
|