Files
admin-web/src/app/layouts/AppLayout.vue
T
2026-09-15 09:58:15 +08:00

223 lines
11 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 '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, FileText, LogOut, Mail, Menu, ShieldCheck } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
import AdminSidebarMenu from '@/app/components/AdminSidebarMenu.vue'
import WorkspaceTabs from '@/app/components/WorkspaceTabs.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { canAccessWorkspaceRoute, workspaceRouteId, workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
import { MAX_CACHED_WORKSPACE_VIEWS, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const workspace = useWorkspaceTabsStore()
const mobileViewportQuery = '(max-width: 720px)'
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const isMobileViewport = ref(false)
let mobileViewportMedia: MediaQueryList | undefined
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
const menuButtonLabel = computed(() => {
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
return collapsed.value ? '展开导航' : '收起导航'
})
const menuExpanded = computed(() => (
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
))
const canOpenNotificationManagement = computed(() => (
access.can('platform:notification:view')
|| access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
))
access.initializeAfterLogin()
workspace.initialize(auth.user?.id || auth.user?.account || '')
function syncCurrentRoute() {
const candidate = workspaceTabCandidate(route)
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
}
async function pruneInaccessibleTabs() {
const inaccessible = workspace.tabs.filter((tab) => !canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
const discardedDirtyWork = inaccessible.flatMap((tab) => unsaved.findForWorkspace(tab.id))
if (discardedDirtyWork.length) {
await ElMessageBox.alert(
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
'权限已变化',
{ confirmButtonText: '知道了', type: 'warning', customClass: 'admin-density-confirm' },
).catch(() => undefined)
}
const previousActiveId = workspace.activeId
const result = workspace.prune((tab) => canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
if (result.removedIds.length === 0) return
unsaved.removeWorkspaces(result.removedIds)
if (!previousActiveId || !result.removedIds.includes(previousActiveId)) return
const destination = result.fallback?.path || access.firstAccessiblePath || '/403'
if (destination !== route.fullPath) await router.replace(destination)
else syncCurrentRoute()
}
async function handleLogout() {
try {
const dirtyLabels = unsaved.entries.filter((entry) => entry.dirty).map((entry) => entry.label)
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
confirmButtonText: '退出',
customClass: 'admin-density-confirm',
cancelButtonText: '取消',
})
} catch {
return
}
access.reset()
unsaved.clear()
workspace.reset(true)
try {
await auth.logout()
} catch (error) {
const details = getApiErrorDetails(error, '服务器会话撤销失败')
const trackingId = details.requestId || details.traceId
ElMessage.warning(`本地已退出,但服务器会话撤销失败${trackingId ? `(追踪标识:${trackingId})` : ''}`)
}
try {
await router.replace('/login')
} catch {
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
}
}
function handleMenuToggle() {
if (isMobileViewport.value) {
mobileMenuOpen.value = !mobileMenuOpen.value
return
}
collapsed.value = !collapsed.value
}
function syncMobileViewport(event: MediaQueryList | MediaQueryListEvent) {
isMobileViewport.value = event.matches
if (!event.matches) mobileMenuOpen.value = false
}
function openNotificationPage(path: string) { void router.push(path) }
onMounted(() => {
mobileViewportMedia = window.matchMedia(mobileViewportQuery)
syncMobileViewport(mobileViewportMedia)
mobileViewportMedia.addEventListener('change', syncMobileViewport)
})
onBeforeUnmount(() => mobileViewportMedia?.removeEventListener('change', syncMobileViewport))
watch(() => route.fullPath, () => {
mobileMenuOpen.value = false
syncCurrentRoute()
}, { immediate: true })
watch(() => access.permissionSignature, () => { void pruneInaccessibleTabs() }, { immediate: true })
watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId) => {
if (!userId || userId === previousUserId) return
workspace.reset(false)
workspace.initialize(userId)
void pruneInaccessibleTabs().then(syncCurrentRoute)
})
</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">
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
</div>
<nav class="side-nav" aria-label="主导航">
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
</nav>
</aside>
<div class="app-main">
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
<button
class="shell-menu-button"
type="button"
:aria-label="menuButtonLabel"
:aria-expanded="menuExpanded"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading">
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
</div>
<div class="header-user">
<button
v-if="access.localFullAccessAvailable"
class="local-full-access-toggle"
type="button"
role="switch"
:aria-checked="access.localFullAccessEnabled"
:title="access.localFullAccessEnabled ? '关闭后恢复服务端权限' : '开启后显示全部前端页面和按钮'"
@click="access.toggleLocalFullAccess"
>
<ShieldCheck :size="17" />
<span>本地全权限</span>
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
</button>
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心">
<Bell :size="18" /><span>消息中心</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<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>
</header>
<WorkspaceTabs v-if="workspace.tabs.length" />
<main class="page-container">
<RouterView v-slot="{ Component, route: renderedRoute }">
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
<component
:is="Component"
v-if="renderedRoute.meta.keepAlive !== false"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
/>
</KeepAlive>
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
</RouterView>
</main>
</div>
</div>
</template>
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.local-full-access-toggle:hover { border-color: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .22); }
.local-full-access-toggle:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.local-full-access-toggle[aria-checked="true"] { border-color: #86efac; background: rgba(22, 163, 74, .3); }
.local-full-access-toggle strong { color: #dcfce7; font-size: 12px; }
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.notification-trigger > svg { flex: 0 0 auto; }
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
</style>