vue-automation-admin 开发回顾

先简单介绍一下,vue-automation-admin 是一款开箱即用的 Vue 中后台管理系统模版。但是后台框架市面上已经有不少,为什么我还要重新造轮子呢?这就要从 vue-element-admin 说起了。

起因

我司其实一开始是使用 vue-element-admin 的,并且也做了很多定置化的修改,例如针对权限模块就做了定制,可参考之前整理的文章《重构 vue-element-admin 权限模块》。

但也有不好实现的功能,例如大部分后台都是左右两栏布局(左侧为侧边栏导航,右侧为详情内容),但在实际项目开发中,由于模块较多,会有顶部导航的需求,也就是将原先侧边栏导航里的模块进行归类,点击不同类目的头部导航,侧边栏导航的内容也会更新。这个需求我虽然在练习的时候实现过(《利用嵌套路由动态生成后台导航》),但尝试在 vue-element-admin 里实现时,还是遇到了很多头疼的问题,最终也不了了之。可能是当时对 vue-element-admin 的源码理解不够深吧。

总之经历了这些后,终于决定开发一款后台管理框架,目标就两点:

  1. 足够轻量级
  2. 尽量配置化

轻量级是学习了 vue-admin-template ,做为一个轻量级后台管理系统,只包含了必要功能。

而我在开发的时候更加极致,甚至把侧边栏导航收起和响应式布局的功能都去掉了,因为发现这些功能几乎没有人使用,例如响应式布局就是个伪需求,从内部使用反馈来看,几乎没有人会考虑用其它终端使用后台,所以不管是手机还是平板,其实根本没必要做兼容,而且即便框架做了响应式处理,项目中使用到一些第三方组件也未必支持移动端。

而配置化则是其它项目开发的经验总结,当一个东西要产品化的时候,就得考虑到很多使用场景,然后通过配置的形式做到快速切换,举个简单的例子, vue-admin-template 虽然提供了轻量级的后台,但它是不包含权限功能的,如果项目需要权限,则要拉取 permission-control 分支下的代码才行,这就导致代码有两份,一份是不带权限,一份是带权限的。如果项目开发原先是不需要权限的,后期需求调整,要增加权限,就很头疼。

依据这两点目标,开发工作也就开始了。

技术框架选型

因为 vue-automation 在我司项目中大范围的使用,整体也较为成熟,所以新的这套后台框架也就自然而然地使用 vue-automation 做为基础,这样也能继承一些不错的特性。

至于 UI 框架,其实没怎么做选择,更早之前就已经比较过两个相对比较成熟的 UI 框架,一个是 ViewUI ,一个是 ElementUI ,考虑到开发人员对 ElementUI 比较熟悉,所以这次也就没更换。

确定下来后,就是动手写代码了。

因为这篇文章是开发回顾,所以接下来你可能会看到针对某个功能点,我反复重构,或者修改代码的情况,这其实也是我对功能点实现方式上的思考。

布局

首先能确定的是,我要兼容两种布局,一种是带头部导航的,一种是不带头部导航的:

带头部导航

不带头部导航

既然要兼容两种布局,那就需要配置文件了,我在 src/ 目录下新建了一个 setting.js 文件,专门用来存放项目的配置项,就像这样:

1
2
3
4
export default {
// 是否显示头部
showHeader: false
}

然后在 vuex 里引入该文件,这样就可以在全局获取到配置文件的信息了,可参考 src/store/modules/global.js 文件。

接着到 layout/ 目录下新建好页面,就可以编写我们布局页面的代码了,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div>
<header v-if="$store.state.global.showHeader">
<!-- 头部 -->
</header>
<div class="wrapper">
<div class="sidebar-container">
<!-- 侧边栏 -->
</div>
<div class="main-container">
<!-- 主内容 -->
</div>
</div>
</div>
</template>

布局上用 flex 进行布局,省心了不少,另外因为 v-if 的特性,不满足条件时,也就是当设置为不显示头部时,整个 header 标签都不会创建,搭配 css 的 + 选择器,轻松的实现了一份样式兼容两套布局。当然通过给 .wrapper 增加 class 也可以实现,只不过现在的方式会看着更加巧妙吧,哈哈~

考虑到头部的高度和侧边栏的宽度可能需要根据设计稿进行调整,我新建了一个 scss 文件专门用来存放这些变量,参考 src/assets/styles/resources/variables.scss ,为什么不把这些配置也放在 setting.js 文件里,是因为本身这些变量也是和 css 有关,如果存放在 js 里,那就只能将数据通过标签的 style 属性,用 css变量 的形式注入进去,类似下面这样:

1
2
3
4
5
6
export default {
// 是否显示头部
showHeader: false,
// 侧边栏宽度
sidebarWidth: '220px'
}
1
2
3
<div class="sidebar-container" :style="`--g-sidebar-width: ${$store.state.global.sidebarWidth};`">
<!-- 侧边栏 -->
</div>

说实话,有点麻烦,就没有考虑。

现在两套布局基本可以自由切换了,然后我发现其实还有一种情况没考虑到,就是有的后台可能会要求展示宽度固定,然后居中显示,就像这样:

固定宽度带头部导航

固定宽度不带头部导航

这个实现不复杂,把宽度从 100% 改成固定值,然后加个 margin: 0 auto; 就可以搞定。

秉承配置化的理念,我将这块也放到 variables.scss 文件里,同时还增加了几个配置项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 应用宽度,可设置自适应宽度或固定宽度:
// 自适应(默认):宽度100%(自适应只能设置100%)
// 固定宽度:居中显示
$g-app-width: 100%;
// 应用最小宽度(当应用宽度为自适应宽度时生效),可设置自适应宽度或固定宽度:
// 自适应(默认):应用最小宽度100%(自适应只能设置100%)
// 固定宽度:小于固定宽度时,出现横向滚动条
$g-app-min-width: 100%;
// 应用最大宽度(当应用宽度为固定宽度时生效),可设置 true / false:
// true:自适应
// false(默认):小于应用宽度时,出现横向滚动条
$g-app-max-width: false;

#app-main {
width: $g-app-width;
height: 100%;
margin: 0 auto;
@if ($g-app-width == 100%) {
@if ($g-app-min-width != 100%) {
min-width: $g-app-min-width;
}
}
@else {
@if ($g-app-max-width) {
max-width: 100%;
}
}
}

怎么理解呢?就是当自适应布局时,可以设置最小宽度,当页面实际宽度小于最小宽度时,页面将出现横向滚动条;而当固定宽度布局时,可开启最大宽度设置,这样当页面实际宽度小于设置宽度,会变为自适应。

主题

variables.scss 文件里还放了一些配色的变量,通过修改可以方便对界面配色进行调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 应用背景色
$g-app-bg: #fff;
// 主区域背景色
$g-main-bg: #f5f7f9;
// 头部背景色
$g-header-bg: #736477;
// 头部导航选中颜色
$g-header-nav-active-bg: $g-header-bg + 20;
// 侧边栏背景色
$g-sidebar-bg: #ddcdcd;
// 侧边栏Logo区域背景色
$g-sidebar-title-bg: $g-header-bg;
// 侧边栏菜单文字颜色
$g-sidebar-menu-color: #89768f;
// 侧边栏菜单文字选中颜色
$g-sidebar-menu-active-color: $g-sidebar-menu-color - 50;
// 侧边栏菜单选中背景色
$g-sidebar-menu-active-bg: #b5a5a5;

// 输出给js使用
:export {
g_app_bg: $g-app-bg;
g_main_bg: $g-main-bg;
g_header_bg: $g-header-bg;
g_header_nav_active_bg: $g-header-nav-active-bg;
g_sidebar_bg: $g-sidebar-bg;
g_sidebar_title_bg: $g-sidebar-title-bg;
g_sidebar_menu_color: $g-sidebar-menu-color;
g_sidebar_menu_active_color: $g-sidebar-menu-active-color;
g_sidebar_menu_active_bg: $g-sidebar-menu-active-bg;
}

这里用到了 CSS Module 的 :export 特性 ,可以导出一个 js 对象,相当于 ES6 的 export

那为什么要将 scss 里的变量输出给 js 使用呢?这里主要是因为使用了 ElementUI 里的 NavMenu 组件,它是通过属性设置的方式去改变组件的颜色,输出给 js 能更方便设置,类似这样:

1
2
3
4
5
6
<el-menu
:background-color="variables.g_sidebar_bg"
:text-color="variables.g_sidebar_menu_color"
:active-text-color="variables.g_sidebar_menu_active_color"
>
</el-menu>
1
2
3
4
5
6
7
8
import variables from '@/assets/styles/resources/variables.scss'
export default {
computed: {
variables() {
return variables
}
}
}

侧边栏导航

侧边栏导航是后台管理系统的核心,它是根据路由自动生成的,这样就无需开发者单独去配置,只要按照规则配置好路由,侧边栏导航也就生成好了。

既然是根据路由生成的,那就看下路由文件吧,我把路由分成了三块,从 src/router/index.js 文件里可以看到分别是:

1
2
3
const constantRoutes = []
let asyncRoutes = []
const lastRoute = []

其中 asyncRoutes 里的路由就是用来生成侧边栏导航用的,我会将数据存放到 vuex 里。当然后面也会讲到, asyncRoutes 单独分出来也是方便做鉴权。

因为路由是有层级的,对应的侧边栏导航也是有层级结构的,所以参考了下 NavMenu 组件的结构,我在 layout/ 目录下新建了一个 sidebarItem.vue 文件,其实是 SidebarItem 组件,它做的事情就是,当路由如果有子集路由的时候,则调用自身,如果没有则停止,有点像是一个递归函数。

这里要考虑到一点,有的路由其实是不需要在侧边栏导航里展示的,例如编辑页,这种需要在 url 上带上动态数值的,都不需要在侧边栏导航里展示,那要怎么实现呢?我的做法是在路由的 meta 对象里增加自定义的配置项,就像这样:

1
2
3
4
5
6
7
8
{
path: 'page',
component: () => import('@/views/page'),
meta: {
title: '标题',
sidebar: false
}
}

这样在递归创建侧边栏导航的时候,只要加个判断就可以。当然路由还有其它配置项,这里就不一一介绍具体实现了,大家可以看代码理解,主要说下几个难点。

第一个难点是,如何点击导航跳转页面。首先 NavMenu 组件是有提供 router 参数,开启后会将 index 参数做为 path 进行路由跳转,但因为路由是嵌套的,要拿到完整的 path ,必须拼上每一个父级的 path 才行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
path: '/page',
component: () => import('@/views/page'),
children: [
{
path: 'page1',
component: () => import('@/views/page1'),
children: [
{
path: 'page2',
component: () => import('@/views/page2')
}
]
}
]
}

例如上面的例子,我要访问 page2 页面,直接拿到 page2 是不行的,必须拿到 /page/page1/page2 这样的地址才行。

那要怎么拿到完整的地址呢?其实很简单,因为侧边栏导航是递归实现的,所以当有子集路由的时候,只要把当前的 path 做为一个 prop 传入到子集,我定义的参数名是 base-path ,子集里的路由拿到 base-path 再和子集自己的 path 合并一下,就是完整的路由了,如果还有子集路由,则把拼好的 path 当作 base-path 再传入子集即可,有点绕哈,多读几遍就能看懂了。

上面的办法已经能解决问题了,但因为我还有个需求,就是希望可以在路由 path 属性上直接设置 http 外链地址,或者其它外链形式的地址,这样点击侧边栏导航的时候,不光可以进行路由跳转,也可以打开外链地址。

我的解决办法的是在 <el-menu-item></el-menu-item> 外面增加一个 <component></component> 动态组件,然后关闭 NavMenu 组件自带路由跳转功能,通过动态组件来判断到底用什么组件展示。通过正则判断 path 如果是 http 开头的,则用 a 标签,否则就用 router-link ,就像这样:

1
<component v-bind="linkProps(path)"></component>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default {
methods: {
isExternal(path) {
return /^(https?:)/.test(path)
},
linkProps(path) {
if (this.isExternal(path)) {
return {
is: 'a',
href: path,
target: '_blank',
rel: 'noopener'
}
}
return {
is: 'router-link',
to: path
}
}
}
}

第二个难点是,如何复原侧边栏导航高亮并自动展开。因为页面一刷新,导航就恢复初始化状态了,在看了 NavMenu 组件的文档后发现,使用 default-activeindex 这两个属性可以实现导航默认激活指定项,因为 index 是唯一的。

而具体要用什么做为 index 唯一字段呢?没错,就是上面组装好的完整 path 地址,而 default-active 直接获取 $route.path 就行。

这个问题算是迎刃而解,原本我还在考虑通过路由的 name 做为 index 唯一字段,但最终还是觉得用 path 更好。

面包屑导航

面包屑导航核心实现其实 vue-router 已经帮我做好了,通过 $route.matched 可以拿到当前路由的所有嵌套路径片段的路由记录,然后根据具体情况,稍微对数据进行一些处理。

例如拿到路由记录后我会先进行一次过滤,把没有标题的,不需要在面包屑导航展示的,都去除掉,对,和 meta.sidebar 一样, meta.breadcrumb 也是我自定义的一个属性,用于不需要在面包屑导航展示的配置项。其次面包屑导航的根路由应该是后台首页,也就是控制台页面,但在路由文件配置中,控制台和其它模块的路由是平级的,并不是一个嵌套的结构,所以我在路由记录第一条数据前手动插了一条进去。最终代码大致是这样子:

1
2
3
4
5
let matched = this.$route.matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
if (!(matched[0].name == 'dashboard' && matched[0].path == '/dashboard')) {
matched = [{ path: '/dashboard', meta: { title: '控制台' }}].concat(matched)
}
return matched

过渡动效

主要做了三处过渡的动效,分别是侧边栏导航、面包屑导航和主内容区,也都是用了 Vue 提供的 <transition></transition><transition-group></transition-group> 组件。

如果你代码看的仔细,你会发现,侧边栏导航和面包屑导航的动画是用 transform 里的 translateX()translateY() 实现的,唯独主内容区动画是通过 margin-left 实现的,这是因为 transform 会导致子元素里的 position: fixed; 失效,而我在后面开发系统组件的时候,其中有个组件需要固定在窗口底部,所有就没有用 transform 去实现动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 侧边栏动画
.sidebar-enter-active {
transition: all 0.3s;
}
.sidebar-enter,
.sidebar-leave-active {
opacity: 0;
transform: translateY(20px);
}
.sidebar-leave-active {
position: absolute;
}

// 面包屑动画
.breadcrumb-enter-active {
transition: all 0.3s;
}
.breadcrumb-enter,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-active {
position: absolute;
}

// 主内容区动画
.main-enter-active,
.main-leave-active {
transition: all 0.3s;
}
.main-enter {
opacity: 0;
margin-left: -20px;
}
.main-leave-to {
opacity: 0;
margin-left: 20px;
}

后台基本的界面也完成的七七八八了,还剩下一个重头戏,那就是权限。

权限

权限首先分为几块:

  • 路由鉴权
  • 鉴权组件:<Auth></Auth><AuthAll></AuthAll>
  • 鉴权指令:v-authv-auth-all
  • 鉴权函数:this.$auth()this.$authAll()

其实这块并没有花费我太多时间,因为文章一开始就说过,很早之前我就已经对 vue-element-admin 的权限模块进行了重构,所以大部分的代码是直接复制过来就能用的,逻辑也不需要调整,我就不细说了,想了解的可以看《重构 vue-element-admin 权限模块》这篇文章。下面我就主要说下配置项里的权限开关是怎么实现权限开启和关闭的。

首先需要理一下用户首次访问路由到最终进入页面的整个流程:

这个流程有没有问题?先思考一下。

如果你觉得流程没问题,那说明你和我当时一样,也掉入了一个逻辑陷阱。这个逻辑陷阱就是,当用户真实的权限就是为空的时候,导航守卫会进入一个死循环,因为每次判断 vuex 里用户权限为空的时候,去获取用户权限后,依旧还是空数据。所以正确的流程应该是这样:

不是判断用户权限是否为空,而是增加一个是否获取过用户权限的全局状态,默认为 false ,一旦获取了用户权限,不管权限是否为空,这个状态就变为 true 了。

在这个流程的基础上,再去实现权限开启和关闭的功能,就变得很简单了。

结束

到此为止,后台框架的核心功能都已全部实现了,剩下还有一些内置的组件以及使用的小技巧,就不在这展开了,如果有兴趣,可以访问代码仓库,把代码拉取到本地,你能体验到更详尽的演示。

浙江易网科技股份有限公司/vue-automation-admin