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