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

298 lines
10 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, LogOut, Menu, X } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
import { useAuthStore } from '@/modules/auth/public'
import { useNotificationsStore } from '@/modules/notifications/public'
import { getFirstAccessiblePath, getRouteBreadcrumbs } from '@/navigation/clientNavigation'
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
import {
MAX_CACHED_WORKSPACE_VIEWS,
isWorkspaceRoute,
useWorkspaceTabsStore,
type WorkspaceTab,
} from '@/navigation/workspace-tabs/workspaceTabs'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const notifications = useNotificationsStore()
const workspace = useWorkspaceTabsStore()
const unsaved = useUnsavedWorkStore()
const pageTitle = computed(() => route.meta.title || '业务总览')
const breadcrumbs = computed(() => getRouteBreadcrumbs(route.path, String(pageTitle.value)))
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
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()
}
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()
}
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('|'),
() => {
if (!auth.isAuthenticated) return
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()
void router.replace('/login')
},
)
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(() => {
if (auth.hasPermission('merchant:notification:view')) void notifications.refreshUnreadCount()
document.addEventListener('keydown', handleKeydown)
window.addEventListener('beforeunload', handleBeforeUnload)
desktopMediaQuery = window.matchMedia('(min-width: 721px)')
desktopMediaQuery.addEventListener('change', handleDesktopBreakpoint)
handleDesktopBreakpoint(desktopMediaQuery)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('beforeunload', handleBeforeUnload)
desktopMediaQuery?.removeEventListener('change', handleDesktopBreakpoint)
restoreBodyOverflow()
})
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!unsaved.hasDirty) return
event.preventDefault()
event.returnValue = ''
}
async function handleLogout() {
const logoutToken = auth.token
try {
await ElMessageBox.confirm(unsaved.hasDirty
? '当前还有未保存的修改,退出后这些内容将丢失。确定继续吗?'
: '确定退出客户工作台吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: unsaved.hasDirty ? 'warning' : undefined,
})
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
if (auth.token !== logoutToken) return
void auth.logout().catch(() => {
if (!auth.isAuthenticated) ElMessage.warning('已退出本页,服务器会话注销未完成')
})
}
</script>
<template>
<div v-if="auth.isAuthenticated" class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen, 'is-dashboard-route': route.path === '/' }">
<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>
<p class="client-header-greeting">Hi, 客户</p>
<div class="client-user">
<RouterLink v-if="auth.hasPermission('merchant:notification:view')" class="client-header-notifications" to="/notifications" 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></RouterLink>
<RouterLink
v-if="auth.hasPermission('account:profile:view')"
class="client-header-profile"
to="/account/profile"
aria-label="打开我的信息"
>
<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>
</RouterLink>
<template v-else>
<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>
</template>
</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"
>
<div class="client-sidebar-brand">
<RouterLink class="client-sidebar-brand-link" to="/" aria-label="返回业务总览" @click="closeMobileMenu">
<img class="client-sidebar-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
</RouterLink>
<button
ref="sidebarCloseButtonRef"
type="button"
aria-label="关闭导航"
@click="closeMobileMenu"
>
<X :size="20" />
</button>
</div>
<nav aria-label="客户工作台导航">
<ClientSidebarMenu
:active-path="route.path"
@navigate="closeMobileMenu"
/>
<button type="button" class="client-sidebar-logout" @click="handleLogout">
<LogOut :size="18" aria-hidden="true" />
<span>退出登录</span>
</button>
</nav>
</aside>
<WorkspaceTabs
v-if="route.path !== '/'"
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
/>
<main
class="client-main"
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
>
<nav v-if="route.path !== '/'" class="client-breadcrumb" aria-label="页面路径">
<template v-for="(item, index) in breadcrumbs" :key="`${item.title}-${index}`">
<span v-if="index" aria-hidden="true">/</span>
<RouterLink v-if="item.path" :to="item.path">{{ item.title }}</RouterLink>
<strong v-else aria-current="page">{{ item.title }}</strong>
</template>
</nav>
<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>
</main>
</div>
</template>