permission.ts 5.94 KB
import { defineStore } from 'pinia'
import type { RouteRecordRaw } from 'vue-router'
import router from '@/router'
import { notFoundRoute } from '@/router/routes'
import { HOME_PATH } from '@/config'
import { trimSlash } from '@/utils'
import type { MenuVO } from '@/types'

/** 静态注册的外壳路由名(见 router/routes.ts 的 layoutRoute),动态子路由挂在它下面 */
const ROOT_ROUTE_NAME = 'Root'

/**
 * 权限/菜单 store:把后端下发的菜单树转成前端路由
 *
 * 设计要点:
 *  1. 菜单是**后端驱动**的——新增页面只要后端菜单表加一条记录、前端有对应组件文件即可,
 *     不需要改前端代码(这是本方案的核心诉求之一)。
 *  2. 组件解析走 import.meta.glob(懒加载),component 字段填相对 src/views 的路径,
 *     如 'system/user/index'。
 *  3. 无 component 的菜单是「目录」,只参与侧边栏渲染,不生成路由。
 *  4. 侧边栏直接渲染后端菜单树(而不是路由树),保证菜单顺序/图标/可见性完全由后端说了算。
 */

// 所有页面组件(懒加载),key 形如 '/src/views/system/user/index.vue'
const viewModules = import.meta.glob('/src/views/**/*.vue')

/** 静态路由(含 Layout)之外,本 store 动态添加过的路由名,用于登出时清理 */
const dynamicRouteNames = new Set<string>()

export const usePermissionStore = defineStore('permission', () => {
  /** 后端菜单树(侧边栏数据源) */
  const menus = ref<MenuVO[]>([])
  /** 后端菜单拍平后的 path → title 映射(面包屑 / 标签页用) */
  const menuTitleMap = ref<Record<string, string>>({})
  const isRoutesLoaded = ref(false)

  /** component 字符串 → 异步组件 */
  function resolveComponent(component?: string | null) {
    if (!component) return null
    let path = component.trim()
    if (path.startsWith('@/views/')) path = path.replace('@/views/', '')
    if (!path.endsWith('.vue')) path += '.vue'
    const key = `/src/views/${trimSlash(path)}`
    return viewModules[key] || null
  }

  /** 菜单树 → 路由(只处理能解析到组件的叶子) */
  function menusToRoutes(list: MenuVO[]): RouteRecordRaw[] {
    const routes: RouteRecordRaw[] = []
    const walk = (nodes: MenuVO[]) => {
      ;(nodes || []).forEach((node) => {
        const comp = resolveComponent(node.component)
        if (comp && node.path) {
          routes.push({
            path: node.path,
            name: routeNameOf(node),
            component: comp,
            meta: {
              title: node.name,
              icon: node.icon,
              menuId: node.id
            }
          })
        }
        if (node.children && node.children.length) walk(node.children)
      })
    }
    walk(list)
    return routes
  }

  function routeNameOf(node: MenuVO): string {
    const base = (node.path || `menu-${node.id}`)
      .split('/')
      .filter(Boolean)
      .map((s) => s.replace(/[^\w]/g, '-'))
      .join('-')
    return `Menu-${base}-${node.id}`
  }

  /** 记录 path → title,供面包屑/标签页显示中文名 */
  function buildTitleMap(list: MenuVO[]) {
    const map: Record<string, string> = {}
    const walk = (nodes: MenuVO[]) => {
      ;(nodes || []).forEach((n) => {
        if (n.path) map[n.path] = n.name
        if (n.children?.length) walk(n.children)
      })
    }
    walk(list)
    menuTitleMap.value = map
  }

  /** 根据后端菜单生成并注册动态路由 */
  function generateRoutes(menuList: MenuVO[]) {
    menus.value = menuList || []
    buildTitleMap(menus.value)
    // 先清掉上一次注册的动态路由与兜底路由,保证刷新/换账号时不残留
    resetRoutes()

    // 外壳路由(path '/',name 'Root')已在静态路由里注册,这里只往它下面挂业务子路由
    const children = menusToRoutes(menus.value)
    children.forEach((r) => {
      router.addRoute(ROOT_ROUTE_NAME, r)
      dynamicRouteNames.add(String(r.name))
    })
    // 兜底必须在动态路由之后注册
    router.addRoute(notFoundRoute)
    dynamicRouteNames.add(String(notFoundRoute.name))
    isRoutesLoaded.value = true
    return children
  }

  function resetRoutes() {
    dynamicRouteNames.forEach((name) => {
      if (name && router.hasRoute(name)) router.removeRoute(name)
    })
    dynamicRouteNames.clear()
    isRoutesLoaded.value = false
  }

  function reset() {
    resetRoutes()
    menus.value = []
    menuTitleMap.value = {}
  }

  /**
   * 当前账号是否真的够得着这个地址(先按动态路由匹配一遍)。
   *
   * ⚠️ 不能用 `router.resolve(path).path === '/404'` 来判断:catch-all 记录上的
   *    `redirect: '/404'` **不会**被 resolve 跟随,解析结果仍是原 path,
   *    只有 `matched` 会指向 catch-all。所以这里只认 matched 里的记录名。
   */
  function canReach(path: string): boolean {
    if (!path) return false
    const matched = router.resolve(path).matched
    return matched.length > 0 && !matched.some((m) => m.name === notFoundRoute.name)
  }

  /**
   * 该账号实际可访问的第一个页面路径,用作登录后的落点。
   *
   * 不能无条件用 HOME_PATH:`/dashboard` 是**动态路由**,由后端菜单(首页)生成,
   * 不是每个账号都被授权了这个菜单——直接跳 HOME_PATH 会落到 404 兜底。
   * 有首页菜单就用首页,否则用菜单树里第一个能解析到组件的叶子。
   */
  function getDefaultPath(): string {
    const paths: string[] = []
    const walk = (nodes: MenuVO[]) => {
      ;(nodes || []).forEach((n) => {
        if (n.path && resolveComponent(n.component)) paths.push(n.path)
        if (n.children?.length) walk(n.children)
      })
    }
    walk(menus.value)
    return paths.includes(HOME_PATH) ? HOME_PATH : paths[0] || HOME_PATH
  }

  return {
    menus,
    menuTitleMap,
    isRoutesLoaded,
    generateRoutes,
    resetRoutes,
    reset,
    getDefaultPath,
    canReach,
    resolveComponent
  }
})