前端转AI全栈开发第三天:Vue3+TypeScript动态路由封装与权限控制(Element Plus Layout集成)
1. 项目背景与目标在上一篇文章中我们已经完成了 Pinia 状态管理的封装实现了用户信息、Token 和菜单列表的集中存储。本篇将在此基础上深入讲解 Vue Router 的动态路由封装与权限控制方案并集成 Element Plus Layout 布局构建完整的后台管理系统。核心目标实现基于后端菜单数据的动态路由注册。设计模块化的静态路由结构便于维护。实现完整的路由守卫包括登录校验、白名单、404 和 401 页面处理。集成 Element Plus Layout 布局组件。确保 TypeScript 类型安全。2. 项目结构与 Element Plus Layout 集成我们将使用 Element Plus 的 Container 布局组件构建后台管理系统的基础布局。2.1 安装 Element Plusnpm install element-plus element-plus/icons-vue2.2 基础布局组件 (src/components/Layout/adminLayout.vue)script setup langts import LeftMenu from /components/Layout/leftMenu.vue; /script template el-container el-header classheader-container !-- 顶部导航栏 -- div classheader后台管理系统/div /el-header el-container classmain-container el-aside width200px left-menu / /el-aside el-main classcener-page !-- 页面内容区域 -- router-view / /el-main /el-container /el-container /template style scoped .header-container{ height: 60px; background-color: #169bfa; .header { height: 100%; display: flex; align-items: center; font-size: 18px; font-weight: bold; } } .main-container{ height: calc(100vh - 60px); .cener-page{ background-color: #E9EEF3; } } /styleleft-menu.vue是左侧菜单栏组件它从 Pinia 中获取菜单数据并与动态路由使用同一套数据源确保菜单与路由权限的一致性。template div classleft-menu-container !-- 菜单区域 -- el-menu :default-activeactiveMenu classsidebar-menu background-color#001529 text-color#b7bdc3 active-text-color#ffffff :collapseisCollapse :unique-openedtrue router !-- 递归渲染菜单项 -- menu-item v-foritem in menuList :keyitem.path :menuitem / /el-menu /div /template script setup langts import { computed } from vue; import { useRoute } from vue-router; import MenuItem from ./menuItem.vue; import {useAppStore, useUserStore} from /store/modules; // 获取当前路由 const route useRoute(); // 从 Pinia 获取状态 const userStore useUserStore(); const appStore useAppStore(); // 计算属性菜单列表从 Pinia 中获取 const menuList computed(() { return userStore.menuList.filter((menu) !menu.meta.isHidden); }); // 计算属性当前激活的菜单 const activeMenu computed(() { const { meta, path } route; // 如果有设置激活菜单则使用 if (meta.activeMenu) { return meta.activeMenu as string; } return path; }); // 计算属性是否折叠从 appStore 获取 const isCollapse computed(() appStore.sidebarCollapsed); /script style scoped langscss .left-menu-container { height: 100%; display: flex; flex-direction: column; background-color: #001529; .logo { height: 60px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.3s; padding: 0 16px; :hover { background-color: rgba(255, 255, 255, 0.1); } .logo-img { width: 32px; height: 32px; margin-right: 8px; } .logo-text { color: #ffffff; font-size: 18px; font-weight: bold; white-space: nowrap; overflow: hidden; } } .sidebar-menu { flex: 1; border-right: none; // 自定义滚动条 ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: #001529; } ::-webkit-scrollbar-thumb { background: #4a5568; border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: #718096; } } } // 折叠状态下的样式 .el-menu--collapse { .logo { padding: 0; justify-content: center; .logo-img { margin-right: 0; } .logo-text { display: none; } } } /styleMenuItem.vue 子组件为了支持无限级菜单嵌套我们创建一个 MenuItem 递归组件script langts import { defineComponent, type PropType } from vue import type { MenuItem as MenuRecord } from /type/menu export default defineComponent({ name: MenuItem, props: { menu: { type: Object as PropTypeMenuRecord, required: true } }, setup() { /** * 子菜单统一按数组处理避免 children 为空时模板读取 length 报错。 */ const getChildren (menu: MenuRecord) (menu.children ?? []).filter((child) !child.meta.isHidden) /** * 判断当前菜单是否需要渲染成 el-sub-menu。 */ const hasChildren (menu: MenuRecord) getChildren(menu).length 0 /** * 菜单标题优先使用路由 meta.title没有配置时用 name 兜底。 */ const getTitle (menu: MenuRecord) menu.meta?.title || menu.name return { getChildren, hasChildren, getTitle } } }) /script template el-sub-menu v-ifhasChildren(menu) :indexmenu.path popper-classsub-menu-popper template #title el-icon v-ifmenu.meta?.icon component :ismenu.meta.icon / /el-icon span classmenu-title{{ getTitle(menu) }}/span /template !-- 递归渲染子菜单数据结构与 leftMenu.vue 传入的 :menu 保持一致。 -- menu-item v-forchild in getChildren(menu) :keychild.path :menuchild / /el-sub-menu el-menu-item v-else :indexmenu.path el-icon v-ifmenu.meta?.icon component :ismenu.meta.icon / /el-icon span classmenu-title{{ getTitle(menu) }}/span /el-menu-item /template style scoped langscss .menu-title { margin-left: 8px; } // el-sub-menu 的弹出层会挂到组件外部需要用全局选择器覆盖弹层样式。 :global(.sub-menu-popper) { .el-menu { background-color: #0c2135 !important; .el-menu-item { background-color: #0c2135 !important; :hover, .is-active { background-color: #1890ff !important; color: #ffffff !important; } } .el-sub-menu__title { background-color: #0c2135 !important; :hover { background-color: #1890ff !important; } } } } /stylePinia Store 中的菜单数据left-menu.vue 组件从 userStore 中获取菜单数据以下是 userStore 中相关代码import { defineStore } from pinia import { computed, reactive, toRefs } from vue import type { MenuItem } from /type/menu import type { UserInfo } from /type // 用户模块 Store负责保存登录态、用户信息和菜单权限数据。 export const useUserStore defineStore(user, () { // 使用 reactive 集中管理用户状态后面通过 toRefs 暴露给组件和路由守卫。 const state reactive{ // 登录凭证路由守卫通过它判断用户是否已登录。 token:string // 当前登录用户信息页面可以根据它展示用户名、头像、角色等内容。 userInfo:UserInfo | null // 当前用户拥有的菜单树动态路由和左侧菜单都从这里读取。 menuList:MenuItem[] }({ // token 初始为空表示未登录。 token:, // 用户信息初始为空登录成功后写入。 userInfo:null, // 菜单初始为空登录后或路由守卫中通过 fetchUserMenu 写入。 menuList:[] }) // 计算登录态只要 token 有值就认为当前用户已登录。 const isLogin computed(() Boolean(state.token)) // 写入 token登录成功后调用。 const setToken (newToken: string) { state.token newToken } // 写入用户信息登录成功后调用。 const setUserInfo (info: UserInfo) { state.userInfo info } // 写入菜单权限路由守卫会根据这个菜单树注册动态路由。 const setMenuList (list: MenuItem[]) { state.menuList list } const login (res:any){ setToken(res.token) setUserInfo(res.userInfo) setMenuList(res.menuList) } // 退出登录清理登录凭证、用户信息和菜单权限。 const logout () { state.token state.userInfo null state.menuList [] } // 返回给组件、页面、路由守卫使用的状态和方法。 return { // reactive 对象不能直接展开返回否则 menuList 会失去响应式leftMenu.vue 无法感知菜单更新。 ...toRefs(state), // 暴露计算后的登录态。 isLogin, // 暴露设置用户信息的方法。 setUserInfo, // 暴露设置菜单的方法。 setMenuList, // 登录方法 login, // 暴露退出登录的方法。 logout } }, { // Pinia 持久化配置。 persist: { // 只持久化这些字段刷新页面后路由守卫可以恢复登录态和菜单权限。 pick: [token, userInfo, menuList], // 持久化登录态和菜单权限刷新页面后可恢复动态路由。 // 使用 localStorage关闭浏览器后数据仍然保留。 storage: localStorage, // localStorage 中的 key 名称。 key: userVo // 自定义存储键名 } })使用说明数据同步left-menu.vue 组件通过userStore.menuList获取菜单数据这与动态路由使用的是同一套数据源确保菜单与路由权限完全一致。递归渲染通过 MenuItem.vue 递归组件支持无限级菜单嵌套。激活状态根据当前路由自动高亮对应菜单项。折叠功能菜单支持折叠/展开状态存储在 appStore 中。样式定制使用 Element Plus 的 Menu 组件并自定义了深色主题样式。这样左侧菜单栏就能根据用户权限动态显示对应的菜单项并与路由系统完美集成。2.3 路由模块化设计我们将路由分为静态路由和动态路由两部分采用模块化组织。3. 动态路由实现动态路由的核心是根据登录后从后端获取的菜单列表动态添加路由记录。3.1 定义菜单与路由类型export interface MenuItem { name?: string path: string meta: { title: string icon?: string isHidden?: boolean roles?:string[] }, component?: string redirect?:string, children?: MenuItem[] }3.2 动态路由添加工具函数import { createRouter, createWebHistory, type RouteRecordRaw } from vue-router import adminLayout from /components/Layout/adminLayout.vue import { useUserStore } from /store/modules import demoRoutes from /router/modules/demo; import type { MenuItem } from /type/menu // 入口文件 app.use(router) 需要安装 Router 实例不能直接传路由数组。 const ADMIN_ROOT_ROUTE_NAME admin-root const viewModules require.context(/views, true, /\.vue$/) const routes: RouteRecordRaw[] [ { name: ADMIN_ROOT_ROUTE_NAME, path:/, redirect:/index, component:adminLayout, children:[ { path:/index, meta:{ title:首页, }, component:()import(/views/index.vue) } ] }, { path: /admin/login, name: login, meta: { title: 登录, white: true }, component:()import(/views/login/adminLogin.vue) }, { path: /401, name: forbidden, meta: { // 页面标题。 title: 无权限, white: true }, // 懒加载无权限页面。 component: () import(/views/401.vue) }, { path: /404, name: not-found, meta: { title: 页面不存在, white: true }, component: () import(/views/404.vue) }, { path: /:pathMatch(.*)*, redirect: /404 }, // 前台门户 ...demoRoutes ] const router createRouter({ history: createWebHistory(process.env.BASE_URL || /), routes }) let dynamicRoutesRegistered false const normalizeMenuPath (path: string, parentPath ) { if (path.startsWith(/)) { return path } const normalizedParent parentPath.endsWith(/) ? parentPath.slice(0, -1) : parentPath return ${normalizedParent}/${path}.replace(/\//g, /) } const getRouteName (menu: MenuItem, fullPath: string) { if (menu.name) { return menu.name } const routeKey fullPath.replace(/^\//, ).replace(/[^\w]/g, -) return dynamic-${routeKey || root} } const normalizeViewPath (componentPath?: string) { if (!componentPath) { return } let viewPath componentPath.trim().replace(/\\/g, /) viewPath viewPath .replace(/^\/views\//, ) .replace(/^\/?src\/views\//, ) .replace(/^\/?views\//, ) .replace(/^\//, ) if (!viewPath.endsWith(.vue)) { viewPath ${viewPath}.vue } return viewPath.startsWith(./) ? viewPath : ./${viewPath} } const resolveViewComponent (menu: MenuItem) { const viewPath normalizeViewPath(menu.component) if (!viewPath || !viewModules.keys().includes(viewPath)) { return undefined } // 后端只返回页面地址前端用 require.context 限制可加载范围避免任意路径被注册成路由。 return () Promise.resolve(viewModules(viewPath).default) } // 注册动态菜单 const flattenMenuRoutes (menuList: MenuItem[], parentPath ): RouteRecordRaw[] { return menuList.flatMap((menu) { const fullPath normalizeMenuPath(menu.path, parentPath) const component resolveViewComponent(menu) const children flattenMenuRoutes(menu.children || [], fullPath) const currentRoute component || menu.redirect ? [{ path: fullPath, name: getRouteName(menu, fullPath), meta: menu.meta, redirect: menu.redirect, component } as RouteRecordRaw] : [] return [...currentRoute, ...children] }) } const hasRoutePath (path: string) { return router.getRoutes().some((route) route.path path) } const registerDynamicRoutes (menuList: MenuItem[]) { if (dynamicRoutesRegistered || menuList.length 0) { return false } const dynamicRoutes flattenMenuRoutes(menuList) dynamicRoutes.forEach((route) { if (!hasRoutePath(route.path)) { router.addRoute(ADMIN_ROOT_ROUTE_NAME, route) } }) dynamicRoutesRegistered true return dynamicRoutes.length 0 } // 路由守卫 router.beforeEach(async (to, from, next) { // 守卫运行时 Pinia 已经由 main.ts 安装完成此时再获取 store 可以避免初始化阶段报错。 const userStore useUserStore() const registered registerDynamicRoutes(userStore.menuList) if (registered) { const target to.redirectedFrom?.fullPath || to.fullPath return next({ path: target, replace: true }) } if(!userStore.isLogin !to.meta.white to.name ! login) { // 重定向到登录页面 return next({name:login}) } return next() }) export default router3.3 在登录成功后调用script setup langts import {useUserStore} from /store/modules; import { useRouter} from vue-router; const userStore useUserStore() // const route useRoute() const router useRouter() // 方法调用 function login() { const res { code:200, data:{ token:aaa, userInfo:{ userName:张三, id:1, roles:[admin], }, menuList:[ { meta: { title: 首页, isHidden: false }, path: /index, component: /views/index.vue }, { meta: { title: 菜单管理, isHidden: false }, path: /menuList, component: /views/menu/list.vue }, { meta: { title: 字典管理, isHidden: false }, path: /dict, component: /views/dict/index.vue, // children 表示子菜单router/index.ts 会递归转换成子路由。 children: [ { meta: { title: 新增字典, isHidden: false }, path: /dict/add, component: /views/dict/addMenu.vue } ] } ] } } if(res.code200) { userStore.login(res.data) router.push(/) } } const handleLogout () { userStore.logout() } /script template el-button clicklogin登录/el-button div clickhandleLogout退出/div /template style scoped langscss /style4. 路由守卫实现路由守卫是权限控制的核心我们将实现完整的校验逻辑。4.1 路由守卫主文件 (router/guard.ts)4.2 错误页面路由模块 (modules/error.ts)import type { RouteRecordRaw } from vue-router; const errorRoutes: RouteRecordRaw[] [ { path: /404, name: NotFound, component: () import(/views/error/404.vue), meta: { title: 页面不存在, requiresAuth: false } }, { path: /401, name: Unauthorized, component: () import(/views/error/401.vue), meta: { title: 无权限访问, requiresAuth: false } } ]; export default errorRoutes;