Files
medical-mall/layouts/admin/AdminLayout.uvue
2026-02-05 17:11:41 +08:00

397 lines
10 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">
<!-- 统一遮罩层 (复刻 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"
/>
<!-- 二级侧边栏 (1:1 复刻 CRMEB 抽屉/Dock 平滑切换) -->
<AdminSubSider
:visible="isSubSiderVisible"
:class="{ 'sub-sider-overlay': isSubSiderOverlay || layoutMode === 'mobile' }"
:topMenuTitle="activeTopMenuTitle"
:groups="activeGroups"
:routes="activeRoutes"
:activeRouteId="activeRouteId"
:asideWidth="layoutMode === 'mobile' ? 0 : ASIDE_W"
:siderWidth="SUB_W"
@route-click="onRouteClick"
/>
<!-- 右侧内容区 -->
<view
class="main"
:style="{ marginLeft: isMobile ? '0' : mainLeft }"
>
<!-- 顶部导航栏 -->
<AdminHeader
:breadcrumb="breadcrumb"
:hasNotification="hasNotification"
:isMobile="isMobile"
@search="onSearch"
@refresh="onRefresh"
@notify="onNotify"
/>
<!-- 标签页 (CRMEB风格) - 移动端可以隐藏或滚动 -->
<AdminTagsView
v-if="!isMobile"
:tabs="tabs"
:activeTabId="activeRouteId"
@tab-click="onTabClick"
@tab-close="onTabClose"
@close-other="onCloseOther"
@close-all="onCloseAll"
/>
<!-- 内容展示区 (内部路由渲染) -->
<view class="content-scroll">
<view class="content-inner" :style="{ padding: isMobile ? '12px' : '16px' }">
<component :is="currentComponent" />
</view>
<AdminFooter />
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } 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 {
getTopMenus,
getGroupsByTopMenu,
getRoutesByGroup,
findRouteById,
getBreadcrumb
} from '@/layouts/admin/router/adminRoutes.uts'
import type { TopMenu, MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
import {
activeTopMenuId,
activeRouteId,
tabs,
isMainAsideCollapsed,
showSubSider,
windowWidth,
layoutMode,
isMobile,
isMobileMenuOpen,
isOverlayVisible,
openRoute,
closeTab,
closeOtherTabs,
closeAllTabs,
toggleMainAsideCollapse as storeToggleCollapse,
toggleSubSider as storeToggleSubSider,
initNavState
} from '@/layouts/admin/store/adminNavStore.uts'
import type { TabItem } from '@/layouts/admin/store/adminNavStore.uts'
import { getComponent } from '@/layouts/admin/router/adminComponentMap.uts'
// 侧边栏宽度配置 (CRMEB 1:1)
const ASIDE_W = 70
const SUB_W = 200
const hasNotification = ref<boolean>(false)
/**
* 核心逻辑:计算二级菜单是否应该以 Overlay (抽屉) 模式展示
* CRMEB 规则Tablet 屏 (768~1199) 为 Overlay
*/
const isSubSiderOverlay = computed<boolean>(() => {
return layoutMode.value === 'tablet'
})
/**
* 核心逻辑:二级菜单是否可见
* 1. 如果有子菜单内容 (activeGroups > 0)
* 2. 如果是 Desktop 且 showSubSider 为真 (Dock模式)
* 3. 如果是 Tablet/Mobile 且 isOverlayVisible 为真 (Overlay模式)
*/
const isSubSiderVisible = computed<boolean>(() => {
if (activeGroups.value.length === 0) return false
if (layoutMode.value === 'desktop') {
return showSubSider.value
}
// Tablet 和 Mobile 模式下,作为 Overlay 受 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 && activeGroups.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)
})
// ============================================
// 事件处理
// ============================================
function onTopMenuClick(menu: TopMenu): void {
activeTopMenuId.value = menu.id
// 1:1 复刻 CRMEB 交互:
// 1. 如果有子菜单Desktop 下 dockTablet/Mobile 下唤起 Overlay
if (menu.groups.length > 0) {
if (layoutMode.value === 'desktop') {
showSubSider.value = true
} else {
isOverlayVisible.value = true
}
} else {
// 2. 如果没有子菜单:直接跳转并关闭所有 Overlay
openRoute(menu.id + '_index')
closeAllMenu()
}
}
function onRouteClick(routeId: string): void {
openRoute(routeId)
// 1:1 复刻 CRMEB在移动端或平板叠加模式下点击具体子路由后自动收起
if (layoutMode.value !== 'desktop') {
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(() => {
initNavState()
// 初始化窗口宽度
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
})
})
})
</script>
<style scoped lang="scss">
.layout-root {
display: flex;
flex-direction: row;
width: 100%;
min-height: 100vh;
background: #f0f2f5;
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: #f0f2f5;
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: #f0f2f5;
}
.content-inner {
min-height: calc(100vh - 120px);
padding: 16px;
}
</style>