266 lines
9.3 KiB
Vue
266 lines
9.3 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import {
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
LogOut,
|
||
Menu,
|
||
} from '@lucide/vue'
|
||
import { PRODUCT_NAME } from '@chongchong/shared'
|
||
import AdminSidebarMenu from '@/components/AdminSidebarMenu.vue'
|
||
import RolePreviewSwitcher from '@/components/RolePreviewSwitcher.vue'
|
||
import { resolvePreviewTarget } from '@/router/accessDecision'
|
||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
const access = useAccessPreviewStore()
|
||
const unsaved = useUnsavedWorkStore()
|
||
const collapsed = ref(false)
|
||
const mobileMenuOpen = ref(false)
|
||
const correctingRoute = ref(false)
|
||
const roleSwitchPending = ref(false)
|
||
const loggingOut = ref(false)
|
||
const pageHeading = ref<HTMLHeadingElement | null>(null)
|
||
const announcement = ref('')
|
||
|
||
access.initializeAfterLogin()
|
||
|
||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
||
const pageDescription = computed(() => route.meta.description || '')
|
||
|
||
function currentPermission(): string | undefined {
|
||
return typeof route.meta.permission === 'string' ? route.meta.permission : undefined
|
||
}
|
||
|
||
async function replaceAndFocus(path: string) {
|
||
await router.replace(path)
|
||
await nextTick()
|
||
pageHeading.value?.focus()
|
||
}
|
||
|
||
function getRoleSwitchDecision(roleId: string) {
|
||
const candidateRole = access.enabledRoles.find((role) => role.id === roleId)
|
||
if (!candidateRole) return null
|
||
const candidate = access.previewRole(roleId)
|
||
const target = resolvePreviewTarget({
|
||
currentPath: route.path,
|
||
currentPermission: currentPermission(),
|
||
candidateCodes: candidate.codes,
|
||
firstPath: candidate.firstPath,
|
||
})
|
||
const willLeave = target !== null && target !== route.path
|
||
const blockers = unsaved.findBlocking(candidate.codes, willLeave)
|
||
const blockerSignature = blockers
|
||
.map((entry) => `${entry.key}:${entry.label}:${entry.requiredPermission ?? ''}`)
|
||
.sort()
|
||
.join('|')
|
||
|
||
return {
|
||
candidateRole,
|
||
target,
|
||
willLeave,
|
||
blockers,
|
||
signature: `${target ?? ''}:${willLeave}:${blockerSignature}`,
|
||
}
|
||
}
|
||
|
||
async function confirmRoleSwitch(decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>) {
|
||
const labels = decision.blockers.map((entry) => entry.label).join('、')
|
||
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
|
||
const message = decision.willLeave
|
||
? `切换到${decision.candidateRole.name}后,将离开${currentPage},并放弃以下未保存内容:${labels}。是否继续?`
|
||
: `切换到${decision.candidateRole.name}后,${currentPage}中的以下表单将失去保存权限:${labels}。是否继续?`
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
message,
|
||
'未保存内容',
|
||
{ confirmButtonText: '放弃并切换', cancelButtonText: '取消', type: 'warning' },
|
||
)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function requestRoleSwitch(roleId: string) {
|
||
if (access.currentRole?.id === roleId || roleSwitchPending.value) return
|
||
roleSwitchPending.value = true
|
||
|
||
try {
|
||
let decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>
|
||
let confirmedSignature: string | null = null
|
||
|
||
while (true) {
|
||
const refreshedDecision = getRoleSwitchDecision(roleId)
|
||
if (!refreshedDecision) return
|
||
decision = refreshedDecision
|
||
|
||
if (decision.blockers.length && decision.signature !== confirmedSignature) {
|
||
if (!await confirmRoleSwitch(decision)) return
|
||
confirmedSignature = decision.signature
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
|
||
correctingRoute.value = true
|
||
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
|
||
const previousRoleId = access.currentRole?.id ?? null
|
||
try {
|
||
if (!access.commitRole(roleId)) return
|
||
const navigated = Boolean(decision.target && decision.target !== route.path)
|
||
if (navigated && decision.target) {
|
||
try {
|
||
await replaceAndFocus(decision.target)
|
||
} catch {
|
||
const rolledBack = previousRoleId ? access.commitRole(previousRoleId) : false
|
||
if (rolledBack) {
|
||
ElMessage.error('权限预览切换失败,已恢复原角色')
|
||
return
|
||
}
|
||
|
||
const reconciliationTarget = resolvePreviewTarget({
|
||
currentPath: route.path,
|
||
currentPermission: currentPermission(),
|
||
candidateCodes: access.permissionCodes,
|
||
firstPath: access.firstAccessiblePath,
|
||
})
|
||
if (reconciliationTarget && reconciliationTarget !== route.path) {
|
||
try {
|
||
await replaceAndFocus(reconciliationTarget)
|
||
} catch {
|
||
// 保留当前路由,错误提示要求用户刷新以重新进入一致状态。
|
||
}
|
||
}
|
||
ElMessage.error('权限预览切换失败,当前权限状态可能已变化,请刷新页面')
|
||
return
|
||
}
|
||
}
|
||
const roleName = access.currentRole?.name ?? decision.candidateRole.name
|
||
announcement.value = navigated
|
||
? `权限预览已切换为${roleName},当前页面:${String(route.meta.title ?? currentPage)}`
|
||
: `权限预览已切换为${roleName}`
|
||
ElMessage.success(`已切换至“${roleName}”,当前仅为静态权限预览`)
|
||
} finally {
|
||
correctingRoute.value = false
|
||
}
|
||
} finally {
|
||
roleSwitchPending.value = false
|
||
}
|
||
}
|
||
|
||
async function handleLogout() {
|
||
try {
|
||
await ElMessageBox.confirm('确定退出管理后台吗?', '退出登录', {
|
||
confirmButtonText: '退出',
|
||
cancelButtonText: '取消',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
loggingOut.value = true
|
||
access.reset()
|
||
unsaved.clear()
|
||
auth.logout()
|
||
try {
|
||
await router.replace('/login')
|
||
} catch {
|
||
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
|
||
}
|
||
}
|
||
|
||
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
||
watch(
|
||
() => `${access.currentRole?.id ?? ''}:${access.permissionSignature}`,
|
||
async () => {
|
||
if (correctingRoute.value || loggingOut.value) return
|
||
const target = resolvePreviewTarget({
|
||
currentPath: route.path,
|
||
currentPermission: currentPermission(),
|
||
candidateCodes: access.permissionCodes,
|
||
firstPath: access.firstAccessiblePath,
|
||
})
|
||
if (!target || target === route.path) return
|
||
|
||
correctingRoute.value = true
|
||
try {
|
||
await replaceAndFocus(target)
|
||
const targetTitle = String(route.meta.title ?? '可访问页面')
|
||
announcement.value = target === '/403'
|
||
? `当前权限预览角色没有可访问页面,已前往${targetTitle}`
|
||
: `当前页面权限已变化,已前往${targetTitle}`
|
||
} finally {
|
||
correctingRoute.value = false
|
||
}
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
|
||
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
|
||
<aside class="app-sidebar">
|
||
<div class="brand-block">
|
||
<div class="brand-mark">CC</div>
|
||
<div v-if="!collapsed" class="brand-copy">
|
||
<strong>{{ PRODUCT_NAME }}</strong>
|
||
<span>管理后台</span>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="side-nav" aria-label="主导航">
|
||
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
|
||
</nav>
|
||
|
||
<button class="sidebar-toggle" type="button" @click="collapsed = !collapsed">
|
||
<ChevronRight v-if="collapsed" :size="18" />
|
||
<ChevronLeft v-else :size="18" />
|
||
<span v-if="!collapsed">收起导航</span>
|
||
</button>
|
||
</aside>
|
||
|
||
<div class="app-main">
|
||
<header class="app-header">
|
||
<button class="mobile-menu-button" type="button" aria-label="打开菜单" :aria-expanded="mobileMenuOpen" @click="mobileMenuOpen = true">
|
||
<Menu :size="20" />
|
||
</button>
|
||
<div class="page-heading">
|
||
<h1 ref="pageHeading" tabindex="-1">{{ pageTitle }}</h1>
|
||
<p v-if="pageDescription">{{ pageDescription }}</p>
|
||
</div>
|
||
<RolePreviewSwitcher
|
||
v-if="!access.usesServerPermissions"
|
||
:roles="access.enabledRoles"
|
||
:current-role="access.currentRole"
|
||
@select="requestRoleSwitch"
|
||
/>
|
||
<div class="header-user">
|
||
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
||
<div
|
||
class="user-copy"
|
||
:aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"
|
||
>
|
||
<strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong>
|
||
</div>
|
||
<el-button text aria-label="退出登录" @click="handleLogout">
|
||
<LogOut :size="18" />
|
||
</el-button>
|
||
</div>
|
||
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ announcement }}</p>
|
||
</header>
|
||
|
||
<main class="page-container">
|
||
<RouterView />
|
||
</main>
|
||
</div>
|
||
</div>
|
||
</template>
|