Files
admin-web/src/layouts/AppLayout.vue
T

266 lines
9.3 KiB
Vue
Raw Normal View History

2026-07-30 17:59:43 +08:00
<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>