vue3 ---- 递归组件生成menu菜单 && 路由守卫鉴权
对于一些有规律的DOM结构,如果我们再一遍遍的编写同样的代码,显然代码是比较繁琐和不科学的,而且自己的工作量会大大增加,那么有没有一种方法来解决这个问题呢?答案是肯定的,我们可以通过方式来生成这个结构,当然在 Vue 模板中也是可以实现的,我们可以在 Vue 的组件中调用自己本身,这样就能达到目的。一个单文件组件可以通过它的文件名被其自己所引用。例如:名为FooBar.vue的组件可以在其模板中用
目录
对于一些有规律的DOM结构,如果我们再一遍遍的编写同样的代码,显然代码是比较繁琐和不科学的,而且自己的工作量会大大增加,
那么有没有一种方法来解决这个问题呢?
答案是肯定的,我们可以通过 递归 方式来生成这个结构,当然在 Vue 模板中也是可以实现的,我们可以在 Vue 的组件中调用自己本身,这样就能达到目的。
当然,在 Vue 中,组件可以递归的调用本身,但是有一些条件:
- 该组件一定要有
name
属性 - 要确保递归的调用有终止条件,防止内存溢出
递归组件
一个单文件组件可以通过它的文件名被其自己所引用。例如:名为 FooBar.vue
的组件可以在其模板中用 <FooBar/>
引用它自己。
el-menu
导航菜单默认为垂直模式,通过将 mode 属性设置为 horizontal 来使导航菜单变更为水平模式。 另外,在菜单中通过 sub-menu 组件可以生成二级菜单。 Menu 还提供了background-color
、text-color
和active-text-color
,分别用于设置菜单的背景色、菜单的文字颜色和当前激活菜单的文字颜色。
collapse | 是否水平折叠收起菜单(仅在 mode 为 vertical 时可用) | boolean | — | false |
background-color | 菜单的背景颜色(十六进制格式)(已被废弃,使用--bg-color ) | string | — | #ffffff |
text-color | 文字颜色(十六进制格式)(已被废弃,使用--text-color ) | string | — | #303133 |
active-text-color | 活动菜单项的文本颜色(十六进制格式)(已被废弃,使用--active-color ) | string | — | #409EFF |
default-active | 页面加载时默认激活菜单的 index | string | — | — |
index | 唯一标志 | string | — | — |
<template>
<el-menu
:default-active="activeIndex"
class="el-menu-demo"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
>
<el-menu-item index="0">LOGO</el-menu-item>
<div class="flex-grow" />
<el-menu-item index="1">Processing Center</el-menu-item>
<el-sub-menu index="2">
<template #title>Workspace</template>
<el-menu-item index="2-1">item one</el-menu-item>
<el-menu-item index="2-2">item two</el-menu-item>
<el-menu-item index="2-3">item three</el-menu-item>
<el-sub-menu index="2-4">
<template #title>item four</template>
<el-menu-item index="2-4-1">item one</el-menu-item>
<el-menu-item index="2-4-2">item two</el-menu-item>
<el-menu-item index="2-4-3">item three</el-menu-item>
</el-sub-menu>
</el-sub-menu>
</el-menu>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const activeIndex = ref('1')
const handleSelect = (key: string, keyPath: string[]) => {
console.log(key, keyPath)
}
</script>
<style>
.flex-grow {
flex-grow: 1;
}
</style>
父组件
<!-- 导航菜单 -->
<el-scrollbar class="scrollbar">
<el-menu
background-color="#001529"
text-color="white"
active-text-color="yellowgreen"
:default-active="route.path"
:collapse="SettingStore.fold ? true : false"
>
<Menu :menuList="UserStore.menuRouter"></Menu>
</el-menu>
</el-scrollbar>
子组件
必须要有name
要确保递归的调用有终止条件,防止内存溢出
<template>
<template v-for="item in menuList" :key="item.path">
<!-- 没有子集 -->
<templatem v-if="!item.children">
<el-menu-item
:index="item.path"
v-if="!item.meta.hidden"
@click="goRoute"
>
<el-icon>
<component :is="item.meta.icon"></component>
</el-icon>
<template #title>
<span>{{ item.meta.title }}</span>
</template>
</el-menu-item>
</templatem>
<!-- 只有一个子集 -->
<template v-if="item.children && item.children.length == 1">
<el-menu-item
@click="goRoute"
:index="item.children[0].path"
v-if="!item.children[0].meta.hidden"
>
<el-icon>
<component :is="item.children[0].meta.icon"></component>
</el-icon>
<template #title>
<span>{{ item.children[0].meta.title }}</span>
</template>
</el-menu-item>
</template>
<!-- 多个子集 -->
<el-sub-menu
:index="item.path"
v-if="item.children && item.children.length > 1"
>
<template #title>
<el-icon>
<component :is="item.meta.icon"></component>
</el-icon>
<span>{{ item.meta.title }}</span>
</template>
<Menu :menuList="item.children"></Menu>
</el-sub-menu>
</template>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
defineProps(['menuList'])
const goRoute = (e: any) => {
console.log(e.index)
router.push(e.index)
}
</script>
<script lang="ts">
export default {
name: 'Menu',
}
</script>
<style scoped></style>
路由
layout 布局组件
title:菜单标题
hidden: true,代表路由标题在菜单中是否隐藏 true:隐藏 false:不隐藏
icon: 'Promotion', 菜单文字左侧的图标,支持element-plus全部图标
//对外暴露配置路由(常量路由):全部用户都可以访问到的路由
export const constantRoute = [
{
//登录
path: '/login',
component: () => import('@/views/login/index.vue'),
name: 'login',
meta: {
title: '登录', //菜单标题
hidden: true, //代表路由标题在菜单中是否隐藏 true:隐藏 false:不隐藏
icon: 'Promotion', //菜单文字左侧的图标,支持element-plus全部图标
},
},
{
//登录成功以后展示数据的路由
path: '/',
component: () => import('@/layout/index.vue'),
name: 'layout',
meta: {
title: '',
hidden: false,
icon: '',
},
redirect: '/home',
children: [
{
path: '/home',
component: () => import('@/views/home/index.vue'),
meta: {
title: '首页',
hidden: false,
icon: 'HomeFilled',
},
},
],
},
{
//404
path: '/404',
component: () => import('@/views/404/index.vue'),
name: '404',
meta: {
title: '404',
hidden: true,
icon: 'DocumentDelete',
},
},
{
path: '/screen',
component: () => import('@/views/screen/index.vue'),
name: 'Screen',
meta: {
hidden: false,
title: '数据大屏',
icon: 'Platform',
},
},
]
//异步路由
export const asnycRoute = [
{
path: '/acl',
component: () => import('@/layout/index.vue'),
name: 'Acl',
meta: {
title: '权限管理',
icon: 'Lock',
},
redirect: '/acl/user',
children: [
{
path: '/acl/user',
component: () => import('@/views/acl/user/index.vue'),
name: 'User',
meta: {
title: '用户管理',
icon: 'User',
},
},
{
path: '/acl/role',
component: () => import('@/views/acl/role/index.vue'),
name: 'Role',
meta: {
title: '角色管理',
icon: 'UserFilled',
},
},
{
path: '/acl/permission',
component: () => import('@/views/acl/permission/index.vue'),
name: 'Permission',
meta: {
title: '菜单管理',
icon: 'Monitor',
},
},
],
},
{
path: '/product',
component: () => import('@/layout/index.vue'),
name: 'Product',
meta: {
title: '商品管理',
icon: 'Goods',
},
redirect: '/product/trademark',
children: [
{
path: '/product/trademark',
component: () => import('@/views/product/trademark/index.vue'),
name: 'Trademark',
meta: {
title: '品牌管理',
icon: 'ShoppingCartFull',
},
},
{
path: '/product/attr',
component: () => import('@/views/product/attr/index.vue'),
name: 'Attr',
meta: {
title: '属性管理',
icon: 'ChromeFilled',
},
},
{
path: '/product/spu',
component: () => import('@/views/product/spu/index.vue'),
name: 'Spu',
meta: {
title: 'SPU管理',
icon: 'Calendar',
},
},
{
path: '/product/sku',
component: () => import('@/views/product/sku/index.vue'),
name: 'Sku',
meta: {
title: 'SKU管理',
icon: 'Orange',
},
},
],
},
]
//任意路由
export const anyRoute = {
//任意路由
path: '/:pathMatch(.*)*',
redirect: '/404',
name: 'Any',
meta: {
title: '任意路由',
hidden: true,
icon: 'DataLine',
},
}
Vue路由守卫实现登录鉴权
一.路由守卫就是:
比如说,当点击商城的购物车的时候,需要判断一下是否登录,如果没有登录,就跳转到登录页面,如果登陆了,就跳转到购物车页面,相当于有一个守卫在安检路由守卫有三种:
1:全局钩子
2:独享守卫(单个路由里面的钩子
3:组件内守卫
每个守卫方法接收三个参数:①to: Route: 即将要进入的目标路由对象(to是一个对象,是将要进入的路由对象,可以用to.path调用路由对象中的属性)
②from: Route: 当前导航正要离开的路由
③next: Function: 这是一个必须需要调用的方法,执行效果依赖 next 方法的调用参数。
全局守卫
在路由跳转前触发,参数包括to,from,next(参数会单独介绍)三个,这个钩子作用主要是用于登录验证,也就是路由还没跳转提前告知,以免跳转了再通知就为时已晚。
全局解析守卫beforeResolve(2.5.0 新增)
在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被调用
全局后置钩子afterEach
全局后置钩子不会接受 next 函数也不会改变导航本身
//路由鉴权:鉴权,项目当中路由能不能被的权限的设置(某一个路由什么条件下可以访问、什么条件下不可以访问)
import router from '@/router'
import setting from './setting'
//@ts-ignore
import nprogress from 'nprogress'
//引入进度条样式
import 'nprogress/nprogress.css'
nprogress.configure({ showSpinner: false })
//获取用户相关的小仓库内部token数据,去判断用户是否登录成功
import useUserStore from './store/modules/user'
import pinia from './store'
const userStore = useUserStore(pinia)
//全局守卫:项目当中任意路由切换都会触发的钩子
//全局前置守卫
router.beforeEach(async (to: any, from: any, next: any) => {
document.title = `${setting.title} - ${to.meta.title}`
//to:你将要访问那个路由
//from:你从来个路由而来
//next:路由的放行函数
nprogress.start()
//获取token,去判断用户登录、还是未登录
const token = userStore.token
//获取用户名字
const username = userStore.username
//用户登录判断
if (token) {
//登录成功,访问login,不能访问,指向首页
if (to.path == '/login') {
next({ path: '/' })
} else {
//登录成功访问其余六个路由(登录排除)
//有用户信息
if (username) {
//放行
next()
} else {
//如果没有用户信息,在守卫这里发请求获取到了用户信息再放行
try {
//获取用户信息
await userStore.userInfo()
//放行
//万一:刷新的时候是异步路由,有可能获取到用户信息、异步路由还没有加载完毕,出现空白的效果
next({ ...to })
} catch (error) {
//token过期:获取不到用户信息了
//用户手动修改本地存储token
//退出登录->用户相关的数据清空
await userStore.userLogout()
next({ path: '/login', query: { redirect: to.path } })
}
}
}
} else {
//用户未登录判断
if (to.path == '/login') {
next()
} else {
next({ path: '/login', query: { redirect: to.path } })
}
}
})
//全局后置守卫
router.afterEach((to: any, from: any) => {
nprogress.done()
})
//第一个问题:任意路由切换实现进度条业务 ---nprogress
//第二个问题:路由鉴权(路由组件访问权限的设置)
//全部路由组件:登录|404|任意路由|首页|数据大屏|权限管理(三个子路由)|商品管理(四个子路由)
//用户未登录:可以访问login,其余六个路由不能访问(指向login)
//用户登录成功:不可以访问login[指向首页],其余的路由可以访问
路由独享的守卫
指在单个路由配置的时候也可以设置的钩子函数
beforeEnter
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
meta: { isAuth: true },
beforeEnter: (to, from, next) => {
if (to.meta.isAuth) { //判断是否需要授权
if (localStorage.getItem('school') === 'qinghuadaxue') {
next() //放行
} else {
alert('抱歉,您无权限查看!')
}
} else {
next() //放行
}
}
},
组件内的守卫
beforeRouteEnter 守卫不能访问 this,因为守卫在导航确认前被调用,因此即将登场的新组件还没被创建。
注意:beforeRouteEnter 是支持给 next 传递回调的唯一守卫。对于 beforeRouteUpdate 和 beforeRouteLeave 来说,this 已经可用了,所以不支持传递回调,因为没有必要了。
//通过路由规则,进入该组件时被调用
beforeRouteEnter(to,from,next) {
if(toString.meta.isAuth){
if(localStorage.getTime('school')==='qinghuadaxue'){
next()
}else{
alert('学校名不对,无权限查看!')
}
} else{
next()
}
},
beforeRouteUpdate(2.2 新增)
这个守卫主要是路由复用时被调用,即在当前页面跳转当前页面,会走该守卫,而不会从头走路由钩子函数。
beforeRouteUpdate (to, from, next) {
// 在当前路由改变,但是该组件被复用时调用
// 举例来说,对于一个带有动态参数的路径 /foo/:id,在 /foo/1 和 /foo/2 之间跳转的时候,
// 由于会渲染同样的 Foo 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
// 可以访问组件实例 `this`
}
beforeRouteLeave
这个离开守卫通常用来禁止用户在还未保存修改前突然离开。该导航可以通过 next(false) 来取消。
beforeRouteLeave (to, from, next) {
// 导航离开该组件的对应路由时调用
// 可以访问组件实例 `this`
}
完整的导航解析流程
- 导航被触发。
- 在失活的组件里调用 beforeRouteLeave 守卫。(组件内守卫,离开组件)
- 调用全局的 beforeEach 守卫。(全局前置守卫)
- 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。(组件内守卫,组件被复用)
- 在路由配置里调用 beforeEnter。(组件内守卫)
- 解析异步路由组件。
- 在被激活的组件里调用 beforeRouteEnter。(组件内守卫,进入组件)
- 调用全局的 beforeResolve 守卫 (2.5+)。(导航被确认前,组件内路由被加载完成)
- 导航被确认。
- 调用全局的 afterEach 钩子。(全局后置守卫)
- 触发 DOM 更新。
- 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。(组件内守卫,进入组件)
菜单权限
通过后端接口返回的用户权限和之前前端写的异步路由的每一个页面所需要的权限做匹配,最后返回一个该用户能够访问路由有哪些。 addRoutes动态设置成功的路由表
这里还有一个小hack的地方,就是router.addRoutes之后的next()可能会失效,因为可能next()的时候路由并没有完全add完成,好在查阅文档发现
这样我们就可以简单的通过next(to)巧妙的避开之前的那个问题了。这行代码重新进入router.beforeEach这个钩子,这时候再通过next()来释放钩子,就能确保所有的路由都已经挂在完成了。
使用 lodash对异步路由表进行深克隆,避免过滤之后影响路由表的完整
import { defineStore } from 'pinia'
import type { loginFormData, loginResponseData } from '@/api/type'
import { reqLogin, reqUserInfo, reqLogout } from '@/api/user'
import { ref, Ref } from 'vue'
import { constanRouter, asnycRoute, anyRoute } from '@/router/routes'
import type { RouteRecordRaw } from 'vue-router'
import { SET_TOKEN, GET_TOKEN, REMOVE_TOKEN } from '@/utils/token'
import router from '@/router'
//@ts-expect-error
import cloneDeep from 'lodash/cloneDeep'
export const useUserStore = defineStore('user', () => {
const token: Ref<string> = ref(GET_TOKEN() as string)
// 仓库存储生成菜单需要的数组
let menuRouter: Ref<RouteRecordRaw[]> = ref(constanRouter)
let buttons: Ref<string[]> = ref([])
const username: Ref<string> = ref('')
const avatar: Ref<string> = ref('')
// 过滤异步路由
const filterAsyncRoute = (asnycRoute: any, routes: any) => {
return asnycRoute.filter((item: any) => {
if (routes.includes(item.name)) {
if (item.children && item.children.length > 0) {
item.children = filterAsyncRoute(item.children, routes)
}
return true
}
})
}
//登录
const userLogin = async (data: loginFormData) => {
console.log(data)
let res: loginResponseData = await reqLogin(data)
console.log(res)
if (res.code === 200) {
token.value = res.data as string
SET_TOKEN(res.data as string)
return 'ok'
} else {
return Promise.reject(new Error(res.data))
}
}
// 获取用户信息
const userInfo = async () => {
let res = await reqUserInfo()
// console.log(res)
if (res.code === 200) {
console.log('-------')
username.value = res.data.name
avatar.value = res.data.avatar
buttons.value = res.data.buttons
// 异步路由
const userAsyncRoute = filterAsyncRoute(
cloneDeep(asnycRoute),
res.data.routes,
)
//菜单需要的数据整理完毕
menuRouter.value = [...constanRouter, ...anyRoute, ...userAsyncRoute]
//目前路由器管理的只有常量路由:用户计算完毕异步路由、任意路由动态追加
console.log(menuRouter);
[...userAsyncRoute, anyRoute].forEach((route: any) => {
router.addRoute(route)
})
return 'ok'
} else {
return Promise.reject('获取用户信息失败')
}
}
// 退出登录
const userlogout = async () => {
let res = await reqLogout()
if (res.code === 200) {
token.value = ''
username.value = ''
avatar.value = ''
REMOVE_TOKEN()
return 'ok'
} else {
return Promise.reject(res.message)
}
}
return {
userLogin,
token,
menuRouter,
userInfo,
username,
avatar,
userlogout,
buttons,
}
})
按钮权限
封装了一个全局自定义指令
权限,能简单快速的实现按钮级别的权限判断。
实现步骤:
我们登录成功之后,处理接口返回数据的时候,就存储了用户权限按钮数组buttons,
进行封装,项目根目录下新建一个directive文件夹 =》has.ts
主要思路就是用户没有这个按钮权限的话,隐藏按钮。
import { useUserStore } from '@/store/user'
export const isHasButton = (app: any) => {
//获取对应的用户仓库
//全局自定义指令:实现按钮的权限
app.directive('has', {
//代表使用这个全局自定义指令的DOM|组件挂载完毕的时候会执行一次
mounted(el: any, options: any) {
let userStore = useUserStore()
console.log(el, '----------')
//自定义指令右侧的数值:如果在用户信息buttons数组当中没有
//从DOM树上干掉
if (!userStore.buttons.includes(options.value)) {
el.parentNode.removeChild(el)
}
},
})
}
main.ts中注册为全局指令
//引入自定义指令文件
import { isHasButton } from '@/directive/has'
isHasButton(app)
页面中使用
<el-button
type="primary"
size="default"
@click="addUser"
v-has="`btn.User.add`"
>
添加用户
</el-button>
更多推荐
所有评论(0)