All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m58s
Co-authored-by: Cursor <cursoragent@cursor.com>
533 lines
16 KiB
TypeScript
533 lines
16 KiB
TypeScript
import { defineStore } from 'pinia'
|
||
import { ref, computed } from 'vue'
|
||
import {
|
||
AuthService,
|
||
setAuthToken,
|
||
clearAuthToken,
|
||
OmsLoginType,
|
||
type LoginResultDto,
|
||
type UserInfoDto,
|
||
type MenuTreeDto,
|
||
} from '@/api/rbac'
|
||
import { handleApiError } from '@/utils/rbac-error-handler'
|
||
import { getLoginRouteNameByType } from '@/utils/login-portal'
|
||
import { getLoginTypeFromAccessToken } from '@/utils/jwt'
|
||
import {
|
||
markSessionEndedByUser,
|
||
resetSessionExpiredGuard,
|
||
} from '@/utils/auth-session-guard'
|
||
|
||
// Token 存储 key
|
||
const TOKEN_KEY = 'rbac_access_token'
|
||
const REFRESH_TOKEN_KEY = 'rbac_refresh_token'
|
||
const TOKEN_EXPIRES_KEY = 'rbac_token_expires_at'
|
||
const USER_INFO_KEY = 'rbac_userInfo'
|
||
const LAST_ACTIVITY_KEY = 'rbac_last_activity'
|
||
const MENUS_SESSION_KEY = 'rbac_user_menus'
|
||
const MENUS_USER_KEY = 'rbac_user_menus_uid'
|
||
// 兼容旧代码的 key
|
||
const OLD_TOKEN_KEY = 'rbac_token'
|
||
const LOGIN_TYPE_KEY = 'rbac_login_type'
|
||
const SSO_SESSION_FLAG = 'rbac_sso_session'
|
||
|
||
function isSsoSessionActive(): boolean {
|
||
return sessionStorage.getItem(SSO_SESSION_FLAG) === '1'
|
||
}
|
||
|
||
function getAuthStorage(): Storage {
|
||
return isSsoSessionActive() ? sessionStorage : localStorage
|
||
}
|
||
|
||
function readAuthItem(key: string): string {
|
||
if (isSsoSessionActive()) {
|
||
return sessionStorage.getItem(key) || ''
|
||
}
|
||
return localStorage.getItem(key) || ''
|
||
}
|
||
|
||
// 1小时无操作则认为会话应过期(前端主导的“空闲超时”,用于控制是否允许续期)
|
||
const KEEP_ALIVE_IDLE_MS = 60 * 60 * 1000
|
||
// 注意:tokenExpiresAt 在 saveTokens 里会提前 5 分钟到期刷新(当前实现是 -300s)
|
||
// 因此“真实 accessToken 过期时间”= tokenExpiresAt + 5min
|
||
const ACCESS_TOKEN_REAL_EXPIRES_AT_OFFSET_MS = 5 * 60 * 1000
|
||
|
||
export const useRbacUserStore = defineStore('rbacUser', () => {
|
||
const persistInSession = ref(isSsoSessionActive())
|
||
const accessToken = ref<string>(readAuthItem(TOKEN_KEY))
|
||
const refreshToken = ref<string>(readAuthItem(REFRESH_TOKEN_KEY))
|
||
const tokenExpiresAt = ref<number>(Number(readAuthItem(TOKEN_EXPIRES_KEY)) || 0)
|
||
// 仅在用户发生交互后才会开启;开启后允许续期,并在倒计时到达时登出
|
||
const keepAliveUntil = ref<number>(0)
|
||
const savedUserInfo = getAuthStorage().getItem(USER_INFO_KEY)
|
||
const userInfo = ref<UserInfoDto | null>(savedUserInfo ? JSON.parse(savedUserInfo) : null)
|
||
const loginPortalType = ref<OmsLoginType>(readStoredLoginType())
|
||
|
||
/** get-current-user 单飞,避免 F5 多处并发重复请求 */
|
||
let currentUserInflight: Promise<UserInfoDto> | null = null
|
||
|
||
const permissions = computed(() => userInfo.value?.permissions || [])
|
||
const menus = computed(() => userInfo.value?.menus || [])
|
||
const isLoggedIn = computed(() => !!accessToken.value)
|
||
// 兼容旧代码
|
||
const token = computed(() => accessToken.value)
|
||
|
||
/** 持久化瘦身:menus 仅留内存 / session,避免 localStorage 写入过大 JSON */
|
||
function persistUserInfo(info: UserInfoDto | null) {
|
||
if (!info) {
|
||
getAuthStorage().removeItem(USER_INFO_KEY)
|
||
return
|
||
}
|
||
const slim: UserInfoDto = { ...info }
|
||
delete slim.menus
|
||
getAuthStorage().setItem(USER_INFO_KEY, JSON.stringify(slim))
|
||
}
|
||
|
||
function persistMenusToSession(info: UserInfoDto) {
|
||
if (!info.id || !info.menus?.length) return
|
||
try {
|
||
sessionStorage.setItem(MENUS_USER_KEY, String(info.id))
|
||
sessionStorage.setItem(MENUS_SESSION_KEY, JSON.stringify(info.menus))
|
||
} catch {
|
||
// quota / private mode:忽略,不影响主流程
|
||
}
|
||
}
|
||
|
||
function clearMenusSession() {
|
||
sessionStorage.removeItem(MENUS_SESSION_KEY)
|
||
sessionStorage.removeItem(MENUS_USER_KEY)
|
||
}
|
||
|
||
/** 同标签页 F5:从 sessionStorage 恢复 menus,可跳过 get-current-user */
|
||
function restoreMenusFromSession() {
|
||
if (!userInfo.value?.id) return
|
||
const uid = sessionStorage.getItem(MENUS_USER_KEY)
|
||
if (uid !== String(userInfo.value.id)) return
|
||
const raw = sessionStorage.getItem(MENUS_SESSION_KEY)
|
||
if (!raw) return
|
||
try {
|
||
const cached = JSON.parse(raw) as MenuTreeDto[]
|
||
if (Array.isArray(cached) && cached.length > 0) {
|
||
userInfo.value = { ...userInfo.value, menus: cached }
|
||
}
|
||
} catch {
|
||
clearMenusSession()
|
||
}
|
||
}
|
||
|
||
function setUserInfo(info: UserInfoDto) {
|
||
userInfo.value = info
|
||
persistUserInfo(info)
|
||
persistMenusToSession(info)
|
||
}
|
||
|
||
function readStoredLoginTypeRaw(): OmsLoginType | null {
|
||
const raw = getAuthStorage().getItem(LOGIN_TYPE_KEY) ?? localStorage.getItem(LOGIN_TYPE_KEY)
|
||
if (raw === String(OmsLoginType.Customer)) return OmsLoginType.Customer
|
||
if (raw === String(OmsLoginType.User)) return OmsLoginType.User
|
||
return null
|
||
}
|
||
|
||
function readStoredLoginType(): OmsLoginType {
|
||
return readStoredLoginTypeRaw() ?? OmsLoginType.User
|
||
}
|
||
|
||
function setLoginPortalType(type: OmsLoginType, options?: { sessionOnly?: boolean }) {
|
||
loginPortalType.value = type
|
||
if (options?.sessionOnly) {
|
||
persistInSession.value = true
|
||
sessionStorage.setItem(SSO_SESSION_FLAG, '1')
|
||
sessionStorage.setItem(LOGIN_TYPE_KEY, String(type))
|
||
return
|
||
}
|
||
persistInSession.value = false
|
||
sessionStorage.removeItem(SSO_SESSION_FLAG)
|
||
localStorage.setItem(LOGIN_TYPE_KEY, String(type))
|
||
}
|
||
|
||
/** 从 JWT 校正门户类型(兼容旧会话无 rbac_login_type) */
|
||
function syncLoginPortalTypeFromToken(token?: string | null) {
|
||
const fromJwt = getLoginTypeFromAccessToken(token || accessToken.value)
|
||
if (fromJwt == null) return
|
||
if (loginPortalType.value === fromJwt && readStoredLoginTypeRaw() === fromJwt) return
|
||
setLoginPortalType(fromJwt, { sessionOnly: persistInSession.value })
|
||
}
|
||
|
||
/**
|
||
* 登出/过期回跳用的主体类型。
|
||
* 优先 JWT claim → 显式存储 → 内存门户类型(登录时写入,须可区分两端)。
|
||
*/
|
||
function resolveLoginTypeForRedirect(token?: string | null): OmsLoginType {
|
||
const fromJwt = getLoginTypeFromAccessToken(token || accessToken.value)
|
||
if (fromJwt != null) return fromJwt
|
||
const stored = readStoredLoginTypeRaw()
|
||
if (stored != null) return stored
|
||
return loginPortalType.value
|
||
}
|
||
|
||
function getLoginRouteName(): string {
|
||
return getLoginRouteNameByType(loginPortalType.value)
|
||
}
|
||
|
||
function isCustomerPortal(): boolean {
|
||
return loginPortalType.value === OmsLoginType.Customer
|
||
}
|
||
|
||
// 保存 Token 信息
|
||
function saveTokens(result: LoginResultDto, options?: { sessionOnly?: boolean }) {
|
||
if (!result.accessToken) {
|
||
return
|
||
}
|
||
|
||
resetSessionExpiredGuard()
|
||
accessToken.value = result.accessToken
|
||
refreshToken.value = result.refreshToken || ''
|
||
const expiresInSeconds =
|
||
typeof result.expiresIn === 'number' && result.expiresIn > 0 ? result.expiresIn : 3600
|
||
tokenExpiresAt.value = Date.now() + (expiresInSeconds - 300) * 1000
|
||
|
||
const storage = options?.sessionOnly ? sessionStorage : localStorage
|
||
if (options?.sessionOnly) {
|
||
persistInSession.value = true
|
||
sessionStorage.setItem(SSO_SESSION_FLAG, '1')
|
||
} else {
|
||
persistInSession.value = false
|
||
sessionStorage.removeItem(SSO_SESSION_FLAG)
|
||
}
|
||
|
||
storage.setItem(TOKEN_KEY, accessToken.value)
|
||
if (refreshToken.value) {
|
||
storage.setItem(REFRESH_TOKEN_KEY, refreshToken.value)
|
||
}
|
||
storage.setItem(TOKEN_EXPIRES_KEY, String(tokenExpiresAt.value))
|
||
|
||
setAuthToken(accessToken.value)
|
||
|
||
if (!options?.sessionOnly) {
|
||
// 兼容旧代码及 auth utils 的 isLogin 检查
|
||
localStorage.setItem(OLD_TOKEN_KEY, accessToken.value)
|
||
localStorage.setItem('token', accessToken.value)
|
||
}
|
||
}
|
||
|
||
// 真实 accessToken 过期时间(用于“无操作也要按时间跳登录”的兜底)
|
||
function getAccessTokenRealExpiresAt(): number {
|
||
if (!tokenExpiresAt.value) return 0
|
||
return tokenExpiresAt.value + ACCESS_TOKEN_REAL_EXPIRES_AT_OFFSET_MS
|
||
}
|
||
|
||
function isKeepAliveActive(): boolean {
|
||
return keepAliveUntil.value > 0 && Date.now() < keepAliveUntil.value
|
||
}
|
||
|
||
function persistLastActivity(at = Date.now()) {
|
||
getAuthStorage().setItem(LAST_ACTIVITY_KEY, String(at))
|
||
}
|
||
|
||
function enableKeepAlive() {
|
||
const now = Date.now()
|
||
keepAliveUntil.value = now + KEEP_ALIVE_IDLE_MS
|
||
persistLastActivity(now)
|
||
}
|
||
|
||
function disableKeepAlive() {
|
||
keepAliveUntil.value = 0
|
||
}
|
||
|
||
function getKeepAliveUntil(): number {
|
||
return keepAliveUntil.value
|
||
}
|
||
|
||
/** F5 后根据上次交互时间恢复保活窗,允许冷启动续 token */
|
||
function restoreKeepAliveFromStorage() {
|
||
const raw = Number(readAuthItem(LAST_ACTIVITY_KEY) || 0)
|
||
if (!raw || Number.isNaN(raw)) return
|
||
const until = raw + KEEP_ALIVE_IDLE_MS
|
||
if (Date.now() < until) {
|
||
keepAliveUntil.value = until
|
||
}
|
||
}
|
||
|
||
// 检查 Token 是否需要刷新
|
||
function isTokenExpiringSoon(): boolean {
|
||
return tokenExpiresAt.value > 0 && Date.now() >= tokenExpiresAt.value
|
||
}
|
||
|
||
function normalizeRefreshToken(raw: string): string | null {
|
||
let rt = raw
|
||
if (rt.startsWith('Bearer ')) {
|
||
rt = rt.slice('Bearer '.length)
|
||
}
|
||
const parts = rt.split('.')
|
||
if (parts.length !== 3 || parts.some((p) => !p || p.length < 8)) {
|
||
return null
|
||
}
|
||
if (rt.includes('[') || rt.includes(']') || rt.includes('{') || rt.includes('}')) {
|
||
return null
|
||
}
|
||
return rt
|
||
}
|
||
|
||
/**
|
||
* 刷新 Token。
|
||
* @param options.force 冷启动/首请求:即使 keepAlive 未恢复也允许用 refreshToken 换新 access
|
||
*/
|
||
async function refreshAccessToken(options?: { force?: boolean }): Promise<boolean> {
|
||
if (!options?.force && !isKeepAliveActive()) {
|
||
return false
|
||
}
|
||
if (!refreshToken.value) {
|
||
return false
|
||
}
|
||
|
||
const rt = normalizeRefreshToken(refreshToken.value)
|
||
if (!rt) {
|
||
console.warn('[rbac] refreshToken format invalid, skip refresh')
|
||
logout()
|
||
return false
|
||
}
|
||
|
||
try {
|
||
const result = await AuthService.postRbacAuthRefreshToken({ refreshToken: rt })
|
||
if (result?.accessToken) {
|
||
saveTokens(result, { sessionOnly: persistInSession.value })
|
||
return true
|
||
}
|
||
return false
|
||
} catch {
|
||
logout()
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 登录(OMS 管理端默认 type=User)
|
||
// 失败不弹全局 Message,由登录页内联 errorBox 展示
|
||
async function login(account: string, password: string, loginType: OmsLoginType = OmsLoginType.User) {
|
||
try {
|
||
const result = await AuthService.postRbacAuthLogin({
|
||
account,
|
||
password,
|
||
type: loginType,
|
||
})
|
||
saveTokens(result)
|
||
setLoginPortalType(loginType)
|
||
enableKeepAlive()
|
||
await hydrateUserAfterLogin(result)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 微信扫码登录成功后写入 Token 并拉取用户信息 */
|
||
async function loginByWxResult(result: LoginResultDto): Promise<boolean> {
|
||
try {
|
||
if (!result?.accessToken) {
|
||
return false
|
||
}
|
||
saveTokens(result)
|
||
setLoginPortalType(OmsLoginType.Customer)
|
||
enableKeepAlive()
|
||
await hydrateUserAfterLogin(result)
|
||
return true
|
||
} catch (error) {
|
||
handleApiError(error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 管理员代登客户门户:Token 仅存 sessionStorage,避免覆盖管理端登录态 */
|
||
async function loginBySsoResult(result: LoginResultDto): Promise<boolean> {
|
||
try {
|
||
if (!result?.accessToken) {
|
||
return false
|
||
}
|
||
saveTokens(result, { sessionOnly: true })
|
||
setLoginPortalType(OmsLoginType.Customer, { sessionOnly: true })
|
||
enableKeepAlive()
|
||
await hydrateUserAfterLogin(result)
|
||
return true
|
||
} catch (error) {
|
||
handleApiError(error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 登录响应若已带 userInfo 则直接用,否则再打 get-current-user */
|
||
async function hydrateUserAfterLogin(result: LoginResultDto) {
|
||
const embedded = result.userInfo
|
||
if (embedded?.id) {
|
||
setUserInfo(embedded)
|
||
return embedded
|
||
}
|
||
return getCurrentUser()
|
||
}
|
||
|
||
// 获取当前用户信息(基于 Token,无需 userId 参数)
|
||
async function getCurrentUser() {
|
||
try {
|
||
if (isTokenExpiringSoon()) {
|
||
// 冷启动可能尚未 restore keepAlive,强制允许一次续期
|
||
const ok = await refreshAccessToken({ force: true })
|
||
if (!ok && isTokenExpiringSoon()) {
|
||
logout()
|
||
throw new Error('登录已过期')
|
||
}
|
||
}
|
||
|
||
const data = await AuthService.getRbacAuthGetCurrentUser()
|
||
setUserInfo(data)
|
||
return data
|
||
} catch (error) {
|
||
logout()
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确保有完整用户信息(含 menus)。
|
||
* - 内存已有 menus 且非 force:直接返回
|
||
* - 多处并发共用同一 Promise
|
||
*/
|
||
async function ensureCurrentUser(options?: { force?: boolean }): Promise<UserInfoDto | null> {
|
||
if (!accessToken.value) {
|
||
return null
|
||
}
|
||
if (
|
||
!options?.force &&
|
||
userInfo.value?.id &&
|
||
(userInfo.value.menus?.length ?? 0) > 0
|
||
) {
|
||
return userInfo.value
|
||
}
|
||
if (!currentUserInflight) {
|
||
currentUserInflight = getCurrentUser().finally(() => {
|
||
currentUserInflight = null
|
||
})
|
||
}
|
||
return currentUserInflight
|
||
}
|
||
|
||
/** bootstrap 提前发起,与 i18n 并行 */
|
||
function prefetchCurrentUser() {
|
||
if (!accessToken.value) return
|
||
if ((userInfo.value?.menus?.length ?? 0) > 0) return
|
||
void ensureCurrentUser()
|
||
}
|
||
|
||
// 刷新用户信息(强制从服务器获取最新数据)
|
||
async function refreshUserInfo() {
|
||
try {
|
||
const data = await AuthService.getRbacAuthGetCurrentUser()
|
||
setUserInfo(data)
|
||
return data
|
||
} catch (error) {
|
||
handleApiError(error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 登出:保留 loginPortalType / LOGIN_TYPE,确保过期回跳仍进对应门户登录页
|
||
function logout() {
|
||
// 先标记会话结束,避免页面卸载前 in-flight 请求 401 连弹「登录已过期」
|
||
markSessionEndedByUser()
|
||
accessToken.value = ''
|
||
refreshToken.value = ''
|
||
tokenExpiresAt.value = 0
|
||
keepAliveUntil.value = 0
|
||
userInfo.value = null
|
||
currentUserInflight = null
|
||
|
||
// SSO 会话结束前,把客户门户类型落到 localStorage,避免仅存在 session 时丢失
|
||
if (persistInSession.value && loginPortalType.value === OmsLoginType.Customer) {
|
||
localStorage.setItem(LOGIN_TYPE_KEY, String(OmsLoginType.Customer))
|
||
}
|
||
persistInSession.value = false
|
||
|
||
localStorage.removeItem(TOKEN_KEY)
|
||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||
localStorage.removeItem(TOKEN_EXPIRES_KEY)
|
||
localStorage.removeItem(USER_INFO_KEY)
|
||
localStorage.removeItem(LAST_ACTIVITY_KEY)
|
||
// 兼容旧代码
|
||
localStorage.removeItem(OLD_TOKEN_KEY)
|
||
localStorage.removeItem('token')
|
||
localStorage.removeItem('userInfo')
|
||
|
||
sessionStorage.removeItem(SSO_SESSION_FLAG)
|
||
sessionStorage.removeItem(TOKEN_KEY)
|
||
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||
sessionStorage.removeItem(TOKEN_EXPIRES_KEY)
|
||
sessionStorage.removeItem(USER_INFO_KEY)
|
||
sessionStorage.removeItem(LAST_ACTIVITY_KEY)
|
||
clearMenusSession()
|
||
|
||
clearAuthToken()
|
||
}
|
||
|
||
// 初始化:恢复登录状态、保活窗、同页菜单缓存
|
||
function initAuth() {
|
||
if (accessToken.value) {
|
||
setAuthToken(accessToken.value)
|
||
syncLoginPortalTypeFromToken(accessToken.value)
|
||
}
|
||
restoreKeepAliveFromStorage()
|
||
restoreMenusFromSession()
|
||
}
|
||
|
||
initAuth()
|
||
|
||
// 检查权限(与 @/utils/rbac-permission 规则一致:资源码 ↔ Query 互通)
|
||
function hasPermission(permission: string): boolean {
|
||
if (!permission?.trim()) return true
|
||
// Admin 角色默认全权限
|
||
if (hasRole('Admin')) return true
|
||
const code = permission.trim()
|
||
const granted = permissions.value
|
||
if (granted.includes(code)) return true
|
||
if (/\.Query$/i.test(code)) {
|
||
const resource = code.replace(/\.Query$/i, '')
|
||
if (resource && granted.includes(resource)) return true
|
||
} else if (granted.includes(`${code}.Query`)) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 检查角色
|
||
function hasRole(roleCode: string): boolean {
|
||
return userInfo.value?.roles?.some((r) => r.code === roleCode) || false
|
||
}
|
||
|
||
return {
|
||
token,
|
||
accessToken,
|
||
refreshToken,
|
||
userInfo,
|
||
permissions,
|
||
menus,
|
||
isLoggedIn,
|
||
loginPortalType,
|
||
getLoginRouteName,
|
||
isCustomerPortal,
|
||
resolveLoginTypeForRedirect,
|
||
syncLoginPortalTypeFromToken,
|
||
getAccessTokenRealExpiresAt,
|
||
isKeepAliveActive,
|
||
enableKeepAlive,
|
||
disableKeepAlive,
|
||
getKeepAliveUntil,
|
||
login,
|
||
loginByWxResult,
|
||
loginBySsoResult,
|
||
getCurrentUser,
|
||
ensureCurrentUser,
|
||
prefetchCurrentUser,
|
||
refreshUserInfo,
|
||
logout,
|
||
hasPermission,
|
||
hasRole,
|
||
refreshAccessToken,
|
||
isTokenExpiringSoon,
|
||
}
|
||
})
|