08 权限菜单与动态路由:不同人看到不同入口
08 权限菜单与动态路由:不同人看到不同入口
本章你会做出什么
你会将后台业务页面改为登录后动态注册。USER 访问不了看板,SUPERVISOR 可以进入看板和工单中心,ADMIN 还能看到系统管理。按钮可以通过 v-permission 隐藏。
先理解三个概念
1. 页面权限、按钮权限与数据权限
- 页面权限:没有对应权限码,就不注册该路由。
- 按钮权限:无操作权限就不展示按钮。
- 数据权限:后端仍需判断工单是否属于当前人或可查看范围。
只有第三点才是真正防止越权读取或操作的安全控制。
2. 动态路由
公共路由只有登录、403、404 和空的主布局。恢复当前用户后,根据 permissionCodes 将能访问的页面加到布局 children 中。刷新受保护地址时先恢复身份再注册,因此页面不会丢失。
3. 路由守卫
路由守卫在页面进入前执行:没有登录则跳登录;知道是系统页面但没有权限则跳 403;不存在的地址跳 404。
本章最终目录变化
client/src/
directives/permission.ts
router/
access.ts
index.ts # 改为动态路由版本
layouts/AppLayout.vue # 菜单按权限生成
main.ts # 注册指令
一步一步操作
第 1 步:创建权限工具
建立 client/src/router/access.ts。这个纯函数文件之后非常容易测试。
第 2 步:列出受保护页面
每一页的 meta.permission 标明访问所需权限;menu: true 表示在侧栏显示。
第 3 步:注册动态子路由
router.addRoute('app-layout', route) 将有权限页面加入布局;角色权限改变或退出时清掉旧路由。
第 4 步:创建按钮指令
业务按钮写成:
<el-button v-permission="'ticket:assign'">分配处理人</el-button>
没有权限时按钮节点会被移除。
第 5 步:后端继续拦截
例如分配接口必须加中间件:
app.post('/api/v1/tickets/:id/assign', authenticated, requirePermission('ticket:assign'), async (request, response) => {
// 第 10 章补业务处理
})
关键文件完整代码
client/src/router/access.ts
export type PermissionRequirement = string | string[] | undefined
export interface PermissionRoute {
path: string
meta?: { permission?: PermissionRequirement }
}
export const hasAnyPermission = (permissionCodes: string[], required?: PermissionRequirement) => {
if (!required) return true
const codes = Array.isArray(required) ? required : [required]
return codes.some((code) => permissionCodes.includes(code))
}
export const filterAccessibleRoutes = <T extends PermissionRoute>(routes: T[], permissionCodes: string[]) =>
routes.filter((route) => hasAnyPermission(permissionCodes, route.meta?.permission))
export const isKnownProtectedPath = (path: string, routes: PermissionRoute[]) =>
routes.some((route) => {
const pattern = route.path
.split('/')
.map((part) => (part.startsWith(':') ? '[^/]+' : part))
.join('/')
return new RegExp(`^${pattern}$`).test(path)
})
client/src/directives/permission.ts
import type { Directive } from 'vue'
import { useAuthStore } from '../stores/auth'
export const permission: Directive<HTMLElement, string | string[]> = {
mounted(element, binding) {
const auth = useAuthStore()
if (!auth.hasPermission(binding.value)) element.remove()
}
}
在 client/src/main.ts 中增加:
import { permission } from './directives/permission'
// 在 app.use(...) 之后、mount 之前
app.directive('permission', permission)
client/src/router/index.ts(关键完整实现)
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { filterAccessibleRoutes, isKnownProtectedPath } from './access'
declare module 'vue-router' {
interface RouteMeta {
title?: string
permission?: string | string[]
public?: boolean
menu?: boolean
}
}
export const protectedRoutes: RouteRecordRaw[] = [
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../views/DashboardPlaceholderView.vue'),
meta: { title: '运营看板', permission: ['dashboard:view', 'dashboard:view_own'], menu: true }
},
{
path: '/tickets/my',
name: 'my-tickets',
component: () => import('../views/tickets/TicketListView.vue'),
meta: { title: '我的工单', permission: 'ticket:view_own', menu: true }
},
{
path: '/tickets/manage',
name: 'ticket-center',
component: () => import('../views/tickets/TicketListView.vue'),
meta: { title: '工单中心', permission: ['ticket:view_assigned', 'ticket:view_all'], menu: true }
},
{
path: '/tickets/create',
name: 'ticket-create',
component: () => import('../views/tickets/TicketCreateView.vue'),
meta: { title: '新建工单', permission: 'ticket:create' }
},
{
path: '/tickets/:id',
name: 'ticket-detail',
component: () => import('../views/tickets/TicketDetailView.vue'),
meta: { title: '工单详情', permission: ['ticket:view_own', 'ticket:view_assigned', 'ticket:view_all'] }
},
{
path: '/system/users',
name: 'users',
component: () => import('../views/system/UsersView.vue'),
meta: { title: '用户管理', permission: 'system:user', menu: true }
},
{
path: '/system/roles',
name: 'roles',
component: () => import('../views/system/RolesView.vue'),
meta: { title: '角色权限', permission: 'system:role', menu: true }
},
{
path: '/system/categories',
name: 'categories',
component: () => import('../views/system/CategoriesView.vue'),
meta: { title: '工单分类', permission: 'system:category', menu: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { public: true, title: '登录' } },
{
path: '/403',
name: 'forbidden',
component: () => import('../views/errors/ForbiddenView.vue'),
meta: { public: true, title: '无权限' }
},
{ path: '/', name: 'app-layout', component: () => import('../layouts/AppLayout.vue'), children: [] },
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/errors/NotFoundView.vue'),
meta: { public: true, title: '页面不存在' }
}
]
})
export const defaultHome = (auth: ReturnType<typeof useAuthStore>) =>
auth.hasPermission(['dashboard:view', 'dashboard:view_own'])
? '/dashboard'
: auth.hasPermission('ticket:view_own')
? '/tickets/my'
: '/403'
let permissionKey = ''
let removers: Array<() => void> = []
const syncRoutes = (auth: ReturnType<typeof useAuthStore>) => {
const codes = auth.user?.permissionCodes ?? []
const nextKey = [...codes].sort().join('|')
if (permissionKey === nextKey) return false
removers.forEach((remove) => remove())
removers = filterAccessibleRoutes(protectedRoutes, codes).map((route) => router.addRoute('app-layout', route))
permissionKey = nextKey
return true
}
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!auth.ready) await auth.restore()
const changed = auth.isAuthenticated ? syncRoutes(auth) : false
if (changed && to.name === 'not-found' && router.resolve(to.fullPath).name !== 'not-found') {
return { path: to.fullPath, replace: true }
}
if (to.name === 'login' && auth.isAuthenticated) return defaultHome(auth)
if (to.name === 'not-found' && isKnownProtectedPath(to.path, protectedRoutes)) {
return auth.isAuthenticated ? '/403' : { path: '/login', query: { redirect: to.fullPath } }
}
if (to.meta.public) return true
if (!auth.isAuthenticated) return { path: '/login', query: { redirect: to.fullPath } }
if (to.path === '/') return defaultHome(auth)
return true
})
export default router
提示:创建 tickets 和 system 页面文件时可先使用与第 6 章类似的占位卡片;后续章节逐一替换。
AppLayout.vue 中的菜单逻辑
import { computed } from 'vue'
import { protectedRoutes } from '../router'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const menuRoutes = computed(() =>
protectedRoutes.filter((route) => route.meta?.menu && auth.hasPermission(route.meta.permission))
)
模板中用以下菜单替换固定菜单:
<el-menu router>
<el-menu-item v-for="route in menuRoutes" :key="String(route.name)" :index="route.path">
{{ route.meta?.title }}
</el-menu-item>
</el-menu>
启动并验证
- 使用
user / 123456登录,地址应跳至/tickets/my,看不到看板菜单。 - 手动输入
/dashboard,应显示 403。 - 退出后以
supervisor / 123456登录,应看到运营看板和工单中心。 - 以
admin / 123456登录,应看到用户、角色、分类三个系统菜单。 - 在已授权页面刷新浏览器,页面应仍然可显示。
常见报错与原因
| 现象 | 原因 | 修正 |
|---|---|---|
| 刷新工单页变为 404 | 先解析路由后才恢复权限,未重新导航 | 保留 routes changed 后重新解析的代码 |
| 换账号仍看到旧菜单 | 旧动态路由没有移除 | 切换权限时调用存储的 removers |
| 隐藏按钮后仍可调用接口 | 前端控制不是后端授权 | 为后端操作接口加入 requirePermission |
本章完成清单
- 四种账号看到不同菜单。
- 未登录进入保护页面会跳登录。
- 无页面权限进入系统地址会显示 403。
- 刷新授权页面不会丢失路由。
- 我知道后端权限校验不能省略。
面试时这一章能怎么讲
登录恢复 profile 后,我按照权限码动态注册主布局下的页面,并在刷新直达时重新解析目标路由;按钮使用权限指令控制交互展示,但读取范围和操作资格均由后端再次校验。