233 lines
6.7 KiB
TypeScript
233 lines
6.7 KiB
TypeScript
import { computed, ref } from 'vue'
|
|
import { defineStore } from 'pinia'
|
|
|
|
import {
|
|
changePassword as changePasswordRequest,
|
|
getCurrentAuthorization,
|
|
getCurrentUser,
|
|
login as loginRequest,
|
|
logout as logoutRequest,
|
|
} from '@/api/auth'
|
|
import type {
|
|
AuthenticatedUser,
|
|
LoginRequest,
|
|
LoginResponse,
|
|
CurrentAuthorization,
|
|
PermissionResourceNode,
|
|
PasswordChangeRequest,
|
|
} from '@/types/auth'
|
|
import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
|
|
|
|
export const useLoginStore = defineStore('login', () => {
|
|
const storedSession = readStoredAuthSession()
|
|
const token = ref(storedSession?.token ?? '')
|
|
const expiresAt = ref(storedSession?.expiresAt ?? '')
|
|
const user = ref<AuthenticatedUser | null>(storedSession?.user ?? null)
|
|
const authorization = ref<CurrentAuthorization | null>(null)
|
|
const currentUserReady = ref(false)
|
|
let currentUserRequest: Promise<AuthenticatedUser> | null = null
|
|
let authorizationSyncedAt = 0
|
|
|
|
const isLoggedIn = computed(() => Boolean(token.value))
|
|
// Data scope belongs to roles now; it no longer determines whether a user can write.
|
|
const isReadOnly = computed(() => false)
|
|
const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户')
|
|
const department = computed(() => user.value?.departmentName || '医签通用户')
|
|
|
|
function hasPermission(required: string | string[]) {
|
|
const requiredPermissions = Array.isArray(required) ? required : [required]
|
|
|
|
if (!requiredPermissions.length) {
|
|
return true
|
|
}
|
|
|
|
const permissions = authorization.value?.permissions ?? user.value?.permissions ?? []
|
|
|
|
return requiredPermissions.every(
|
|
(permission) => permissions.includes(permission) || permissions.includes('*'),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 已下线菜单:后端菜单资源里仍然下发、但前端已经不再提供页面的菜单。
|
|
*
|
|
* 「系统管理 → 报表分析」(`/report/index`)已整体下线,后端菜单资源暂时无法同步下架,
|
|
* 所以在前端统一屏蔽:侧边栏不展示,路由守卫也会把它当成无权限菜单重定向走,
|
|
* 避免出现点进去只有「前端暂未接入组件」占位页的死链。
|
|
* 后端下架该菜单资源后,这个集合可以删掉。
|
|
*/
|
|
const RETIRED_MENU_PATHS = new Set(['/report/index'])
|
|
|
|
function isRetiredMenu(menu: PermissionResourceNode) {
|
|
return RETIRED_MENU_PATHS.has(menu.routePath?.replace(/\/$/, '') ?? '')
|
|
}
|
|
|
|
const accessibleMenus = computed(() =>
|
|
(authorization.value?.navigation ?? [])
|
|
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
|
.flatMap((directory) => directory.children ?? [])
|
|
.filter((menu) => !isRetiredMenu(menu)),
|
|
)
|
|
|
|
/** 侧边栏用的导航树:结构与后端一致,只剔除已下线菜单(空目录由使用方过滤) */
|
|
const visibleNavigation = computed(() =>
|
|
(authorization.value?.navigation ?? [])
|
|
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
|
.map((directory) => ({
|
|
...directory,
|
|
children: (directory.children ?? []).filter((menu) => !isRetiredMenu(menu)),
|
|
})),
|
|
)
|
|
|
|
function menuForPath(path: string): PermissionResourceNode | null {
|
|
const normalizedPath = path.replace(/\/$/, '') || '/'
|
|
return (
|
|
accessibleMenus.value.find((menu) => menu.routePath?.replace(/\/$/, '') === normalizedPath) ??
|
|
null
|
|
)
|
|
}
|
|
|
|
const firstAuthorizedPath = computed(
|
|
() =>
|
|
accessibleMenus.value.find(
|
|
(menu) =>
|
|
menu.visible && menu.status === 'ENABLED' && menu.code && hasPermission(menu.code),
|
|
)?.routePath ?? null,
|
|
)
|
|
|
|
function hasWritePermission(required: string | string[]) {
|
|
return !isReadOnly.value && hasPermission(required)
|
|
}
|
|
|
|
function setSession(session: LoginResponse) {
|
|
saveAuthSession(session)
|
|
token.value = session.token
|
|
expiresAt.value = session.expiresAt
|
|
user.value = session.user
|
|
authorization.value = null
|
|
authorizationSyncedAt = 0
|
|
currentUserReady.value = false
|
|
}
|
|
|
|
function setCurrentUser(nextUser: AuthenticatedUser) {
|
|
user.value = nextUser
|
|
|
|
if (token.value) {
|
|
saveAuthSession({
|
|
token: token.value,
|
|
expiresAt: expiresAt.value,
|
|
user: nextUser,
|
|
})
|
|
}
|
|
}
|
|
|
|
function clearSession() {
|
|
clearAuthStorage()
|
|
token.value = ''
|
|
expiresAt.value = ''
|
|
user.value = null
|
|
authorization.value = null
|
|
authorizationSyncedAt = 0
|
|
currentUserReady.value = false
|
|
currentUserRequest = null
|
|
}
|
|
|
|
async function login(payload: LoginRequest) {
|
|
const session = await loginRequest(payload)
|
|
setSession(session)
|
|
return session
|
|
}
|
|
|
|
async function refreshCurrentUser() {
|
|
const [currentUser, currentAuthorization] = await Promise.all([
|
|
getCurrentUser(),
|
|
getCurrentAuthorization(),
|
|
])
|
|
const effectiveUser = { ...currentUser, permissions: currentAuthorization.permissions }
|
|
authorization.value = currentAuthorization
|
|
authorizationSyncedAt = Date.now()
|
|
setCurrentUser(effectiveUser)
|
|
return effectiveUser
|
|
}
|
|
|
|
async function refreshAuthorization() {
|
|
const currentAuthorization = await getCurrentAuthorization()
|
|
authorization.value = currentAuthorization
|
|
authorizationSyncedAt = Date.now()
|
|
if (user.value) {
|
|
setCurrentUser({ ...user.value, permissions: currentAuthorization.permissions })
|
|
}
|
|
return currentAuthorization
|
|
}
|
|
|
|
async function ensureCurrentUser() {
|
|
if (!token.value) {
|
|
return null
|
|
}
|
|
|
|
if (
|
|
currentUserReady.value &&
|
|
user.value &&
|
|
authorization.value &&
|
|
Date.now() - authorizationSyncedAt < 10_000
|
|
) {
|
|
return user.value
|
|
}
|
|
|
|
if (!currentUserRequest) {
|
|
currentUserRequest = refreshCurrentUser()
|
|
.then((currentUser) => {
|
|
currentUserReady.value = true
|
|
return currentUser
|
|
})
|
|
.catch((error: unknown) => {
|
|
currentUserReady.value = false
|
|
throw error
|
|
})
|
|
.finally(() => {
|
|
currentUserRequest = null
|
|
})
|
|
}
|
|
|
|
return currentUserRequest
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await logoutRequest()
|
|
} finally {
|
|
clearSession()
|
|
}
|
|
}
|
|
|
|
async function changePassword(payload: PasswordChangeRequest) {
|
|
await changePasswordRequest(payload)
|
|
// 服务端会递增 authEpoch;当前令牌随即失效,必须使用新密码重新登录。
|
|
clearSession()
|
|
}
|
|
|
|
return {
|
|
token,
|
|
expiresAt,
|
|
user,
|
|
authorization,
|
|
accessibleMenus,
|
|
visibleNavigation,
|
|
isLoggedIn,
|
|
isReadOnly,
|
|
userName,
|
|
department,
|
|
login,
|
|
refreshCurrentUser,
|
|
ensureCurrentUser,
|
|
refreshAuthorization,
|
|
menuForPath,
|
|
firstAuthorizedPath,
|
|
hasPermission,
|
|
hasWritePermission,
|
|
changePassword,
|
|
logout,
|
|
clearSession,
|
|
}
|
|
})
|