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

171 lines
8.2 KiB
Vue
Raw Normal View History

2026-07-30 17:59:43 +08:00
<script setup lang="ts">
2026-07-31 15:34:35 +08:00
import { computed, ref, watch } from 'vue'
2026-07-30 17:59:43 +08:00
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
2026-08-12 18:24:05 +08:00
import { Bell, FileText, LogOut, Mail, Menu } from '@lucide/vue'
2026-08-03 18:49:18 +08:00
import { getApiErrorDetails } from '@/shared/api/http'
2026-07-31 15:34:35 +08:00
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
2026-08-03 18:49:18 +08:00
import AdminSidebarMenu from '@/app/components/AdminSidebarMenu.vue'
2026-08-05 15:54:59 +08:00
import WorkspaceTabs from '@/app/components/WorkspaceTabs.vue'
2026-08-03 18:49:18 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
2026-08-05 15:54:59 +08:00
import { canAccessWorkspaceRoute, workspaceRouteId, workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
import { MAX_CACHED_WORKSPACE_VIEWS, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
2026-07-30 17:59:43 +08:00
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
2026-08-05 15:54:59 +08:00
const workspace = useWorkspaceTabsStore()
2026-07-30 17:59:43 +08:00
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
2026-07-31 15:34:35 +08:00
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
2026-07-30 17:59:43 +08:00
2026-07-31 15:34:35 +08:00
access.initializeAfterLogin()
2026-08-05 15:54:59 +08:00
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' },
).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()
}
2026-07-30 17:59:43 +08:00
async function handleLogout() {
try {
2026-08-05 15:54:59 +08:00
const dirtyLabels = unsaved.entries.filter((entry) => entry.dirty).map((entry) => entry.label)
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
2026-07-30 17:59:43 +08:00
confirmButtonText: '退出',
cancelButtonText: '取消',
})
} catch {
return
}
access.reset()
unsaved.clear()
2026-08-05 15:54:59 +08:00
workspace.reset(true)
2026-07-31 13:43:16 +08:00
try {
await auth.logout()
} catch (error) {
const details = getApiErrorDetails(error, '服务器会话撤销失败')
const trackingId = details.requestId || details.traceId
ElMessage.warning(`本地已退出,但服务器会话撤销失败${trackingId ? `(追踪标识:${trackingId})` : ''}`)
}
2026-07-30 17:59:43 +08:00
try {
await router.replace('/login')
} catch {
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
}
}
function handleMenuToggle() {
if (window.matchMedia('(max-width: 720px)').matches) {
mobileMenuOpen.value = !mobileMenuOpen.value
return
}
collapsed.value = !collapsed.value
}
2026-08-05 15:54:59 +08:00
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)
})
2026-07-30 17:59:43 +08:00
</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">
2026-07-31 15:34:35 +08:00
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
2026-07-30 17:59:43 +08:00
</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="mobileMenuOpen ? '关闭菜单' : collapsed ? '展开导航' : '收起导航'"
:aria-expanded="mobileMenuOpen || !collapsed"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
2026-07-30 17:59:43 +08:00
<div class="header-user">
2026-08-12 18:24:05 +08:00
<el-dropdown disabled trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心,后端契约待接入" disabled>
<Bell :size="18" /><span>消息中心</span><span class="notification-count">--</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item disabled><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item disabled><FileText :size="15" />模板管理</el-dropdown-item>
<el-dropdown-item disabled><Bell :size="15" />我的消息</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
2026-07-30 17:59:43 +08:00
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
2026-07-31 15:34:35 +08:00
<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>
2026-07-30 17:59:43 +08:00
</div>
</header>
2026-08-05 15:54:59 +08:00
<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))"
/>
</KeepAlive>
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
</RouterView>
</main>
2026-07-30 17:59:43 +08:00
</div>
</div>
</template>
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
2026-08-12 18:24:05 +08:00
.notification-trigger { display: inline-flex; min-height: 34px; align-items: center; gap: 6px; padding: 0 9px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-muted); font: inherit; }
.notification-trigger:disabled { cursor: not-allowed; opacity: .72; }
.notification-count { display: inline-flex; min-width: 22px; height: 18px; align-items: center; justify-content: center; padding: 0 5px; border-radius: 9px; background: #e5e7eb; color: #64748b; font-size: 11px; font-weight: 700; }
@media (max-width: 720px) { .notification-trigger > span:not(.notification-count) { display: none; } }
</style>