从0到一个前端后台管理框架(三)

文章列表:

实现方案:

方案一、使用VueRouter的动态路由,遍历路由信息动态构建菜单。

优势:熟悉Vue路由的情况下,这种方式可能构建起来会省部分代码。
劣势:路由信息不可见,对于刚接手的开发者没有后端服务的情况下,无法摸索精确找url对应的页面,构建多级菜单时需要增加一个空页面作为子路由的载体,页面结构变复杂。
根据后端构建动态路由有点难度,如构建 children: [{ path: '', component: () => import('pages/IndexPage.vue') }]类似这样的代码。

方案二、单独构建菜单树,使用单独的一个菜单树结构来构建菜单。

单独构建菜单树,使用单独的一个菜单树结构来构建菜单,使用url或者name来匹配对应的路由信息。
优势:原始路由清晰,易于调试,多级菜单不需要空页面作为路由载体,等等
劣势:维护菜单树和路由树的对应关系时代码可能有点复杂,

这里笔者是用的第二种方式。

思路分析

第一,我们需要构建一个菜单树,怎么构建树呢,我们需要子节点包含父节点信息的平级结构,然后把它转成树结构,只有子节点包含父节点的信息我们才能构建树结构,

{
    id: 'main2', // 节点的Id信息
    pid: '0', // 父节点Id信息,为0表示为根节点
    name: '主页2', // 菜单名称
    path: '/index', // 菜单url,对应路由信息
    icon: 'home', // 菜单图标
    auth: '23', // 权限标识,暂定,鉴权也可以使用url作为鉴权标识。
  },
  {
    id: '1',
    pid: '0',
    name: '系统管理',
    path: '',
    icon: 'settings_applications',
    auth: '23',
  },
  {
    id: '1-1',
    pid: '1',
    name: '菜单管理',
    path: '/sys-menu',
    icon: 'menu',
    auth: '23',
  },

上面这样的平级数据组成树结构应该是这样的

  • 主页
  • 系统管理
    • 菜单管理

了解菜单数据的组成过后,开始编写处理菜单树的代码

stores文件夹下新建layout-store.ts文件,编写如下代码,需要熟悉pinia

import { defineStore } from 'pinia';

export const useLayoutStore = defineStore('layout', {
  state: () => ({
  }), // 存储的数据
  getters: {
  }, // 计算属性
  actions: {
  }, // 一些操作
});

定义菜单类型,在src下新建types目录,之后新建base.d.ts文件,编写如下代码


declare namespace WJ {
  /* 菜单项 */
  interface MenuItem {
     // 菜单ID
    id: string;
    // 菜单父级Id根节点使用"0"
    pid: string;
    // 菜单名称
    name: string;
    // 菜单路径
    path: string;
    // 菜单图标
    icon: string;
    // 菜单权限编码
    auth: string;
    // 排序,数字越大越靠后
    sort: number;
    // 子级别菜单
    children?: Array<WJ.MenuItem>;
  }
}

在router文件夹下新建my-router.ts文件,编写如下内容,此文件内容就是一会需要展示的菜单原始数据。

export default [
  {
    id: '1',
    pid: '0',
    name: '系统管理',
    path: '',
    icon: 'settings_applications',
    sort: 0,
  },
  {
    id: '1-1',
    pid: '1',
    name: '菜单管理',
    path: '/sys-menu',
    icon: 'menu',
    sort: -1,
  },
  {
    id: '1-2',
    pid: '1',
    name: '用户管理',
    path: '/sys-user',
    icon: 'person',
    sort: -2,
  },
]

修改layout-store.ts文件,编写平级菜单结构转树结构的方法

编写过后完整代码如下,

import { store } from 'quasar/wrappers';
import { defineStore } from 'pinia';

// 类型定义
interface LayoutType {
  // 平级菜单数据
  menuList: Array<WJ.MenuItem>
}
export const useLayoutStore = defineStore('layout', {
  state: (): LayoutType => ({
    menuList: []
  }),
  getters: {
    // ID:WJ.MenuItem,Map 结构,Key为菜单ID
    menuIdMap: (state): Map<string, WJ.MenuItem> => {
      const map = new Map<string, WJ.MenuItem>();
      state.menuList.forEach((item) => {
        map.set(item.id, item);
      });
      return map
    },
    // 树形菜单数据
    menuTree(state): Array<WJ.MenuItem> {
      // 将子元素依次放入父元素中
      const res: Array<WJ.MenuItem> = [];
      state.menuList.forEach((item) => {
        const parent = this.menuIdMap.get(item.pid);
        if (parent) {
          const pc = (parent.children || (parent.children = []));
          pc.indexOf(item) === -1 ? pc.push(item) : ''
          pc.sort((a, b) => a.sort - b.sort)
        } else {
          res.push(item)
        }
      });
      res.sort((a, b) => a.sort - b.sort)
      return res;
    }
  },
  actions: {

  },
});

这里菜单数据的代码就写好了,接下来编写页面展示。

layouts文件夹下新建目录comp,在comp目录新建文件LeftDrawer.vue作为左边侧边栏内容组件,

LeftDrawer.vue组件代码如下

<template>
  <div>
    <h3>这是侧边菜单</h3>
  </div>
</template>

<script setup lang="ts"></script>

<style scoped></style>

MainLayout.vue文件中引入LeftDrawer.vue组件,并替换q-drawer的标签中的q-list标签,然后精简代码,编辑过后MainLayout.vue代码如下所示

<template>
  <q-layout view="lHh Lpr lFf">
    <q-header elevated>
      <q-toolbar>
        <q-btn
          flat
          dense
          round
          icon="menu"
          aria-label="Menu"
          @click="toggleLeftDrawer"
        />

        <q-toolbar-title> Quasar App </q-toolbar-title>

        <div>Quasar v{{ $q.version }}</div>
      </q-toolbar>
    </q-header>

    <q-drawer v-model="leftDrawerOpen" show-if-above bordered>
      <LeftDrawer></LeftDrawer>
    </q-drawer>

    <q-page-container>
      <router-view />
    </q-page-container>
  </q-layout>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import LeftDrawer from './comp/LeftDrawer.vue';

const leftDrawerOpen = ref(false);

function toggleLeftDrawer() {
  leftDrawerOpen.value = !leftDrawerOpen.value;
}
</script>

运行项目,如下图所示

示意图

说明组件引入成功,然后在comp文件夹下新建MenuItem.vue文件,编写如下内容代码,此代码有点复杂,用到了q-list,q-expansion-item组件,如果不熟悉组件使用方法,可查看官方文档学习使用

MenuItem.vue代码如下,我尽可能的多写注释,便于理解,如果没看懂,可以把代码拉到本地自己琢磨琢磨。

<template>
  <template v-for="item in menuTree" :key="item.id">
    <q-item v-if="!item.children" @click="handleClick(item)" clickable>
      <q-item-section avatar>
        <q-icon :name="item.icon" />
      </q-item-section>
      <q-item-section>
        <q-item-label>{{ item.name }}</q-item-label>
      </q-item-section>
    </q-item>
    <q-expansion-item
      :icon="item.icon"
      :label="item.name"
      :content-inset-level="0.2"
      v-else
    >
      <!-- 如果包含子集。则递归调用此组件 -->
      <MenuItem :menu-tree="item.children"></MenuItem>
    </q-expansion-item>
  </template>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router';

interface Props {
  // 菜单树
  menuTree: Array<WJ.MenuItem>;
}
withDefaults(defineProps<Props>(), {});
const router = useRouter();

/* 处理点击事件 */
const handleClick = (item: WJ.MenuItem) => {
  router.push(item.path);
};
</script>

<style scoped></style>

编写完成过后,修改LeftDrawer.vue文件,要在此文件中引入MenuItem.vue组件,代码如下

<template>
  <q-scroll-area class="fit">
    <q-list bordered>
      <!-- 主页  -->
      <q-item
        clickable
        :active="route.fullPath === layout.mainPage.path"
        @click="router.push({ path: layout.mainPage.path })"
      >
        <q-item-section avatar>
          <q-icon :name="layout.mainPage.icon" />
        </q-item-section>
        <q-item-section>
          <q-item-label>{{ layout.mainPage.name }}</q-item-label>
        </q-item-section>
      </q-item>
      <!-- 菜单组件 -->
      <menu-item :menu-tree="layout.menuTree"></menu-item>
    </q-list>
  </q-scroll-area>
</template>

<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import { useLayoutStore } from 'src/stores/layout-store';
import { useRoute, useRouter } from 'vue-router';
import myRoute from 'src/router/my-router';
const MenuItem = defineAsyncComponent(() => import('./MenuItem.vue'));
const route = useRoute();
const router = useRouter();
const layout = useLayoutStore();
// 注意这里,赋值菜单数据
layout.menuList = myRoute as Array<WJ.MenuItem>;
</script>

<style scoped></style>

这个时候不出意外的话,LeftDrawer.vue内代码会有报错,提示没有mainPage属性,我们给它加上即可,修改layout-store.ts内代码,在LayoutType中加入代码 mainPage: WJ.MenuItem,加入过后,如下

interface LayoutType {
  // 平级菜单数据
  menuList: Array<WJ.MenuItem>
  mainPage: WJ.MenuItem
}

加入过后,代码提示报错,这时候我们再编写代码在state中添加mainPage属性,修改过后完整代码如下

import { defineStore } from 'pinia';


interface LayoutType {
  // 平级菜单数据
  menuList: Array<WJ.MenuItem>
  // 主页
  mainPage: WJ.MenuItem
}
export const useLayoutStore = defineStore('layout', {
  state: (): LayoutType => ({
    menuList: [],
    // 主要看这里
    mainPage: {
      id: '0',
      pid: '0',
      name: '首页',
      sort: 0,
      icon: 'home',
      auth: '',
      path: '/'
    }
  }),
  getters: {
    // ID:WJ.MenuItem
    menuIdMap: (state): Map<string, WJ.MenuItem> => {
      const map = new Map<string, WJ.MenuItem>();
      state.menuList.forEach((item) => {
        map.set(item.id, item);
      });
      return map
    },
    // 树形菜单数据
    menuTree(state): Array<WJ.MenuItem> {
      // 将子元素依次放入父元素中
      const res: Array<WJ.MenuItem> = [];
      state.menuList.forEach((item) => {
        const parent = this.menuIdMap.get(item.pid);
        if (parent) {
          const pc = (parent.children || (parent.children = []));
          pc.indexOf(item) === -1 ? pc.push(item) : ''
          pc.sort((a, b) => a.sort - b.sort)
        } else {
          res.push(item)
        }
      });
      res.sort((a, b) => a.sort - b.sort)
      return res;
    }
  },
  actions: {

  },
});

重启项目,可以看到,我们编写的动态菜单已经加载出来了,
动态菜单

至此我们的动态菜单的展示已经编写完成,不过点击你会发现会跳转到404页面,因为我们并没有编写对应的路由配置,接下来我们将优化这个动态菜单功能,如,菜单高亮,支持外部链接,支持内部打开外部链接等等……..

动态菜单到此编写完成。

此篇博客进度对代码分支——dynamic-menu

下一篇教程地址

从0到一个前端后台管理框架(四)

封面


从0到一个前端后台管理框架(三)
https://wangijun.com/2023/06/04/vue-10/
作者
无良芳
发布于
2023年6月4日
许可协议