front/src/utils/auth-keepalive.ts
zzh c6a4484448 feat: 查价价卡展示贸易条款与产品分组,并优化 H5/登录与顾问体验
销售产品可配置 DDP/DDU;查价返回分组与贸易条款并展示在价卡上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:48:36 +08:00

182 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 },
)
}