menu.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { reactive, computed } from 'vue'
  2. import { fetchCurrentMenu } from '@/api/menu'
  3. import router from '@/router'
  4. import { useAuth } from '@/store/auth'
  5. // 上边栏最多支持的层级数;超出部分只把最后一层放到侧边栏
  6. const MAX_TOPBAR_LEVELS = 3
  7. const state = reactive({
  8. loaded: false,
  9. loading: false,
  10. error: null,
  11. topMenus: [],
  12. activeId: null,
  13. })
  14. function bySort(a, b) {
  15. return (a?.sort ?? 0) - (b?.sort ?? 0)
  16. }
  17. function sortTree(list) {
  18. if (!Array.isArray(list)) return []
  19. return list
  20. .slice()
  21. .sort(bySort)
  22. .map((m) => ({ ...m, children: Array.isArray(m.children) ? sortTree(m.children) : [] }))
  23. }
  24. function pathMatches(item, path) {
  25. if (!item?.route) return false
  26. return path === item.route || path.startsWith(`${item.route}/`)
  27. }
  28. // 判断 item 是否“拥有”该路径,即自身匹配,或任意后代匹配
  29. function ownsRoute(item, path) {
  30. if (pathMatches(item, path)) return true
  31. if (!Array.isArray(item.children)) return false
  32. return item.children.some((c) => ownsRoute(c, path))
  33. }
  34. function findNode(list, path) {
  35. if (!Array.isArray(list)) return null
  36. for (const node of list) {
  37. if (pathMatches(node, path)) return node
  38. if (Array.isArray(node.children)) {
  39. const hit = findNode(node.children, path)
  40. if (hit) return node
  41. }
  42. }
  43. return null
  44. }
  45. // 计算整棵菜单树的最大深度
  46. function treeDepth(list, current = 1) {
  47. if (!Array.isArray(list) || list.length === 0) return 0
  48. let max = current
  49. for (const node of list) {
  50. if (Array.isArray(node.children) && node.children.length) {
  51. const d = treeDepth(node.children, current + 1)
  52. if (d > max) max = d
  53. }
  54. }
  55. return max
  56. }
  57. // 从顶层往下逐级选中(未命中的层级用第一个子项补齐)
  58. function buildCascade(items, path) {
  59. const result = []
  60. let current = items
  61. while (Array.isArray(current) && current.length) {
  62. // 用 ownsRoute 判断:路由可能在当前项上,也可能在后代上
  63. let hit = current.find((m) => ownsRoute(m, path))
  64. if (!hit) hit = current[0]
  65. result.push(hit)
  66. current = hit.children
  67. }
  68. return result
  69. }
  70. function findFirstDescendantRoute(item) {
  71. let cur = item
  72. while (cur) {
  73. if (cur.route) return cur.route
  74. cur = Array.isArray(cur.children) && cur.children.length ? cur.children[0] : null
  75. }
  76. return null
  77. }
  78. export function useMenu() {
  79. async function load(force = false) {
  80. if (state.loading) return
  81. if (state.loaded && !force) return
  82. state.loading = true
  83. state.error = null
  84. try {
  85. const data = await fetchCurrentMenu()
  86. const list = Array.isArray(data) ? data : []
  87. state.topMenus = sortTree(list)
  88. state.loaded = true
  89. if (!state.topMenus.some((m) => m.id === state.activeId)) {
  90. state.activeId = state.topMenus[0]?.id ?? null
  91. }
  92. } catch (err) {
  93. state.error = err
  94. useAuth().logout()
  95. const redirect = router.currentRoute.value.fullPath
  96. if (router.currentRoute.value.name !== 'login') {
  97. router.replace({ name: 'login', query: { redirect } })
  98. }
  99. } finally {
  100. state.loading = false
  101. }
  102. }
  103. function setActive(id) {
  104. if (state.activeId === id) return
  105. state.activeId = id
  106. }
  107. // 根据当前路径同步选中的一级菜单(及一级菜单下的二级高亮)
  108. function syncActiveFromPath(path) {
  109. if (!Array.isArray(state.topMenus) || !state.topMenus.length) return
  110. const hit = findNode(state.topMenus, path)
  111. if (hit && state.activeId !== hit.id) {
  112. state.activeId = hit.id
  113. }
  114. }
  115. function reset() {
  116. state.loaded = false
  117. state.loading = false
  118. state.error = null
  119. state.topMenus = []
  120. state.activeId = null
  121. }
  122. const activeTopMenu = computed(
  123. () => state.topMenus.find((m) => m.id === state.activeId) ?? null,
  124. )
  125. const subMenus = computed(() => {
  126. const cur = activeTopMenu.value
  127. return cur && Array.isArray(cur.children) ? cur.children : []
  128. })
  129. // ===== 多级菜单:上边栏最多展示 N-1 层(≤3),侧边栏只展示最后一层 =====
  130. // 深度按当前激活的一级菜单所在分支计算,而非整棵树
  131. const activeBranchRoot = computed(() => cascadeSelection.value[0] ?? null)
  132. // 当前激活一级菜单分支的深度
  133. const activeBranchDepth = computed(() => {
  134. const root = activeBranchRoot.value
  135. if (!root) return 0
  136. return treeDepth([root])
  137. })
  138. // 上边栏要渲染的层级数:
  139. // 深度=1 → 1;深度=2 → 1;深度=3 → 2;深度=4 → 3;深度 >4 同样最多 3
  140. const topBarLevelCount = computed(() => {
  141. const depth = activeBranchDepth.value
  142. if (depth <= 0) return 0
  143. return Math.max(1, Math.min(depth - 1, MAX_TOPBAR_LEVELS))
  144. })
  145. // 侧边栏要展示的层级(当前分支最后一层);深度=1 时为 0(不展示侧边栏)
  146. const sidebarDepth = computed(() => Math.max(0, activeBranchDepth.value - topBarLevelCount.value))
  147. // 当前路由对应的级联选中项,顶层到深层依次排列;缺失层级用第一个子项补齐
  148. const cascadeSelection = computed(() => {
  149. const path = router.currentRoute.value.path
  150. return buildCascade(state.topMenus, path)
  151. })
  152. // 上边栏各层级要渲染的菜单项(数组的每一项对应一行)
  153. const topBarLevelItems = computed(() => {
  154. const sel = cascadeSelection.value
  155. const rows = [state.topMenus]
  156. for (let i = 0; i < topBarLevelCount.value - 1; i++) {
  157. const parent = sel[i]
  158. if (!parent) {
  159. rows.push([])
  160. continue
  161. }
  162. rows.push(Array.isArray(parent.children) ? parent.children : [])
  163. }
  164. return rows
  165. })
  166. // 侧边栏要展示的项 = 上边栏最后一行选中项的子项(即整棵树的最后一层)
  167. const sidebarItems = computed(() => {
  168. if (sidebarDepth.value <= 0) return []
  169. const sel = cascadeSelection.value
  170. const parent = sel[topBarLevelCount.value - 1]
  171. if (!parent) return []
  172. return Array.isArray(parent.children) ? parent.children : []
  173. })
  174. // 当前激活的层级项(高亮用):按层级取 cascadeSelection 中对应下标
  175. const activeByLevel = computed(() => cascadeSelection.value.slice())
  176. // 点击某层某项:定位该层为选中项,并跳转到该项目(或首个后代)的路由
  177. function selectLevel(level, item) {
  178. if (!item) return
  179. // L1 仅为分组目录(无 route 且有子项)时,不更新 activeId 也不导航,
  180. // 真正的导航要交给二级 / 三级菜单触发
  181. if (level === 1 && !item.route && Array.isArray(item.children) && item.children.length > 0) {
  182. return
  183. }
  184. if (level === 1) {
  185. state.activeId = item.id
  186. }
  187. const target = findFirstDescendantRoute(item)
  188. if (target && router.currentRoute.value.path !== target) {
  189. router.push(target)
  190. }
  191. }
  192. return {
  193. state,
  194. load,
  195. setActive,
  196. syncActiveFromPath,
  197. reset,
  198. activeTopMenu,
  199. subMenus,
  200. activeBranchRoot,
  201. activeBranchDepth,
  202. topBarLevelCount,
  203. sidebarDepth,
  204. cascadeSelection,
  205. topBarLevelItems,
  206. sidebarItems,
  207. activeByLevel,
  208. selectLevel,
  209. }
  210. }