Files
medical-mall/layouts/admin/AdminLayout.uvue

593 lines
16 KiB
Plaintext
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.
<template>
<view class="layout-root">
<view v-if="!isAuthReady" class="auth-loading-overlay" style="flex: 1; display: flex; align-items: center; justify-content: center; height: 100vh; background-color: #f5f5f5;">
<text style="color: #666; font-size: 16px;">身份鉴权中...</text>
</view>
<template v-else>
<!-- 统一遮罩层 (复刻 CRMEB: 用于所有 Overlay 状态) -->
<view
class="mobile-mask"
:class="{ 'mask-show': isOverlayVisible || (isMobile && isMobileMenuOpen) }"
@click="closeAllMenu"
></view>
<!-- 主侧边栏 (CRMEB风格: 70px) -->
<AdminAside
class="admin-sidebar"
:class="{ 'mobile-aside-open': isMobileMenuOpen }"
:collapsed="false"
:topMenus="topMenus"
:activeTopMenuId="activeTopMenuId"
@toggle="toggleMainAsideCollapse"
@menu-click="onTopMenuClick"
:asideWidth="ASIDE_W"
></AdminAside>
<!-- 二级侧边栏 (1:1 复刻 CRMEB 抽屉/Dock 平滑切换) -->
<AdminSubSider
:visible="isSubSiderVisible"
:isOverlay="isOverlayVisible || layoutMode === 'mobile'"
:layoutMode="layoutMode"
:menuTitle="activeTopMenuTitle"
:menuTree="activeMenuTree"
:activeId="activeRouteId"
:currentPath="currentRoutePath"
:asideWidth="layoutMode === 'mobile' ? 0 : ASIDE_W"
:width="SUB_W"
@sub-click="onSubClick"
></AdminSubSider>
<!-- 右侧内容区 -->
<view
class="main"
:style="{ marginLeft: isMobile ? '0' : mainLeft }"
>
<!-- 顶部导航栏 -->
<AdminHeader
:breadcrumb="breadcrumb"
:hasNotification="hasNotification"
:isMobile="isMobile"
@search="onSearch"
@refresh="onRefresh"
@notify="onNotify"
></AdminHeader>
<!-- 标签页 (CRMEB风格) - 移动端可以隐藏或滚动 -->
<AdminTagsView
v-if="!isMobile"
:tabs="tabs"
:activeTabId="activeRouteId"
@tab-click="onTabClick"
@tab-close="onTabClose"
@close-other="onCloseOther"
@close-all="onCloseAll"
@refresh="onRefresh"
></AdminTagsView>
<!-- 内容展示区 (内部路由渲染) -->
<view class="content-scroll">
<view class="content-inner" :class="{ 'is-mobile': isMobile }">
<slot v-if="hasAccess"></slot>
<component :is="currentComponent" v-if="hasAccess && !isPageLoading && currentComponent != null"></component>
<AdminPageLoading v-if="isPageLoading"></AdminPageLoading>
</view>
<AdminFooter></AdminFooter>
</view>
</view>
</template>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import AdminAside from '@/layouts/admin/components/AdminAside.uvue'
import AdminSubSider from '@/layouts/admin/components/AdminSubSider.uvue'
import AdminHeader from '@/layouts/admin/components/AdminHeader.uvue'
import AdminTagsView from '@/layouts/admin/components/AdminTagsView.uvue'
import AdminFooter from '@/layouts/admin/components/AdminFooter.uvue'
import AdminPageLoading from '@/layouts/admin/components/AdminPageLoading.uvue'
import {
getTopMenus,
getGroupsByTopMenu,
getRoutesByGroup,
findRouteById,
getBreadcrumb
} from '@/layouts/admin/router/adminRoutes.uts'
import type { TopMenu, MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
import { MenuNode, settingSubSiderMenu } from '@/layouts/admin/router/settingSubSiderMenu.uts'
import {
activeTopMenuId,
activeRouteId,
lastSubIdByMenu,
tabs,
isMainAsideCollapsed,
showSubSider,
windowWidth,
layoutMode,
isMobile,
isMobileMenuOpen,
isOverlayVisible,
openRoute,
closeTab,
closeOtherTabs,
closeAllTabs,
toggleMainAsideCollapse as storeToggleCollapse,
toggleSubSider as storeToggleSubSider,
initNavState,
restoreNavState
} from '@/layouts/admin/store/adminNavStore.uts'
import type { TabItem } from '@/layouts/admin/store/adminNavStore.uts'
import { getComponent } from '@/layouts/admin/router/adminComponentMap.uts'
import { hasAdminModuleAccess } from '@/layouts/admin/utils/role.uts'
import { ensureAdminSession, handleSessionExpired } from '@/layouts/admin/utils/adminAuth.uts'
const props = defineProps({
currentPage: {
type: String,
default: ''
}
})
// 侧边栏宽度配置 (CRMEB 1:1)
const ASIDE_W = 70
const SUB_W = 200
// 页面加载状态
const isPageLoading = ref(false)
const isAuthReady = ref(false)
const hasAccess = computed<boolean>(() => {
return hasAdminModuleAccess(activeTopMenuId.value)
})
const hasNotification = ref<boolean>(false)
/**
* 核心逻辑:计算二级菜单是否应该以 Overlay (抽屉) 模式展示
* CRMEB 规则Tablet 屏 (768~1199) 为 Overlay
*/
const isSubSiderOverlay = computed<boolean>(() => {
return layoutMode.value === 'tablet'
})
// 当前路由的路径
const currentRoutePath = computed<string>(() => {
const route = findRouteById(activeRouteId.value)
return route ? route.path : ''
})
// 将旧的 Group + Routes 转换为新的 Tree 结构 (支持 3 级嵌套)
const activeMenuTree = computed<MenuNode[]>(() => {
if (activeTopMenuId.value === 'setting') {
return settingSubSiderMenu
}
const tree: MenuNode[] = []
activeGroups.value.forEach(group => {
const routes = activeRoutes.value.get(group.id)
if (routes != null && routes.length > 0) {
const children: MenuNode[] = []
routes.forEach(route => {
children.push({
id: route.id,
title: route.title,
type: 'page',
path: route.path
} as MenuNode)
})
// 1:1 复刻 CRMEB: 如果分组标题为空,直接将子菜单平铺在顶层,不渲染分组行
if (group.title === '') {
children.forEach(child => {
tree.push(child)
})
} else {
tree.push({
id: group.id,
title: group.title,
type: 'group',
children: children
} as MenuNode)
}
}
})
return tree
})
/**
* 核心逻辑:二级菜单是否可见
* 1. 如果有子菜单内容 (activeMenuTree > 0)
* 2. 如果是 Desktop 且 showSubSider 为真 (Dock模式)
* 3. 如果是 Tablet/Mobile 且 isOverlayVisible 为真 (Overlay模式)
*/
const isSubSiderVisible = computed<boolean>(() => {
// 首页模块通常不显示 SubSider
if (activeTopMenuId.value === 'home' || activeMenuTree.value.length === 0) return false
if (layoutMode.value === 'desktop') {
return showSubSider.value
}
// Tablet 和 Mobile 模式下,直接由 isOverlayVisible 控制
return isOverlayVisible.value
})
/**
* 核心逻辑:计算主内容区左偏移量 (mainLeft)
* 严格按照断点策略计算,防止遮挡
* 1. Desktop: ASIDE_W + (SUB_W if dock)
* 2. Tablet: ASIDE_W (sub-sider 是 overlay不占位)
* 3. Mobile: 0
*/
const mainLeft = computed<string>(() => {
if (layoutMode.value === 'mobile') {
return '0px'
}
let left = ASIDE_W // 只要不是 Mobile主侧栏 70px 始终 Dock
// 只有在 Desktop 模式且二级菜单处于 Dock 模式显示时,才累加宽度
if (layoutMode.value === 'desktop' && showSubSider.value && activeMenuTree.value.length > 0) {
left += SUB_W
}
return left + 'px'
})
// 获取一级菜单列表
const topMenus = computed<TopMenu[]>(() => {
return getTopMenus()
})
// 当前选中一级菜单的标题
const activeTopMenuTitle = computed<string>(() => {
const menu = topMenus.value.find(m => m.id === activeTopMenuId.value)
return menu ? menu.title : ''
})
// 当前一级菜单的分组列表
const activeGroups = computed<MenuGroup[]>(() => {
return getGroupsByTopMenu(activeTopMenuId.value)
})
// 当前一级菜单的所有路由
const activeRoutes = computed<Map<string, RouteRecord[]>>(() => {
const result = new Map<string, RouteRecord[]>()
activeGroups.value.forEach(group => {
result.set(group.id, getRoutesByGroup(group.id))
})
return result
})
// 面包屑导航
const breadcrumb = computed<Array<{id: string, title: string}>>(() => {
return getBreadcrumb(activeRouteId.value)
})
// 当前渲染的组件
const currentComponent = computed<any>(() => {
const route = findRouteById(activeRouteId.value)
if (!route) return null
return getComponent(route.componentKey)
})
// 监听路由变化,同步状态 (处理从 Tabs 或外部跳转的情况)
watch(() => activeRouteId.value, (newId) => {
// 触发页面加载动画
isPageLoading.value = true
setTimeout(() => {
isPageLoading.value = false
}, 400) // 给予足够的时间让异步组件加载
const route = findRouteById(newId)
if (route && route.parentId) {
// 同步一级菜单
if (activeTopMenuId.value !== route.parentId) {
activeTopMenuId.value = route.parentId
}
// 同步最后访问记录
lastSubIdByMenu.value[route.parentId] = newId
// 如果是 Desktop 且 SubSider 关着,自动打开(除非用户手动关了)
if (layoutMode.value === 'desktop' && !showSubSider.value) {
showSubSider.value = true
}
}
}, { immediate: true })
// ============================================
// 事件处理
// ============================================
function onTopMenuClick(menu: TopMenu): void {
activeTopMenuId.value = menu.id
// 1:1 复刻 CRMEB 交互:点击 Aside 立即展示 SubSider
if (layoutMode.value === 'desktop') {
showSubSider.value = true
} else {
isOverlayVisible.value = true
isMobileMenuOpen.value = false // 如果主侧栏开着,关掉它,开二级
}
// 1:1 复刻 CRMEB 交互:点击一级菜单后,自动跳转到上一次记录的子页面或第一个子页面
let targetId = lastSubIdByMenu.value[menu.id]
if (!targetId && activeMenuTree.value.length > 0) {
const firstNode = activeMenuTree.value[0]
if (firstNode.type === 'page') {
targetId = firstNode.id
} else if (firstNode.children != null && firstNode.children!.length > 0) {
targetId = firstNode.children![0].id
}
}
if (targetId) {
openRoute(targetId)
} else {
// 兜底逻辑:如果没有二级菜单,跳转到 index
openRoute(menu.id + '_index')
}
}
function onSubClick(payload: { id: string, path: string }): void {
// CRMEB 跳转逻辑
openRoute(payload.id)
// 更新最后访问记录
lastSubIdByMenu.value[activeTopMenuId.value] = payload.id
// 重要1:1 复刻 CRMEB点击二级菜单项后SubSider 通常保持显示状态
// 只有在 Mobile 模式下,用户如果是想“跳转并收起”,通常需要点击遮罩或关闭按钮,但这里我们按桌面版常驻逻辑
// 如果需要移动端点击自动关闭,才解开下面注释
// if (layoutMode.value === 'mobile') {
// closeAllMenu()
// }
}
function onTabClick(tab: TabItem): void {
openRoute(tab.id, false)
}
function onTabClose(tabId: string): void {
closeTab(tabId)
}
function onCloseOther(tabId: string): void {
closeOtherTabs(tabId)
}
function onCloseAll(): void {
closeAllTabs()
}
function toggleMainAsideCollapse(): void {
storeToggleCollapse()
}
function closeAllMenu(): void {
isMobileMenuOpen.value = false
isOverlayVisible.value = false
// 注意:在 Desktop 模式下closeAllMenu 通常不隐藏 showSubSider除非用户手动点汉堡按钮
}
function onSearch(): void {
uni.showToast({ title: '搜索', icon: 'none' })
}
function onRefresh(): void {
uni.showToast({ title: '刷新', icon: 'none' })
}
function onNotify(): void {
uni.showToast({ title: '通知', icon: 'none' })
}
// ============================================
// 生命周期
// ============================================
let resizeTid: any = null
onMounted(async () => {
// 挂载鉴权相关的事件监听器,并在首次进入时阻断认证
try {
const isOk = await ensureAdminSession()
if (!isOk) {
return // 鉴权失败内部已处理跳转,终止后续挂载
}
} catch (e) {
handleSessionExpired()
return
}
uni.$on('AUTH_SESSION_EXPIRED', () => { handleSessionExpired() })
// 增加 visibilitychange 监听:在用户电脑休眠或者长时间放置重新激活时,核验会话
// #ifdef H5
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', handleVisibilityChange)
}
// #endif
isAuthReady.value = true
// 第二段:尝试从 sessionStorage 恢复上次打开的页签状态
// restoreNavState 会校验路由有效性和权限,过滤无效项后重建 tabs
const restored = restoreNavState()
if (!restored) {
// 首次打开 / 缓存为空 / 恢复失败 → 走默认初始化只有首页tab
initNavState()
if (props.currentPage != '') {
openRoute(props.currentPage as string)
}
}
// restored=true 时activeRouteId 已由 restoreNavState 正确设置,无需额外跳转
// 初始化窗口宽度
windowWidth.value = uni.getWindowInfo().windowWidth
// 监听窗口变化 (增加节流保护与跨断点状态重置)
uni.onWindowResize((res) => {
if (resizeTid != null) {
cancelAnimationFrame(resizeTid)
}
resizeTid = requestAnimationFrame(() => {
const oldMode = layoutMode.value
windowWidth.value = res.size.windowWidth
const newMode = layoutMode.value
// 跨断点自动关闭所有 Overlay防止状态残留遮挡内容
if (oldMode != newMode) {
isOverlayVisible.value = false
isMobileMenuOpen.value = false
// 如果切到桌面端,默认展开二级侧栏以符合 CRMEB 习惯
if (newMode === 'desktop') {
showSubSider.value = true
}
}
resizeTid = null
})
})
})
// ===== Auth State Handlers =====
let _lastVisibilityCheck = 0
const handleVisibilityChange = async () => {
// #ifdef H5
if (document.visibilityState === 'visible') {
const now = Date.now()
// 节流机制,防止频繁切换标签页产生过多校验
if (now - _lastVisibilityCheck < 10000) return
_lastVisibilityCheck = now
// 简易验证即可,如果底层 token 已经失效而 fetch 拿不到,就会报出 401 触发全局 AUTH_SESSION_EXPIRED
try {
await ensureAdminSession()
} catch(e) {}
}
// #endif
}
onUnmounted(() => {
uni.$off('AUTH_SESSION_EXPIRED')
// #ifdef H5
if (typeof window !== 'undefined') {
window.removeEventListener('visibilitychange', handleVisibilityChange)
}
// #endif
})
</script>
<style scoped lang="scss">
.layout-root {
--admin-page-padding-desktop: 20px;
--admin-page-padding-mobile: 10px;
--admin-section-gap: 20px;
--admin-card-padding: 24px;
display: flex;
flex-direction: row;
width: 100%;
min-height: 100vh;
background: #f5f7f9;
position: relative;
}
/* 移动端侧边栏样式 */
.mobile-aside {
position: absolute;
left: -70px; /* 隐藏在左侧,匹配 ASIDE_W: 70 */
top: 0;
bottom: 0;
z-index: 1001;
transition: transform 0.3s ease;
background: #fff;
}
.mobile-aside-open {
transform: translateX(70px); /* 移入视图 */
}
.mobile-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 300ms ease, visibility 0s linear 300ms;
}
.mask-show {
opacity: 1;
visibility: visible;
transition: opacity 300ms ease, visibility 0s linear 0s;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin-left 300ms ease;
background: #f5f7f9;
width: 100%;
}
/* 响应式强制覆盖 */
@media screen and (max-width: 768px) {
.main {
margin-left: 0 !important;
}
/* 强行改变侧边栏布局模式 */
.admin-sidebar {
position: absolute !important;
left: -70px !important; /* 隐藏在左侧 */
top: 0;
bottom: 0;
z-index: 1001;
transition: transform 0.3s ease !important;
}
/* 展开时的状态 */
.mobile-aside-open {
transform: translateX(70px) !important;
}
}
.content-scroll {
flex: 1;
overflow-y: scroll;
overflow-x: auto; /* 允许横向滚动,兼容极端窄屏 */
background: #f5f7f9;
padding: 0;
}
.content-inner {
min-height: calc(100vh - 120px);
padding: var(--admin-page-padding-desktop);
display: flex;
flex-direction: column;
}
.content-inner.is-mobile {
padding: var(--admin-page-padding-mobile);
}
</style>