2026-07-30 17:59:42 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import { ElMessageBox } from 'element-plus'
|
2026-08-12 18:24:03 +08:00
|
|
|
|
import { Bell, LogOut, Menu, X } from '@lucide/vue'
|
2026-07-31 15:34:22 +08:00
|
|
|
|
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
2026-07-30 17:59:42 +08:00
|
|
|
|
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
|
2026-08-05 15:55:02 +08:00
|
|
|
|
import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
|
2026-08-03 18:52:30 +08:00
|
|
|
|
import { useAuthStore } from '@/modules/auth/public'
|
2026-08-12 18:24:03 +08:00
|
|
|
|
import { useNotificationsStore } from '@/modules/notifications/public'
|
2026-08-05 15:55:02 +08:00
|
|
|
|
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
|
|
|
|
|
|
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
|
|
|
|
|
|
import {
|
|
|
|
|
|
MAX_CACHED_WORKSPACE_VIEWS,
|
|
|
|
|
|
isWorkspaceRoute,
|
|
|
|
|
|
useWorkspaceTabsStore,
|
|
|
|
|
|
type WorkspaceTab,
|
|
|
|
|
|
} from '@/navigation/workspace-tabs/workspaceTabs'
|
2026-07-30 17:59:42 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const auth = useAuthStore()
|
2026-08-12 18:24:03 +08:00
|
|
|
|
const notifications = useNotificationsStore()
|
2026-08-05 15:55:02 +08:00
|
|
|
|
const workspace = useWorkspaceTabsStore()
|
|
|
|
|
|
const unsaved = useUnsavedWorkStore()
|
2026-07-30 17:59:42 +08:00
|
|
|
|
const pageTitle = computed(() => route.meta.title || '业务总览')
|
|
|
|
|
|
const pageDescription = computed(() => route.meta.description || '')
|
|
|
|
|
|
const mobileMenuOpen = ref(false)
|
|
|
|
|
|
const desktopViewport = ref(true)
|
|
|
|
|
|
const menuButtonRef = ref<HTMLButtonElement>()
|
|
|
|
|
|
const sidebarRef = ref<HTMLElement>()
|
|
|
|
|
|
const sidebarCloseButtonRef = ref<HTMLButtonElement>()
|
|
|
|
|
|
let previousBodyOverflow: string | undefined
|
|
|
|
|
|
let desktopMediaQuery: MediaQueryList | undefined
|
2026-08-05 15:55:02 +08:00
|
|
|
|
const scrollPositions = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
|
|
function isAllowedTab(tab: WorkspaceTab) {
|
|
|
|
|
|
const resolved = router.resolve(tab.fullPath)
|
|
|
|
|
|
if (String(resolved.name ?? '') !== tab.routeName || !isWorkspaceRoute(resolved)) return false
|
|
|
|
|
|
const permission = typeof resolved.meta.permission === 'string' ? resolved.meta.permission : undefined
|
|
|
|
|
|
return auth.hasPermission(permission)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function registerCurrentRoute() {
|
|
|
|
|
|
workspace.openRoute(route, unsaved.dirtyRouteKeys)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const restoredActivePath = workspace.initialize(auth.user?.id ?? '', isAllowedTab)
|
|
|
|
|
|
if (route.fullPath === '/' && restoredActivePath && restoredActivePath !== '/') {
|
|
|
|
|
|
void router.replace(restoredActivePath)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
registerCurrentRoute()
|
|
|
|
|
|
}
|
2026-07-30 17:59:42 +08:00
|
|
|
|
|
|
|
|
|
|
function openMobileMenu() {
|
|
|
|
|
|
mobileMenuOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeMobileMenu() {
|
|
|
|
|
|
mobileMenuOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function restoreBodyOverflow() {
|
|
|
|
|
|
if (previousBodyOverflow === undefined) return
|
|
|
|
|
|
|
|
|
|
|
|
document.body.style.overflow = previousBodyOverflow
|
|
|
|
|
|
previousBodyOverflow = undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
|
|
|
|
if (event.key === 'Escape') closeMobileMenu()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleDesktopBreakpoint(event: MediaQueryList | MediaQueryListEvent) {
|
|
|
|
|
|
desktopViewport.value = event.matches
|
|
|
|
|
|
if (event.matches) closeMobileMenu()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 15:55:02 +08:00
|
|
|
|
watch(() => route.fullPath, () => {
|
|
|
|
|
|
closeMobileMenu()
|
|
|
|
|
|
registerCurrentRoute()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => String(route.name ?? ''),
|
|
|
|
|
|
async (routeName, previousRouteName) => {
|
|
|
|
|
|
if (previousRouteName && workspace.tabs.some((tab) => tab.key === previousRouteName)) {
|
|
|
|
|
|
scrollPositions.set(previousRouteName, window.scrollY)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
|
window.scrollTo(0, scrollPositions.get(routeName) ?? 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => workspace.tabs.map((tab) => tab.key),
|
|
|
|
|
|
(tabKeys) => {
|
|
|
|
|
|
const retainedKeys = new Set(tabKeys)
|
|
|
|
|
|
for (const routeKey of scrollPositions.keys()) {
|
|
|
|
|
|
if (!retainedKeys.has(routeKey)) scrollPositions.delete(routeKey)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => auth.permissionCodes.join('|'),
|
|
|
|
|
|
() => {
|
|
|
|
|
|
const result = workspace.reconcile(isAllowedTab)
|
|
|
|
|
|
if (result.closedActive) void router.replace(result.fallbackPath ?? getFirstAccessiblePath(auth.permissionCodes))
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => auth.isAuthenticated,
|
|
|
|
|
|
(authenticated) => {
|
|
|
|
|
|
if (authenticated) return
|
|
|
|
|
|
workspace.clear()
|
|
|
|
|
|
unsaved.clear()
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-07-30 17:59:42 +08:00
|
|
|
|
|
|
|
|
|
|
watch(mobileMenuOpen, async (open, wasOpen) => {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
previousBodyOverflow = document.body.style.overflow
|
|
|
|
|
|
document.body.style.overflow = 'hidden'
|
|
|
|
|
|
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
if (mobileMenuOpen.value) sidebarCloseButtonRef.value?.focus()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
restoreBodyOverflow()
|
|
|
|
|
|
|
|
|
|
|
|
if (wasOpen) {
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
if (!mobileMenuOpen.value && !desktopViewport.value) menuButtonRef.value?.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-08-12 18:24:03 +08:00
|
|
|
|
void notifications.refreshUnreadCount()
|
2026-07-30 17:59:42 +08:00
|
|
|
|
document.addEventListener('keydown', handleKeydown)
|
2026-08-05 15:55:02 +08:00
|
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
2026-07-30 17:59:42 +08:00
|
|
|
|
desktopMediaQuery = window.matchMedia('(min-width: 721px)')
|
|
|
|
|
|
desktopMediaQuery.addEventListener('change', handleDesktopBreakpoint)
|
|
|
|
|
|
handleDesktopBreakpoint(desktopMediaQuery)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
document.removeEventListener('keydown', handleKeydown)
|
2026-08-05 15:55:02 +08:00
|
|
|
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
2026-07-30 17:59:42 +08:00
|
|
|
|
desktopMediaQuery?.removeEventListener('change', handleDesktopBreakpoint)
|
|
|
|
|
|
restoreBodyOverflow()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-05 15:55:02 +08:00
|
|
|
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
|
|
|
|
|
if (!unsaved.hasDirty) return
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
event.returnValue = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 17:59:42 +08:00
|
|
|
|
async function handleLogout() {
|
|
|
|
|
|
try {
|
2026-08-05 15:55:02 +08:00
|
|
|
|
await ElMessageBox.confirm(unsaved.hasDirty
|
|
|
|
|
|
? '当前还有未保存的修改,退出后这些内容将丢失。确定继续吗?'
|
|
|
|
|
|
: '确定退出客户工作台吗?', '退出登录', {
|
2026-07-30 17:59:42 +08:00
|
|
|
|
confirmButtonText: '退出',
|
|
|
|
|
|
cancelButtonText: '取消',
|
2026-08-05 15:55:02 +08:00
|
|
|
|
type: unsaved.hasDirty ? 'warning' : undefined,
|
2026-07-30 17:59:42 +08:00
|
|
|
|
})
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error === 'cancel' || error === 'close') return
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 15:55:02 +08:00
|
|
|
|
workspace.clear()
|
|
|
|
|
|
unsaved.clear()
|
2026-07-30 17:59:42 +08:00
|
|
|
|
await auth.logout()
|
|
|
|
|
|
await router.replace('/login')
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen }">
|
2026-08-03 10:51:53 +08:00
|
|
|
|
<header class="top-header" :inert="mobileMenuOpen" :aria-hidden="mobileMenuOpen ? 'true' : undefined">
|
|
|
|
|
|
<button
|
|
|
|
|
|
ref="menuButtonRef"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="client-header-menu-button"
|
|
|
|
|
|
aria-label="打开导航"
|
|
|
|
|
|
aria-controls="client-sidebar"
|
|
|
|
|
|
:aria-expanded="mobileMenuOpen"
|
|
|
|
|
|
@click="openMobileMenu"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Menu :size="20" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div class="client-header-heading">
|
|
|
|
|
|
<h1>{{ pageTitle }}</h1>
|
|
|
|
|
|
<p v-if="pageDescription">{{ pageDescription }}</p>
|
|
|
|
|
|
</div>
|
2026-07-30 17:59:42 +08:00
|
|
|
|
<div class="client-user">
|
2026-08-12 18:24:03 +08:00
|
|
|
|
<button class="client-header-notifications" type="button" disabled title="消息中心接口待接入" aria-label="我的消息,接口待接入"><Bell :size="19" /><span v-if="notifications.unreadCount !== null && notifications.unreadCount > 0" class="client-header-notifications-badge">{{ notifications.unreadCount > 99 ? '99+' : notifications.unreadCount }}</span></button>
|
2026-07-30 17:59:42 +08:00
|
|
|
|
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
|
|
|
|
|
|
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="mobileMenuOpen"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="client-sidebar-scrim"
|
|
|
|
|
|
aria-label="关闭导航"
|
|
|
|
|
|
@click="closeMobileMenu"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<aside
|
|
|
|
|
|
ref="sidebarRef"
|
|
|
|
|
|
id="client-sidebar"
|
|
|
|
|
|
class="client-sidebar"
|
|
|
|
|
|
:inert="!desktopViewport && !mobileMenuOpen"
|
|
|
|
|
|
:aria-hidden="!desktopViewport && !mobileMenuOpen ? 'true' : undefined"
|
|
|
|
|
|
>
|
2026-08-03 11:44:50 +08:00
|
|
|
|
<div class="client-sidebar-brand">
|
|
|
|
|
|
<RouterLink class="client-sidebar-brand-link" to="/" aria-label="返回业务总览" @click="closeMobileMenu">
|
2026-08-03 17:44:42 +08:00
|
|
|
|
<img class="client-sidebar-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
|
|
|
|
|
|
<span class="client-sidebar-brand-copy"><small>客户工作台</small></span>
|
2026-08-03 11:44:50 +08:00
|
|
|
|
</RouterLink>
|
2026-07-30 17:59:42 +08:00
|
|
|
|
<button
|
|
|
|
|
|
ref="sidebarCloseButtonRef"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="关闭导航"
|
|
|
|
|
|
@click="closeMobileMenu"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X :size="20" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<nav aria-label="客户工作台导航">
|
|
|
|
|
|
<ClientSidebarMenu
|
|
|
|
|
|
:active-path="route.path"
|
|
|
|
|
|
@navigate="closeMobileMenu"
|
|
|
|
|
|
/>
|
2026-08-03 11:44:50 +08:00
|
|
|
|
<button type="button" class="client-sidebar-logout" @click="handleLogout">
|
|
|
|
|
|
<LogOut :size="18" aria-hidden="true" />
|
|
|
|
|
|
<span>退出登录</span>
|
|
|
|
|
|
</button>
|
2026-07-30 17:59:42 +08:00
|
|
|
|
</nav>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
2026-08-05 15:55:02 +08:00
|
|
|
|
<WorkspaceTabs
|
|
|
|
|
|
:inert="mobileMenuOpen"
|
|
|
|
|
|
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-07-30 17:59:42 +08:00
|
|
|
|
<main
|
|
|
|
|
|
class="client-main"
|
|
|
|
|
|
:inert="mobileMenuOpen"
|
|
|
|
|
|
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
|
|
|
|
|
>
|
2026-08-05 15:55:02 +08:00
|
|
|
|
<RouterView v-slot="{ Component, route: viewRoute }">
|
|
|
|
|
|
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
|
|
|
|
|
|
<component
|
|
|
|
|
|
:is="Component"
|
|
|
|
|
|
v-if="viewRoute.meta.keepAlive !== false"
|
|
|
|
|
|
:key="workspace.cacheKey(viewRoute.name)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</KeepAlive>
|
|
|
|
|
|
<component
|
|
|
|
|
|
:is="Component"
|
|
|
|
|
|
v-if="viewRoute.meta.keepAlive === false"
|
|
|
|
|
|
:key="String(viewRoute.name ?? viewRoute.fullPath)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</RouterView>
|
2026-07-30 17:59:42 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|