permission.ts
5.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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
}
})