手把手教你怎么实现一个后台管理系统——菜单权限篇(一)

2022-10-28 19:04:30 浏览数 (1)

01

前言

hello,广大的小伙伴们大家好,感觉好长时间没有更新文章。只因这段时间公司的事是非常的多,还请大家见谅。等忙完这段时间之后,就会恢复正常。

不知道小伙伴们还记不记得在前一段时间,我曾写过一篇文章名叫:

手把手教你怎么实现一个后台管理系统——架构篇

这篇文章承接以上篇,建议大家可以先进行阅读上一篇文章的内容,以便做到心中有数。

这篇文章主要是给大家说一下,VueAdminWork框架中菜单权限的实现过程。

02

需要的知识体系

不得不说,在整个VueAdminWork框架的开发过程中,实现整套的菜单权限是一个非常麻烦的功能,但是只单纯的实现根据不同用户的角色获取不同的菜单这一功能是不麻烦的(废话,因为这是后端做的)。所以大家也不用很害怕自己能不能实现。

VueAdminWork框架中所实现的菜单权限主要是后端进行控制的。与传统的菜单权限相比并没有太大的改变,不一样的只是前端开发人员需要根据权限数据转化成菜单再配合着路由功能进行实现。

现在说一下在做一功能之前需要的准备知识:

  1. 路由知识(Vue Router)
  2. 网络操作知识(Axios)
  3. 某个UI组件库的Menu等组件用法(NaiveUI、ArcoDesign等)
  4. 状态管理知识(Vuex、Pinia)
  5. Vue2、Vue3的基本知识(最基础的)

从上面可以看出要实现这一个功能还是非常综合性的,这就需要大家具有非常殷实的基本功能。

(PS:有些小伙伴经常的问我基础知识倒底重不重要,很多功能,我从网上随便找个框架或者第三方库简单的根据文档改一下就能用,也能满足日常开发的大部分功能,确实,现在的库都是非常好用的,基本上是傻瓜式的配置,但是大家一定要知识这些看似很厉害的库也都是基础知识一点点的积累。如果作者没有扎实的基本功,也是不可能写出这样的库的,所以在这里我要和大家说的事,基础知识非常非常的重要,一定要好好学习基础,千万不能眼高手低。不要做一看不会,一做就废的人)

03

实现思路

前面也已经说了,VueAdminWork框架实现的菜单功能,主要是通过后端控制的方式。下面说一下实现的思路

  1. 用户通过用户名和密码进行登录操作,然后把用户信息保存在状态管理中。特别是用户id、角色id、token这三个数据
  2. 登录成功之后,再根据以上三个数据通过axios网络操作获取菜单权限数据
  3. 在获取到权限数据之后,把数据保存到状态管理中,确保状态管理中只存一份。
  4. 通过一系列的操作把系统状态中的权限数据适配到UI组件库的Menu组件中,这样最终就可以实现这个功能

以上只是说了一个思路,具体的实现过程还需要非常多的细节要处理,下面重点的说一下具体的实现过程

04

实现过程

用户登录并保存用户信息

代码语言:javascript复制
const onLogin = () => {
        loading.value = true
        post({
          url: login,
          data: {
            username: username.value,
            password: password.value,
          },
        })
          .then((res: any) => {
            userStore.saveToken(res.access_token).then(() => {
              get({
                url: getUserInfo,
              }).then((info: any) => {
                userStore.saveUser(info as UserState).then(() => {
                  router
                    .replace({
                      path: route.query.redirect ? (route.query.redirect as string) : '/',
                    })
                    .then(() => {
                      Message.success('登录成功,欢迎:'   username.value)
                      loading.value = false
                    })
                })
              })
            })
          })
          .catch((error) => {
            loading.value = false
            Message.error(error.message)
          })
      }

获取菜单数据,并且存入到状态管理中

实现这一功能的主要文件在 `src/utils/router.ts`,大家不要找错了文件

这里主要的用 vue-router的 beforeEach 钩子函数,这样做的目的是在每一次的路由操作的都会判断有没有相应的菜单数据,如果没有就会通过网络获取。这样是为了用户在 按 F5 刷新页面的时候,能正常的进行页面跳转。防止没有数据面产生异常

代码语言:javascript复制
router.beforeEach((to) => {
  NProgress.start()
  if (whiteRoutes.includes(to.path)) {
    return true
  } else {
    if (!isTokenExpired()) {
      return {
        path: '/login',
        query: { redirect: to.fullPath },
      }
    } else {
      const isEmptyRoute = layoutStore.isEmptyPermissionRoute()
      if (isEmptyRoute) {
        // 加载路由
        const accessRoutes = getRoutes()
        const mapRoutes = mapTwoLevelRouter(accessRoutes)
        mapRoutes.forEach((it: any) => {
          router.addRoute(it)
        })
        router.addRoute({
          path: '/:pathMatch(.*)*',
          redirect: '/404',
          hidden: true,
        } as RouteRecordRaw)
        layoutStore.initPermissionRoute([...constantRoutes, ...accessRoutes])
        return { ...to, replace: true }
      } else {
        return true
      }
    }
  }
})

layoutStore.initPermissionRoute([...constantRoutes, ...accessRoutes])

这行代码的作用主要把生成的路由表存入到状态管理中,方便以后的取操作

根据菜单数据进行UI适配,显示到页面中

以上的操作都做完之后,最后一步就是把数据适配到组件库中的Menu组件上了,这里以ArcoDesign组件库为例。

关于左边菜单栏的功能文件都放在 `src/layouts/sidebar`目录下面,(不同版本文件可能有所不同,但大致一样。)因为需要实现不同的样式, 所以放在不同的文件中。

src/layouts/sidebar/SideBar.vue内容如下:

代码语言:javascript复制
<template>
  <div
    class="vaw-side-bar-wrapper"
    :style="{ borderRadius: '0px', marginTop: state.layoutMode === 'ttb' ? '48px' : 0 }"
    :class="[!state.isCollapse ? 'open-status' : 'close-status', bgColor]"
  >
    <transition name="logo">
      <Logo v-if="showLogo" />
    </transition>
    <ScrollerMenu :routes="routes" />
  </div>
</template>

<script lang="ts">
  import { computed, defineComponent } from 'vue'
  import { useLayoutStore } from '../index'
  export default defineComponent({
    name: 'SideBar',
    props: {
      showLogo: {
        type: Boolean,
        default: true,
      },
    },
    setup() {
      const store = useLayoutStore()
      const routes = computed(() => {
        return store?.state.permissionRoutes.filter((it) => !!it.name)
      })
      const bgColor = computed(() => {
        if (store.state.sideBarBgColor === 'image') {
          return 'sidebar-bg-img'
        } else if (store.state.sideBarBgColor === 'dark') {
          return 'sidebar-bg-dark'
        } else {
          return 'sidebar-bg-light'
        }
      })
      return {
        state: store?.state,
        routes,
        bgColor,
      }
    },
  })
</script>

src/layouts/sidebar/components/ScrollerMenu.vue内容如下:

代码语言:javascript复制
<template>
  <component :is="tag">
    <a-menu
      :mode="menuMode"
      :theme="theme"
      :collapsed="state.isCollapse"
      v-model:selectedKeys="defaultPath"
      v-model:openKeys="defaultExpandKeys"
      @menu-item-click="onMenuClick"
    >
      <template v-for="item of menuOptions" :key="item.key">
        <template v-if="!item.children">
          <a-menu-item :key="item.key">
            <template #icon>
              <component :is="item.icon || 'icon-menu'" />
            </template>
            {{ item.label }}
          </a-menu-item>
        </template>
        <template v-else>
          <SubMenu :key="item.key" :menu-info="item" />
        </template>
      </template>
    </a-menu>
  </component>
</template>

<script lang="ts">
  import {
    computed,
    defineComponent,
    PropType,
    ref,
    shallowReactive,
    watch,
    watchEffect,
  } from 'vue'
  import { useRoute, useRouter } from 'vue-router'
  import { useLayoutStore } from '../../index'
  import { RouteRecordRawWithHidden } from '../../../types/store'
  import { isExternal, transfromMenu } from '../../../utils'

  export default defineComponent({
    name: 'ScrollerMenu',
    props: {
      routes: {
        type: Object as PropType<Array<RouteRecordRawWithHidden>>,
        require: true,
        default: () => [],
      },
      mode: {
        type: String,
        default: 'vertical',
      },
    },
    setup(props) {
      const store = useLayoutStore()
      const menuOptions = shallowReactive([] as Array<any>)
      const defaultPath = ref([] as Array<string>)
      const defaultExpandKeys = ref([] as Array<string>)
      const menuMode = computed(() => props.mode)
      const currentRoute = useRoute()
      const router = useRouter()
      defaultPath.value.push(currentRoute.fullPath)
      const tag = ref(menuMode.value === 'vertical' ? 'Scrollbar' : 'div')
      const theme = computed(() => {
        if (store.state.theme === 'dark') {
          return 'dark'
        }
        if (store.state.layoutMode === 'ttb') {
          return 'light'
        }
        return store.state.sideBarBgColor === 'image'
          ? 'dark'
          : store.state.sideBarBgColor === 'white'
          ? 'light'
          : 'dark'
      })
      handleExpandPath()
      function handleMenu(routes?: Array<RouteRecordRawWithHidden>) {
        menuOptions.length = 0
        const tempMenus = transfromMenu(routes || [])
        menuOptions.push(...tempMenus)
      }
      function handleExpandPath() {
        const paths = currentRoute.fullPath.split('/')
        paths.forEach((it) => {
          if (it && !defaultExpandKeys.value.includes('/'   it)) {
            defaultExpandKeys.value.push('/'   it)
          }
        })
      }
      function onMenuClick(key: string) {
        if (isExternal(key)) {
          window.open(key)
        } else {
          router.push(key)
          if (store.state.device === 'mobile') {
            store.toggleCollapse(true)
          }
        }
      }
      watch(
        () => currentRoute.fullPath,
        (newVal) => {
          defaultPath.value.length = 0
          defaultPath.value.push(newVal)
          handleExpandPath()
        }
      )
      watch(
        () => props.mode,
        (newVal) => {
          newVal === 'vertical' ? 'Scrollbar' : 'div'
        }
      )
      watchEffect(() => {
        handleMenu(props.routes)
      })
      return {
        tag,
        theme,
        menuMode,
        defaultPath,
        defaultExpandKeys,
        state: store?.state,
        menuOptions,
        onMenuClick,
      }
    },
  })
</script>

<style lang="less" scoped>
  :deep(.arco-menu-collapsed) {
    margin: 0 auto;
  }
  :deep(.arco-menu-vertical .arco-menu-item) {
    max-height: 40px;
  }
  .scrollbar {
    height: calc(100vh - @logoHeight) !important;
    overflow-y: auto;
    &::-webkit-scrollbar {
      width: 0;
    }
  }
</style>

说到这里就不得不说一个小小的知识点了,就是关于路由缓存的功能,首先我们拿到的数据都是多级树形的数据,但是为了方便缓存功能,我们要把多级树形数据转成两级树形数据,因为两级的树形可以很好的实现缓存功能

再来看一下最后一个文件内容

src/layouts/sidebar/components/SubMenu.vue内容如下:

代码语言:javascript复制
<template>
  <a-sub-menu :key="menuInfo.key">
    <template #title>
      <span class="sub-menu-lable">{{ menuInfo.label }}</span>
    </template>
    <template #icon>
      <component
        :is="menuInfo.icon || 'icon-menu'"
        style="font-size: 16px; vertical-align: middle"
      />
    </template>
    <template v-for="item in menuInfo.children" :key="item.key">
      <template v-if="!item.children">
        <a-menu-item :key="item.key">
          <template #icon>
            <component :is="item.icon || 'icon-menu'" />
          </template>
          {{ item.label }}
        </a-menu-item>
      </template>
      <template v-else>
        <SubMenu :menu-info="item" :key="item.key" />
      </template>
    </template>
  </a-sub-menu>
</template>

<script lang="ts">
  import { defineComponent } from 'vue'

  export default defineComponent({
    name: 'SubMenu',
    props: {
      menuInfo: {
        type: Object,
        default: () => ({}),
      },
    },
  })
</script>
<style scoped>
  .sub-menu-lable {
    flex: 1;
    margin-left: 3px;
  }
</style>

05

总结

以上便是实现整个菜单权限的整个流程,还有很多的细节没有写出来,下一篇我们再来重点介绍关于菜单和路由的其它细节知识,大家敬请期待。

0 人点赞