Files
admin-web/src/layouts/AppLayout.vue
T
2026-07-30 17:59:43 +08:00

266 lines
9.3 KiB
Vue
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.
<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>