后台管理系统之layout架构解决方案

1,105 阅读4分钟

那么什么叫做 Layout 架构呢?我们来看这张图:

image.png

在这张图中,把页面分为了三个部分,分别是:

  1. 左侧的 Menu 菜单
  2. 顶部的 NavBar
  3. 中间的内容区 Main

看起来非常的简单,但是实际上有很多核心功能要完成:

  1. 用户退出方案
  2. 动态侧边栏方案
  3. 动态面包屑方案

除了这些核心内容之外,还有一些其他的小功能,比如:

  1. 退出的通用逻辑封装
  2. 伸缩侧边栏动画
  3. vue3 动画
  4. 组件状态驱动的动态 CSS 值等等

创建Layout 的基础架构

  1. 整个页面分为三部分,首先创建对应的三个组件:layout/components/Sidebar/index.vue layout/components/Navbar.vue layout/components/AppMain.vue

image.png

  1. 完成对应的布局结构
<template>
 <div class="app-wrapper">
   <!-- 左侧 menu -->
   <sidebar/>
   <div class="main-container">
     <div class="fixed-header">
       <!-- 顶部的 navbar -->
       <navbar />
     </div>
     <!-- 内容区 -->
     <app-main />
   </div>
 </div>
</template>
  1. styles 中创建如下 css 文件:

    1. variables.scss : 定义常量
    2. mixin.scss :定义通用的 css
    3. sidebar.scss:处理 menu 菜单的样式
  2. variables.scss ,定义如下常量并进行导出( :export 可见 scss 与 js 共享变量):

// sidebar
$menuText: #bfcbd9;
$menuActiveText: #ffffff;
...
$subMenuHover: #001528;
$sideBarWidth: 210px;
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
     menuText: $menuText;
     ...
     sideBarWidth: $sideBarWidth;
}
  1. index.scss 中按照顺序导入以上样式文件
@import './variables.scss';
@import './mixin.scss';
@import './sidebar.scss';
  1. layout/index.vue 中写入如下样式

注意:css变量的写法:calc(100% - #{$sideBarWidth});

<style lang="scss" scoped>
@import '~@/styles/mixin.scss';
@import '~@/styles/variables.scss';
.app-wrapper {
     @include clearfix;
     ...
}
.fixed-header {
     ...
     width: calc(100% - #{$sideBarWidth});
}
</style>

获取并展示用户信息

请求拦截器

因为获取用户信息需要对应的 token ,所以我们可以利用 axios请求拦截器token 进行统一注入,在 utils/request.js 中写入如下代码:

import store from '@/store'
// 请求拦截器
service.interceptors.request.use(
  config => {
    // 在这个位置需要统一的去注入token
    if (store.getters.token) {
      // 如果token存在 注入token
      config.headers.Authorization = `Bearer ${store.getters.token}`
    }
    return config // 必须返回配置
  },
  error => {
    return Promise.reject(error)
  }
)

在路由跳转过程中的路由拦截阶段触发动作:

permission.js 中写入以下代码:

if (to.path === '/login') {
  ...
} else {
  // 判断用户资料是否获取
  // 若不存在用户信息,则需要获取用户信息
  if (!store.getters.hasUserInfo) {
    // 触发获取用户信息的 action
    await store.dispatch('user/getUserInfo')
  }
  next()
}

现在已经拿到了 用户数据,并且在 getters 中做了对应的快捷访问 ,那么接下来我们就可以根据数据渲染出 用户头像内容

动态menu菜单处理

动态menu菜单处理方案解析

动态menu菜单 其实主要是和 动态路由表 配合来去实现 用户权限 的。用户权限处理后面会专门介绍, 在这里只处理 动态menu菜单

所谓 动态menu菜单 指的是:根据路由表的配置,自动生成对应的 menu 菜单;当路由表发生变化时,menu 菜单自动发生变化。

实现方案:

  1. 定义 路由表 对应 menu 菜单规则
  2. 根据规则制定 路由表
  3. 根据规则,依据 路由表 ,生成 menu 菜单

根据实现方案可以发现,实现 动态menu菜单 最核心的关键点其实就在步骤一,也就是

定义 路由表 对应 menu 菜单规则

规则是可以根据项目情况而定,这里采用的规则如下:

  1. 如果meta && meta.title && meta.icon :则显示在 menu 菜单中,其中 title 为显示的内容,icon 为显示的图标
  2. 如果存在 children :则以 el-sub-menu(子菜单) 展示
  3. 否则:则以 el-menu-item(菜单项) 展示
  4. 否则:不显示在 menu 菜单中

动态menu菜单处理方案实现

明确了对应的方案之后,下面来实现对应的代码逻辑。

根据我们的分析,想要完成动态的 menu,那么我们需要按照以下的步骤来去实现:

  1. 创建页面组件
  2. 生成路由表
  3. 解析路由表
  4. 生成 menu 菜单

创建结构路由表

image.png

这是我们最终要实现的 menu 截图,可以知道两点内容:

  1. 创建的页面并没有全部进行展示

根据该规则,不满足meta && meta.title && meta.icon的条件,将不会展示,比如某些详情页或者错误页

  1. menu 菜单将具备父子级的结构
[
  {
      "title": "个人中心",
      "path": ""
  },
  {
      "title": "用户",
      "children": [
          {
              "title": "员工管理",
              "path": ""
          },
          {
              "title": "角色列表",
              "path": ""
          },
          {
              "title": "权限列表",
              "path": ""
          }
      ]
  },
  {
      "title": "文章",
      "children": [
          {
              "title": "文章排名",
              "path": ""
          },
          {
              "title": "创建文章",
              "path": ""
          }
      ]
  }
]

因为将来进行 用户权限处理,所以此时我们需要先对路由表进行一个划分:1. 私有路由表 privateRoutes :权限路由;2. 公有路由表 publicRoutes:无权限路由

根据以上理论,生成以下路由表结构:

// 私有路由表
const privateRoutes = [
  {
    path: '/user',
    component: layout,
    redirect: '/user/manage',
    meta: {
      title: 'user',
      icon: 'personnel'
    },
    children: [
      {
        path: '/user/manage',
        component: () => import('@/views/user-manage/index'),
        meta: {
          title: 'userManage',
          icon: 'personnel-manage'
        }
      },
      {
        path: '/user/role',
        component: () => import('@/views/role-list/index'),
        meta: {
          title: 'roleList',
          icon: 'role'
        }
      },
      {
        path: '/user/permission',
        component: () => import('@/views/permission-list/index'),
        meta: {
          title: 'permissionList',
          icon: 'permission'
        }
      },
      {
        path: '/user/info/:id',
        name: 'userInfo',
        component: () => import('@/views/user-info/index'),
        meta: {
          title: 'userInfo'
        }
      },
      {
        path: '/user/import',
        name: 'import',
        component: () => import('@/views/import/index'),
        meta: {
          title: 'excelImport'
        }
      }
    ]
  },
  ...
]

// 公开路由表
const publicRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login/index')
  },
  {
    path: '/',
    // 注意:带有路径"/"的记录中的组件"默认"是一个不返回 Promise 的函数
    component: layout,
    redirect: '/profile',
    children: [
      {
        path: '/profile',
        name: 'profile',
        component: () => import('@/views/profile/index'),
        meta: {
          title: 'profile',
          icon: 'el-icon-user'
        }
      },
      {
        path: '/404',
        name: '404',
        component: () => import('@/views/error-page/404')
      },
      {
        path: '/401',
        name: '401',
        component: () => import('@/views/error-page/401')
      }
    ]
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes: [...publicRoutes, ...privateRoutes]
})

layout/appMain 下设置路由出口

<template>
  <div class="app-main">
    <router-view></router-view>
  </div>
</template>

解析路由表,获取结构化数据

想要获取路由表数据,那么有两种方式:

  1. router.options.routes:初始路由列表(新增的路由 无法获取到)
  2. router.getRoutes():获取所有 路由记录 的完整列表

所以,我们此时使用 router.getRoutes()

layout/components/Sidebar/SidebarMenu 下写入以下代码:

<script setup>
import { useRouter } from 'vue-router'

const router = useRouter()
console.log(router.getRoutes())
</script>

得到返回的数据:

[
    {
        "path":"/user/info/:id",
        "name":"userInfo",
        "meta":{
            "title":"userInfo"
        },
        "children":[
        ]
    },
    {
        "path":"/article/editor/:id",
        "meta":{
            "title":"articleEditor"
        },
        "children":[

        ]
    }
]

从返回的数据来看,它与我们想要的符合menu菜单数据结构相去甚远。出现这个问题的原因,是因为它返回的是一个 完整的路由表

这个路由表距离我们想要的存在两个问题:

  1. 存在重复的路由数据
  2. 不满足该条件 meta && meta.title && meta.icon 的数据不应该存在

创建两个方法分别处理对应的两个问题: filterRoutersgenerateMenus

SidebarMenu 中调用该方法

<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { filterRouters, generateMenus } from '@/utils/route'

const router = useRouter()
const routes = computed(() => {
  const filterRoutes = filterRouters(router.getRoutes())
  return generateMenus(filterRoutes)
})
console.log(JSON.stringify(routes.value))
</script>

生成动态 menu 菜单

整个 menu 菜单,我们将分成三个组件来进行处理

  1. SidebarMenu:处理数据,作为最顶层 menu 载体
  2. SidebarItem:根据数据处理 当前项为 el-submenu || el-menu-item
  3. MenuItem:处理 el-menu-item 样式

首先是 SidebarMenu

<template>
  <!-- 一级 menu 菜单 -->
  <el-menu
    ...
  >
    <sidebar-item
      v-for="item in routes"
      :key="item.path"
      :route="item"
    ></sidebar-item>
  </el-menu>
</template>

创建 SidebarItem 组件,用来根据数据处理 当前项为 el-submenu || el-menu-item ,这里面存一个循环组件渲染的知识点。

<template>
  <!-- 支持渲染多级 menu 菜单 -->
  <el-submenu v-if="route.children.length > 0" :index="route.path">
    <template #title>
      <menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
    </template>
    <!-- 循环渲染 -->
    <sidebar-item
      v-for="item in route.children"
      :key="item.path"
      :route="item"
    ></sidebar-item>
  </el-submenu>
  <!-- 渲染 item 项 -->
  <el-menu-item v-else :index="route.path">
    <menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
  </el-menu-item>
</template>

创建 MenuItem 用来处理 el-menu-item 样式

<template>
  <i v-if="icon.includes('el-icon')" class="sub-el-icon" :class="icon"></i>
  <svg-icon v-else :icon="icon"></svg-icon>
  <span>{{ title }}</span>
</template>

至此,整个的 menu 菜单结构就已经完成了。但是还存在一个样式问题,因为后面我们需要处理 主题替换 ,所以此处我们不能把样式写死

store/getters 中创建一个新的 快捷访问

import variables from '@/styles/variables.scss'
const getters = {
  ...
  cssVar: state => variables
}
export default getters

SidebarMenu 中写入如下样式

<el-menu
    :background-color="$store.getters.cssVar.menuBg"
    :text-color="$store.getters.cssVar.menuText"
    :active-text-color="$store.getters.cssVar.menuActiveText"
  >

动画逻辑,左侧菜单伸缩功能实现

左侧菜单伸缩 这个功能核心的点在于动画处理,样式的改变总是由数据进行驱动,所以首先我们去创建对应的数据

创建 store/app 模块,写入如下代码

export default {
  namespaced: true,
  state: () => ({
    sidebarOpened: true
  }),
  mutations: {
    triggerSidebarOpened(state) {
      state.sidebarOpened = !state.sidebarOpened
    }
  },
  actions: {}
}

SidebarMenu 中,控制 el-menucollapse 属性

<el-menu
    :collapse="!$store.getters.sidebarOpened"
    ...

layout/index 中指定 整个侧边栏的宽度和缩放动画

<div
    class="app-wrapper"
    :class="[$store.getters.sidebarOpened ? 'openSidebar' : 'hideSidebar']"
  >
  ...

layout/index 中 处理 navbar 的宽度

<style lang="scss" scoped>
.fixed-header {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 9;
  width: calc(100% - #{$sideBarWidth});
  transition: width 0.28s;
}

.hideSidebar .fixed-header {
  width: calc(100% - #{$hideSideBarWidth});
}
</style>

styles/variables.scss 中指定 hideSideBarWidth

$hideSideBarWidth: 54px;

组件状态驱动的动态 CSS 值

vue 3.2 最新更新中,除了之前我们介绍的 响应式变化 之外,还有另外一个很重要的更新,那就是 组件状态驱动的动态 CSS ,对应的文档也已经公布,大家可以 点击这里 查看

那么下面利用最新的特性,来为 logo-container 指定下高度:

<template>
 <el-avatar :size="logoHeight"
</template>

<script setup>
...
const logoHeight = 44
</script>

<style lang="scss" scoped>
.logo-container {
  height: v-bind(logoHeight) + 'px';
...
}
</style>

动态面包屑方案分析

动态面包屑:根据当前的 url 自动生成面包屑导航菜单

image.png

动态获取路由数据

无论之后路径发生了什么变化,动态面包屑 都会正确的进行计算,所以我们的关键是如何获取面包屑结构数据。

要动态的获取路由信息,需要使用route.match 属性来:**获取与给定路由地址匹配的[标准化的路由记录]。

const route = useRoute()
// 生成数组数据
const breadcrumbData = ref([])
const getBreadcrumbData = () => {
  breadcrumbData.value = route.matched.filter(
    item => item.meta && item.meta.title
  )
}
// 监听路由变化时触发
watch(
  route,
  () => {
    getBreadcrumbData()
  },
  {
    immediate: true
  }
)
</script>

面包屑的动画处理

vue3对 动画 进行了一些修改(vue 动画迁移文档

主要的修改其实只有两个:

  1. 过渡类名 v-enter 修改为 v-enter-from
  2. 过渡类名 v-leave 修改为 v-leave-from

那么依据修改之后的动画,我们来为面包屑增加一些动画样式:

  1. Breadcrumb/index 中增加 transition-group
<template>
 <el-breadcrumb class="breadcrumb" separator="/">
   <transition-group name="breadcrumb">
     ...
   </transition-group>
 </el-breadcrumb>
</template>
  1. 新建 styles/transition 样式文件
.breadcrumb-enter-active,
.breadcrumb-leave-active {
  transition: all 0.5s;
}

.breadcrumb-enter-from {
  opacity: 0;
  transform: translateX(20px);
}

.breadcrumb-leave-to {
  opacity: 0;
  transform: translateX(-20px);
}
// 在动画过程中,会有重叠效果的影响,所以加上一个层级关系
.breadcrumb-leave-active {
  position: absolute;
  z-index: -1;
}

总结

本文围绕着layout 为核心,主要实现了三个大的业务方案:用户退出方案、动态侧边栏方案、动态面包屑方案。

除了这三块大的方案之后,还有一些小的功能,比如:

  1. 登出的通用逻辑封装
  2. 伸缩侧边栏动画
  3. vue3 动画
  4. 组件状态驱动的动态 CSS 值等等