593 lines
16 KiB
Plaintext
593 lines
16 KiB
Plaintext
<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>
|