front/src/store/modules/rbac-user.ts
zzh 2dd3e656a3
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m58s
feat: H5 访客可逛四 Tab,登录门禁与微信注册引导优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 10:22:58 +08:00

533 lines
16 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 { 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,
}
})