فهرست منبع

feat(web-app): 重构多级导航 — L1 单行 + hover 弹出 L2/L3 + 侧边栏展示最后一层

- store/menu: 新增 menuDepth/activeBranchDepth、topBarLevelCount、sidebarDepth、cascadeSelection、topBarLevelItems、sidebarItems、selectLevel;buildCascade 用 ownsRoute 处理分类型 L1
- TopNavBar: L1 单行原样式;目录型 L1 渲染为 span(不可点击);hover 弹出 L2/L3 下拉,Teleport 到 body 用 position: fixed + getBoundingClientRect 定位,避开 .app-shell overflow 裁剪
- SideNav: 仅展示当前激活分支的最后一层;深度 = 1 时不显示
- App.vue / main.css: .app-body 加 no-sidebar 类,深度 = 1 时收起侧边栏列宽
ligb 1 هفته پیش
والد
کامیت
b9dc42259d
5فایلهای تغییر یافته به همراه522 افزوده شده و 72 حذف شده
  1. 15 3
      web-app/src/App.vue
  2. 8 0
      web-app/src/assets/main.css
  3. 14 5
      web-app/src/components/SideNav.vue
  4. 315 63
      web-app/src/components/TopNavBar.vue
  5. 170 1
      web-app/src/store/menu.js

+ 15 - 3
web-app/src/App.vue

@@ -3,7 +3,7 @@
     <template v-if="isAppLayout">
       <div class="app-shell">
         <TopNavBar @logout="onLogout" />
-        <div class="app-body">
+        <div :class="['app-body', { 'no-sidebar': !hasSidebar }]">
           <SideNav />
           <main class="content">
             <router-view />
@@ -17,7 +17,7 @@
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useAuth } from '@/store/auth'
 import { useMenu } from '@/store/menu'
@@ -29,7 +29,7 @@ import ToastHost from '@/components/common/ToastHost.vue'
 const route = useRoute()
 const router = useRouter()
 const { logout } = useAuth()
-const { reset: resetMenu } = useMenu()
+const { state: menuState, sidebarDepth, reset: resetMenu, syncActiveFromPath } = useMenu()
 
 // 跟随路由同步浏览器 title
 useSyncDocumentTitle()
@@ -38,6 +38,18 @@ useSyncDocumentTitle()
 const isAppLayout = computed(() => route.meta?.layout !== 'blank')
 const layoutClass = computed(() => (isAppLayout.value ? 'layout-app' : 'layout-blank'))
 
+// 仅当菜单存在最后一层时显示侧边栏(深度 = 1 时不显示)
+const hasSidebar = computed(() => sidebarDepth.value > 0)
+
+// 路由变化时,把一级菜单的 active 状态切到当前路径所属的菜单组
+watch(
+  () => route.path,
+  (path) => {
+    if (menuState.loaded) syncActiveFromPath(path)
+  },
+  { immediate: true },
+)
+
 function onLogout() {
   resetMenu()
   logout()

+ 8 - 0
web-app/src/assets/main.css

@@ -31,6 +31,14 @@ body {
   overflow: hidden;
 }
 
+.app-body.no-sidebar {
+  grid-template-columns: 0 1fr;
+}
+
+.app-body.no-sidebar > .sidebar {
+  display: none;
+}
+
 .layout-app .content {
   flex: 1;
   min-width: 0;

+ 14 - 5
web-app/src/components/SideNav.vue

@@ -1,5 +1,5 @@
 <template>
-  <aside class="sidebar">
+  <aside v-if="sidebarDepth > 0" class="sidebar">
     <div v-if="topLabel" class="sb-label sb-top">{{ topLabel }}</div>
 
     <div v-if="items.length" class="sb-section">
@@ -24,10 +24,19 @@ import { useMenu } from '@/store/menu'
 
 const router = useRouter()
 const route = useRoute()
-const { state, load, subMenus, activeTopMenu } = useMenu()
-
-const items = computed(() => subMenus.value)
-const topLabel = computed(() => activeTopMenu.value?.name || '')
+const { state, load, sidebarItems, sidebarDepth, cascadeSelection, topBarLevelCount } = useMenu()
+
+// 侧边栏要展示的项由菜单 store 派生:始终是菜单树的最后一层
+const items = computed(() => sidebarItems.value)
+const sidebarVisible = computed(() => sidebarDepth.value > 0)
+
+// 侧边栏标题:取上边栏最后一行选中项的名称(即层级最深的可见项)
+const topLabel = computed(() => {
+  if (!sidebarVisible.value) return ''
+  const sel = cascadeSelection.value
+  const idx = topBarLevelCount.value - 1
+  return sel[idx]?.name || sel[0]?.name || ''
+})
 
 onMounted(() => {
   if (!state.loaded) load()

+ 315 - 63
web-app/src/components/TopNavBar.vue

@@ -6,12 +6,27 @@
     </div>
 
     <nav class="nav-main">
-      <a
+      <div
         v-for="item in topMenus"
         :key="item.id"
-        :class="{ on: item.id === activeId }"
-        @click="onSelect(item)"
-      >{{ item.name }}</a>
+        class="nav-item"
+        :ref="(el) => bindL1Ref(item.id, el)"
+        @mouseenter="onL1Enter(item)"
+        @mouseleave="onL1Leave"
+      >
+        <!-- 仅目录(无 route)的 L1:渲染为 span,点击不导航;只用于 hover 弹出下拉 -->
+        <span
+          v-if="!item.route"
+          class="nav-link nav-category"
+        >{{ item.name }}</span>
+        <!-- 真正可点击进入的 L1(有 route) -->
+        <a
+          v-else
+          class="nav-link"
+          :class="{ on: isL1Active(item) }"
+          @click="onL1Click(item)"
+        >{{ item.name }}</a>
+      </div>
       <span v-if="!loading && topMenus.length === 0" class="nav-empty">暂无菜单</span>
     </nav>
 
@@ -25,10 +40,56 @@
       </div>
     </div>
   </header>
+
+  <!--
+    弹出层通过 Teleport 渲染到 body,避开 .nav-main / .app-shell 的 overflow 裁剪,
+    使用 position: fixed + 实时 getBoundingClientRect() 定位
+  -->
+  <Teleport to="body">
+    <!-- L1 下拉:L2 列表 -->
+    <div
+      v-if="openL1Item && showL1Dropdown(openL1Item)"
+      class="g-dropdown"
+      :style="l1DropdownStyle"
+      @mouseenter="cancelL1Close"
+    >
+      <div
+        v-for="l2 in openL1Item.children"
+        :key="l2.id"
+        class="g-dropdown-item"
+        :class="{ active: isL2Active(l2) }"
+        :ref="(el) => bindL2Ref(l2.id, el)"
+        @mouseenter="onL2Enter(l2)"
+        @mouseleave="onL2Leave"
+        @click="onL2Click(l2)"
+      >
+        <span class="g-dropdown-label">{{ l2.name }}</span>
+        <span v-if="showL2SubDropdown(l2, openL1Item)" class="caret">›</span>
+      </div>
+    </div>
+
+    <!-- L2 子下拉:L3 列表 -->
+    <div
+      v-if="openL2Item && showL2SubDropdown(openL2Item, openL1Item)"
+      class="g-dropdown"
+      :style="l2DropdownStyle"
+      @mouseenter="cancelL2Close"
+    >
+      <div
+        v-for="l3 in openL2Item.children"
+        :key="l3.id"
+        class="g-dropdown-item"
+        :class="{ active: isL3Active(l3) }"
+        @click="onL3Click(l3)"
+      >
+        <span>{{ l3.name }}</span>
+      </div>
+    </div>
+  </Teleport>
 </template>
 
 <script setup>
-import { computed, onMounted, watch } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useAuth } from '@/store/auth'
 import { useMenu } from '@/store/menu'
@@ -39,16 +100,185 @@ defineEmits(['logout'])
 const router = useRouter()
 const route = useRoute()
 const { auth } = useAuth()
-const { state, load, setActive, activeTopMenu } = useMenu()
+const {
+  state,
+  load,
+  selectLevel,
+  activeByLevel,
+} = useMenu()
 
-const topMenus = computed(() => state.topMenus)
-const activeId = computed(() => state.activeId)
 const loading = computed(() => state.loading)
+const topMenus = computed(() => state.topMenus)
+
+function hasChildren(item) {
+  return Array.isArray(item?.children) && item.children.length > 0
+}
+
+// 计算单个节点所在子树的深度(包含自身)
+function branchDepth(item) {
+  if (!item) return 0
+  let max = 1
+  if (Array.isArray(item.children) && item.children.length) {
+    for (const c of item.children) {
+      const d = branchDepth(c)
+      if (d + 1 > max) max = d + 1
+    }
+  }
+  return max
+}
+
+// L1 下拉:仅当 L1 子树深度 ≥ 3 时弹出(深度 = 2 时 L2 只在侧边栏)
+function showL1Dropdown(item) {
+  return hasChildren(item) && branchDepth(item) >= 3
+}
+// L2 子下拉:仅当所属 L1 子树深度 ≥ 4 时弹出(深度 = 3 时 L3 只在侧边栏)
+function showL2SubDropdown(l2Item, l1Item) {
+  return hasChildren(l2Item) && branchDepth(l1Item) >= 4
+}
+
+// 当前打开的菜单 id
+const openL1Id = ref(null)
+const openL2Id = ref(null)
+// 当前打开菜单对应的 DOM 矩形(用于 fixed 定位)
+const l1Rect = ref(null)
+const l2Rect = ref(null)
+// 用 Map 保存各级菜单项的 DOM 引用
+const l1Refs = new Map()
+const l2Refs = new Map()
+
+let l1CloseTimer = null
+let l2CloseTimer = null
+
+function bindL1Ref(id, el) {
+  if (el) l1Refs.set(id, el)
+  else l1Refs.delete(id)
+}
+function bindL2Ref(id, el) {
+  if (el) l2Refs.set(id, el)
+  else l2Refs.delete(id)
+}
+
+const openL1Item = computed(() => {
+  if (!openL1Id.value) return null
+  return topMenus.value.find((m) => m.id === openL1Id.value) || null
+})
+const openL2Item = computed(() => {
+  if (!openL1Item.value || !openL2Id.value) return null
+  const children = Array.isArray(openL1Item.value.children) ? openL1Item.value.children : []
+  return children.find((c) => c.id === openL2Id.value) || null
+})
+
+const l1DropdownStyle = computed(() => {
+  const rect = l1Rect.value
+  if (!rect) return { display: 'none' }
+  return {
+    position: 'fixed',
+    top: `${Math.round(rect.bottom)}px`,
+    left: `${Math.round(rect.left)}px`,
+  }
+})
+const l2DropdownStyle = computed(() => {
+  const rect = l2Rect.value
+  if (!rect) return { display: 'none' }
+  return {
+    position: 'fixed',
+    top: `${Math.round(rect.top)}px`,
+    left: `${Math.round(rect.right + 4)}px`,
+  }
+})
+
+function onL1Enter(item) {
+  if (l1CloseTimer) { clearTimeout(l1CloseTimer); l1CloseTimer = null }
+  if (openL1Id.value !== item.id) {
+    openL2Id.value = null
+    l2Rect.value = null
+  }
+  openL1Id.value = item.id
+  const el = l1Refs.get(item.id)
+  if (el) l1Rect.value = el.getBoundingClientRect()
+}
+function onL1Leave() {
+  l1CloseTimer = setTimeout(() => {
+    openL1Id.value = null
+    openL2Id.value = null
+    l1Rect.value = null
+    l2Rect.value = null
+    l1CloseTimer = null
+  }, 150)
+}
+function cancelL1Close() {
+  if (l1CloseTimer) { clearTimeout(l1CloseTimer); l1CloseTimer = null }
+}
+
+function onL2Enter(item) {
+  if (l2CloseTimer) { clearTimeout(l2CloseTimer); l2CloseTimer = null }
+  openL2Id.value = item.id
+  const el = l2Refs.get(item.id)
+  if (el) l2Rect.value = el.getBoundingClientRect()
+}
+function onL2Leave() {
+  l2CloseTimer = setTimeout(() => {
+    openL2Id.value = null
+    l2Rect.value = null
+    l2CloseTimer = null
+  }, 150)
+}
+function cancelL2Close() {
+  if (l2CloseTimer) { clearTimeout(l2CloseTimer); l2CloseTimer = null }
+}
+
+function isL1Active(item) {
+  return activeByLevel.value[0]?.id === item.id
+}
+function isL2Active(item) {
+  return activeByLevel.value[1]?.id === item.id
+}
+function isL3Active(item) {
+  return activeByLevel.value[2]?.id === item.id
+}
+
+function onL1Click(item) {
+  // 没有 route 的 L1 已经在模板里渲染为 span,不会触发此函数
+  selectLevel(1, item)
+}
+function onL2Click(item) { selectLevel(2, item) }
+function onL3Click(item) { selectLevel(3, item) }
+
+function refreshRects() {
+  if (openL1Id.value) {
+    const el = l1Refs.get(openL1Id.value)
+    if (el) l1Rect.value = el.getBoundingClientRect()
+  }
+  if (openL2Id.value) {
+    const el = l2Refs.get(openL2Id.value)
+    if (el) l2Rect.value = el.getBoundingClientRect()
+  }
+}
 
 onMounted(() => {
   if (!state.loaded) load()
+  window.addEventListener('resize', refreshRects)
+  window.addEventListener('scroll', refreshRects, true)
 })
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', refreshRects)
+  window.removeEventListener('scroll', refreshRects, true)
+  if (l1CloseTimer) clearTimeout(l1CloseTimer)
+  if (l2CloseTimer) clearTimeout(l2CloseTimer)
+})
+
+// 路由变化时关闭弹出(避免残留)
+watch(
+  () => route.path,
+  () => {
+    openL1Id.value = null
+    openL2Id.value = null
+    l1Rect.value = null
+    l2Rect.value = null
+  },
+)
 
+// 菜单加载完成后,如果浏览器当前正处在根路径 /,引导到首个有路由的菜单项
 let didInitNav = false
 watch(
   () => state.loaded,
@@ -57,33 +287,17 @@ watch(
     didInitNav = true
     const first = state.topMenus[0]
     if (!first) return
-    let target = null
-    if (first.route) {
-      target = first
-    } else if (Array.isArray(first.children) && first.children.length > 0) {
-      target = first.children[0]
-    }
-    if (target?.route && route.path !== target.route) {
-      router.replace(target.route)
+    const currentPath = typeof window !== 'undefined' ? window.location.pathname : (route.path || '')
+    if (currentPath !== '/') return
+    let target = first
+    while (target && !target.route) {
+      target = Array.isArray(target.children) && target.children.length ? target.children[0] : null
     }
+    if (target?.route) router.replace(target.route)
   },
   { immediate: true },
 )
 
-function onSelect(item) {
-  setActive(item.id)
-  const active = activeTopMenu.value
-  if (!active) return
-  if (active.route) {
-    router.push(active.route)
-    return
-  }
-  const firstChild = Array.isArray(active.children) ? active.children[0] : null
-  if (firstChild?.route && route.path !== firstChild.route) {
-    router.push(firstChild.route)
-  }
-}
-
 const displayName = computed(() => auth.nickname || auth.account || '未登录')
 const avatarText = computed(() => {
   const src = auth.nickname || auth.account
@@ -112,6 +326,7 @@ const roleText = ''
   background: #fff;
   border-bottom: 1px solid var(--line);
   z-index: 10;
+  position: relative;
 }
 
 .brand {
@@ -141,36 +356,54 @@ const roleText = ''
   color: #1A2438;
 }
 
+/* === L1 一级菜单:保持原样式(横向胶囊式 tab) === */
 .nav-main {
   display: flex;
   gap: 6px;
   margin-left: 50px;
   flex: 1;
   min-width: 0;
-  overflow-x: auto;
+  overflow: visible;
+  align-items: center;
+  height: 100%;
+}
+
+.nav-item {
+  position: relative;
+  display: flex;
+  align-items: center;
 }
 
-.nav-main a {
+.nav-link {
   padding: 7px 14px;
   border-radius: 6px;
   font-size: 13px;
   color: var(--mut);
   transition: 0.15s;
-  cursor: pointer;
   white-space: nowrap;
+  user-select: none;
+}
+
+a.nav-link {
+  cursor: pointer;
 }
 
-.nav-main a:hover {
+.nav-link:hover {
   background: #f0f3f8;
   color: var(--ink);
 }
 
-.nav-main a.on {
+a.nav-link.on {
   background: var(--brand-lt);
   color: var(--brand-d);
   font-weight: 600;
 }
 
+/* 仅目录的 L1:不可点击 */
+.nav-category {
+  cursor: default;
+}
+
 .nav-empty {
   padding: 7px 14px;
   font-size: 12px;
@@ -184,34 +417,6 @@ const roleText = ''
   gap: 14px;
 }
 
-.icon-btn {
-  width: 32px;
-  height: 32px;
-  border-radius: 6px;
-  display: grid;
-  place-items: center;
-  cursor: pointer;
-  color: var(--mut);
-  position: relative;
-  font-size: 14px;
-}
-
-.icon-btn:hover {
-  background: #f0f3f8;
-  color: var(--ink);
-}
-
-.icon-btn .badge-dot {
-  position: absolute;
-  top: 6px;
-  right: 6px;
-  width: 6px;
-  height: 6px;
-  background: var(--up);
-  border-radius: 50%;
-  border: 1.5px solid #fff;
-}
-
 .user-info {
   display: flex;
   align-items: center;
@@ -247,4 +452,51 @@ const roleText = ''
   font-size: 10.5px;
   color: var(--faint);
 }
+</style>
+
+<style>
+/* 全局样式:下拉面板由 Teleport 渲染到 body,必须脱离 scoped 才能命中 */
+.g-dropdown {
+  min-width: 180px;
+  max-width: 280px;
+  background: #fff;
+  border: 1px solid #e4e9f2;
+  border-radius: 8px;
+  box-shadow: 0 8px 28px rgba(15, 32, 64, 0.14);
+  padding: 6px;
+  z-index: 1000;
+}
+
+.g-dropdown-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  padding: 8px 12px;
+  border-radius: 5px;
+  font-size: 13px;
+  color: #1a2438;
+  cursor: pointer;
+  white-space: nowrap;
+  transition: background 0.12s, color 0.12s;
+  user-select: none;
+}
+
+.g-dropdown-item:hover {
+  background: #f7f9fd;
+  color: #0e3a96;
+}
+
+.g-dropdown-item.active {
+  background: #eaf1ff;
+  color: #0e3a96;
+  font-weight: 600;
+}
+
+.g-dropdown-item .caret {
+  color: #98a5bd;
+  font-size: 16px;
+  line-height: 1;
+  font-weight: 400;
+}
 </style>

+ 170 - 1
web-app/src/store/menu.js

@@ -1,5 +1,10 @@
 import { reactive, computed } from 'vue'
 import { fetchCurrentMenu } from '@/api/menu'
+import router from '@/router'
+import { useAuth } from '@/store/auth'
+
+// 上边栏最多支持的层级数;超出部分只把最后一层放到侧边栏
+const MAX_TOPBAR_LEVELS = 3
 
 const state = reactive({
   loaded: false,
@@ -21,6 +26,66 @@ function sortTree(list) {
     .map((m) => ({ ...m, children: Array.isArray(m.children) ? sortTree(m.children) : [] }))
 }
 
+function pathMatches(item, path) {
+  if (!item?.route) return false
+  return path === item.route || path.startsWith(`${item.route}/`)
+}
+
+// 判断 item 是否“拥有”该路径,即自身匹配,或任意后代匹配
+function ownsRoute(item, path) {
+  if (pathMatches(item, path)) return true
+  if (!Array.isArray(item.children)) return false
+  return item.children.some((c) => ownsRoute(c, path))
+}
+
+function findNode(list, path) {
+  if (!Array.isArray(list)) return null
+  for (const node of list) {
+    if (pathMatches(node, path)) return node
+    if (Array.isArray(node.children)) {
+      const hit = findNode(node.children, path)
+      if (hit) return node
+    }
+  }
+  return null
+}
+
+// 计算整棵菜单树的最大深度
+function treeDepth(list, current = 1) {
+  if (!Array.isArray(list) || list.length === 0) return 0
+  let max = current
+  for (const node of list) {
+    if (Array.isArray(node.children) && node.children.length) {
+      const d = treeDepth(node.children, current + 1)
+      if (d > max) max = d
+    }
+  }
+  return max
+}
+
+// 从顶层往下逐级选中(未命中的层级用第一个子项补齐)
+function buildCascade(items, path) {
+  const result = []
+  let current = items
+  while (Array.isArray(current) && current.length) {
+    // 用 ownsRoute 判断:路由可能在当前项上,也可能在后代上
+    let hit = current.find((m) => ownsRoute(m, path))
+    if (!hit) hit = current[0]
+    result.push(hit)
+    current = hit.children
+  }
+  return result
+}
+
+function findFirstDescendantRoute(item) {
+  let cur = item
+  while (cur) {
+    if (cur.route) return cur.route
+    cur = Array.isArray(cur.children) && cur.children.length ? cur.children[0] : null
+  }
+  return null
+}
+
 export function useMenu() {
   async function load(force = false) {
     if (state.loading) return
@@ -37,6 +102,11 @@ export function useMenu() {
       }
     } catch (err) {
       state.error = err
+      useAuth().logout()
+      const redirect = router.currentRoute.value.fullPath
+      if (router.currentRoute.value.name !== 'login') {
+        router.replace({ name: 'login', query: { redirect } })
+      }
     } finally {
       state.loading = false
     }
@@ -47,6 +117,15 @@ export function useMenu() {
     state.activeId = id
   }
 
+  // 根据当前路径同步选中的一级菜单(及一级菜单下的二级高亮)
+  function syncActiveFromPath(path) {
+    if (!Array.isArray(state.topMenus) || !state.topMenus.length) return
+    const hit = findNode(state.topMenus, path)
+    if (hit && state.activeId !== hit.id) {
+      state.activeId = hit.id
+    }
+  }
+
   function reset() {
     state.loaded = false
     state.loading = false
@@ -64,5 +143,95 @@ export function useMenu() {
     return cur && Array.isArray(cur.children) ? cur.children : []
   })
 
-  return { state, load, setActive, reset, activeTopMenu, subMenus }
+  // ===== 多级菜单:上边栏最多展示 N-1 层(≤3),侧边栏只展示最后一层 =====
+  // 深度按当前激活的一级菜单所在分支计算,而非整棵树
+
+  const activeBranchRoot = computed(() => cascadeSelection.value[0] ?? null)
+
+  // 当前激活一级菜单分支的深度
+  const activeBranchDepth = computed(() => {
+    const root = activeBranchRoot.value
+    if (!root) return 0
+    return treeDepth([root])
+  })
+
+  // 上边栏要渲染的层级数:
+  //   深度=1 → 1;深度=2 → 1;深度=3 → 2;深度=4 → 3;深度 >4 同样最多 3
+  const topBarLevelCount = computed(() => {
+    const depth = activeBranchDepth.value
+    if (depth <= 0) return 0
+    return Math.max(1, Math.min(depth - 1, MAX_TOPBAR_LEVELS))
+  })
+
+  // 侧边栏要展示的层级(当前分支最后一层);深度=1 时为 0(不展示侧边栏)
+  const sidebarDepth = computed(() => Math.max(0, activeBranchDepth.value - topBarLevelCount.value))
+
+  // 当前路由对应的级联选中项,顶层到深层依次排列;缺失层级用第一个子项补齐
+  const cascadeSelection = computed(() => {
+    const path = router.currentRoute.value.path
+    return buildCascade(state.topMenus, path)
+  })
+
+  // 上边栏各层级要渲染的菜单项(数组的每一项对应一行)
+  const topBarLevelItems = computed(() => {
+    const sel = cascadeSelection.value
+    const rows = [state.topMenus]
+    for (let i = 0; i < topBarLevelCount.value - 1; i++) {
+      const parent = sel[i]
+      if (!parent) {
+        rows.push([])
+        continue
+      }
+      rows.push(Array.isArray(parent.children) ? parent.children : [])
+    }
+    return rows
+  })
+
+  // 侧边栏要展示的项 = 上边栏最后一行选中项的子项(即整棵树的最后一层)
+  const sidebarItems = computed(() => {
+    if (sidebarDepth.value <= 0) return []
+    const sel = cascadeSelection.value
+    const parent = sel[topBarLevelCount.value - 1]
+    if (!parent) return []
+    return Array.isArray(parent.children) ? parent.children : []
+  })
+
+  // 当前激活的层级项(高亮用):按层级取 cascadeSelection 中对应下标
+  const activeByLevel = computed(() => cascadeSelection.value.slice())
+
+  // 点击某层某项:定位该层为选中项,并跳转到该项目(或首个后代)的路由
+  function selectLevel(level, item) {
+    if (!item) return
+    // L1 仅为分组目录(无 route 且有子项)时,不更新 activeId 也不导航,
+    // 真正的导航要交给二级 / 三级菜单触发
+    if (level === 1 && !item.route && Array.isArray(item.children) && item.children.length > 0) {
+      return
+    }
+    if (level === 1) {
+      state.activeId = item.id
+    }
+    const target = findFirstDescendantRoute(item)
+    if (target && router.currentRoute.value.path !== target) {
+      router.push(target)
+    }
+  }
+
+  return {
+    state,
+    load,
+    setActive,
+    syncActiveFromPath,
+    reset,
+    activeTopMenu,
+    subMenus,
+    activeBranchRoot,
+    activeBranchDepth,
+    topBarLevelCount,
+    sidebarDepth,
+    cascadeSelection,
+    topBarLevelItems,
+    sidebarItems,
+    activeByLevel,
+    selectLevel,
+  }
 }