2026-07-30 17:59:43 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-09-15 09:58:15 +08:00
|
|
|
|
import '@/styles/adminDensity.css'
|
2026-08-14 14:27:03 +08:00
|
|
|
|
import { computed, onBeforeUnmount, onMounted, 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-19 16:45:51 +08:00
|
|
|
|
import { Bell, FileText, LogOut, Mail, Menu, ShieldCheck } 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-08-14 14:27:03 +08:00
|
|
|
|
const mobileViewportQuery = '(max-width: 720px)'
|
2026-07-30 17:59:43 +08:00
|
|
|
|
const collapsed = ref(false)
|
|
|
|
|
|
const mobileMenuOpen = ref(false)
|
2026-08-14 14:27:03 +08:00
|
|
|
|
const isMobileViewport = ref(false)
|
|
|
|
|
|
let mobileViewportMedia: MediaQueryList | undefined
|
2026-07-30 17:59:43 +08:00
|
|
|
|
const pageTitle = computed(() => route.meta.title || '管理后台')
|
|
|
|
|
|
const pageDescription = computed(() => route.meta.description || '')
|
2026-08-01 12:17:33 +08:00
|
|
|
|
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
2026-07-31 15:34:35 +08:00
|
|
|
|
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
2026-08-14 14:27:03 +08:00
|
|
|
|
const menuButtonLabel = computed(() => {
|
|
|
|
|
|
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
|
|
|
|
|
|
return collapsed.value ? '展开导航' : '收起导航'
|
|
|
|
|
|
})
|
|
|
|
|
|
const menuExpanded = computed(() => (
|
|
|
|
|
|
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
|
|
|
|
|
|
))
|
2026-08-13 19:00:37 +08:00
|
|
|
|
const canOpenNotificationManagement = computed(() => (
|
2026-08-27 21:48:15 +08:00
|
|
|
|
access.can('platform:notification:view')
|
|
|
|
|
|
|| access.can('platform:notification:task:list')
|
2026-08-13 19:00:37 +08:00
|
|
|
|
|| access.can('platform:notification:template:list')
|
|
|
|
|
|
))
|
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('、')}。相关页签将关闭。`,
|
|
|
|
|
|
'权限已变化',
|
2026-09-15 09:58:15 +08:00
|
|
|
|
{ confirmButtonText: '知道了', type: 'warning', customClass: 'admin-density-confirm' },
|
2026-08-05 15:54:59 +08:00
|
|
|
|
).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: '退出',
|
2026-09-15 09:58:15 +08:00
|
|
|
|
customClass: 'admin-density-confirm',
|
2026-07-30 17:59:43 +08:00
|
|
|
|
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('已退出登录,但登录页跳转失败,请刷新页面')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 15:50:24 +08:00
|
|
|
|
function handleMenuToggle() {
|
2026-08-14 14:27:03 +08:00
|
|
|
|
if (isMobileViewport.value) {
|
2026-08-03 15:50:24 +08:00
|
|
|
|
mobileMenuOpen.value = !mobileMenuOpen.value
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
collapsed.value = !collapsed.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 14:27:03 +08:00
|
|
|
|
function syncMobileViewport(event: MediaQueryList | MediaQueryListEvent) {
|
|
|
|
|
|
isMobileViewport.value = event.matches
|
|
|
|
|
|
if (!event.matches) mobileMenuOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 18:50:03 +08:00
|
|
|
|
function openNotificationPage(path: string) { void router.push(path) }
|
|
|
|
|
|
|
2026-08-14 14:27:03 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
mobileViewportMedia = window.matchMedia(mobileViewportQuery)
|
|
|
|
|
|
syncMobileViewport(mobileViewportMedia)
|
|
|
|
|
|
mobileViewportMedia.addEventListener('change', syncMobileViewport)
|
|
|
|
|
|
})
|
|
|
|
|
|
onBeforeUnmount(() => mobileViewportMedia?.removeEventListener('change', syncMobileViewport))
|
|
|
|
|
|
|
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">
|
2026-08-01 12:17:33 +08:00
|
|
|
|
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
|
2026-08-03 15:50:24 +08:00
|
|
|
|
<button
|
|
|
|
|
|
class="shell-menu-button"
|
|
|
|
|
|
type="button"
|
2026-08-14 14:27:03 +08:00
|
|
|
|
:aria-label="menuButtonLabel"
|
|
|
|
|
|
:aria-expanded="menuExpanded"
|
2026-08-03 15:50:24 +08:00
|
|
|
|
@click="handleMenuToggle"
|
|
|
|
|
|
><Menu :size="22" /></button>
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<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-19 16:45:51 +08:00
|
|
|
|
<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>
|
2026-08-13 19:00:37 +08:00
|
|
|
|
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
|
|
|
|
|
|
<button class="notification-trigger" type="button" aria-label="消息中心">
|
|
|
|
|
|
<Bell :size="18" /><span>消息中心</span>
|
2026-08-12 18:24:05 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
<template #dropdown><el-dropdown-menu>
|
2026-08-27 21:48:15 +08:00
|
|
|
|
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
|
2026-08-12 18:50:03 +08:00
|
|
|
|
<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>
|
2026-08-12 18:24:05 +08:00
|
|
|
|
</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"
|
2026-08-27 21:48:15 +08:00
|
|
|
|
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
|
2026-08-05 15:54:59 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</KeepAlive>
|
|
|
|
|
|
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
|
|
|
|
|
</RouterView>
|
|
|
|
|
|
</main>
|
2026-07-30 17:59:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-08-01 12:17:33 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.app-header--without-page-heading .header-user { margin-left: auto; }
|
2026-08-19 16:45:51 +08:00
|
|
|
|
.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; }
|
2026-08-13 19:19:34 +08:00
|
|
|
|
.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; }
|
2026-08-19 16:45:51 +08:00
|
|
|
|
@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; } }
|
2026-08-01 12:17:33 +08:00
|
|
|
|
</style>
|