# 第12章 实战:三天开发一个管理端

本章节相关代码存放在Github (opens new window)中。

管理端,大概是让大多数前端又爱又恨的一个业务。爱,大概是因为容错能力稍微强一点,对视觉还原要求比较低、业务使用量通常不会很大。恨,大概是因为管理端多半是增删改查的东西,做多了就会变成重复性的工作。

但不管你在哪里,管理端都是无法避开的一种项目,所以我们来看看怎么又快又好地搭建这样一个项目。由于是速战速决的项目类型,我们直接选择使用热门的 Element 组件。(请不要误会,这里没有收取广告费,偷懒是人的本性,而选择好用的开源组件库,是优秀的程序员都会做的事情)

国内比较热门的组件库大概有 Antd、Element、iView 等,Antd 主要是基于 React.js 的,虽然也支持了 Vue 和 Angular,但整体组件库风格偏自由灵活,而自由灵活往往也是有代价的。Element 和 iView 都是基于 Vue.js 的,而 Element 代码还提供了 codepen 在线调试运行,同时个人感觉文档和布局更舒服,所以就选了 Element,各位其实也可以根据个人喜好或是团队要求来选择使用不同的组件库。

好,我们先来设计一下这个管理端的能力吧。

# 12.1 设计管理端功能

假设我们要做一个管理端的页面,包括常见的增删查改,那会包括菜单、列表、表单这几种内容,如图 12-1:


图 12-1 管理端效果

既然要使用数据驱动的方式,那么我们先来设计这个页面的数据包括哪些。关于数据和组件抽象的方法,已经在《第10章 抽象的正确姿势》有详细地介绍。我们可以先根据页面设计来分隔模块和组件,然后根据组件抽象来设计数据。用数据驱动的模式来进行开发,这样整个思路会更加清晰一点,我们一个个来看具体怎么做。

# 12.1.1 菜单设计


图 12-2 菜单实现效果

如图 12-2,我们能看到,菜单列表主要包括父菜单列表,每个父菜单包括:

  • 图标 icon
  • 菜单名字 text
  • (可选)子菜单列表 subMenus,以及子菜单名字 text

所以,我们可以抽象出这么一个数据结构:

const menus = [
  {
    text: "服务管理", // 父菜单名字
    icon: "el-icon-setting", // 父菜单图标
    subMenus: [{ text: "服务信息" }, { text: "新增" }] // 子菜单列表
  },
  {
    text: "产品管理",
    icon: "el-icon-menu",
    subMenus: [{ text: "产品信息" }]
  },
  {
    text: "日志信息",
    icon: "el-icon-message"
  }
];

# 12.1.2 列表设计


图 12-3 列表实现效果

如图,我们能看到,列表里每行内容包括:

  • 日期: date
  • 姓名: name
  • 电话: phone
  • 地址: address

我们可以先整理到这么一个数据:

const tableItem = {
  date: "2019-05-20", // 日期
  name: "被删", // 姓名
  phone: "13888888888", // 电话
  address: "深圳市南山区滨海大道 888 号" // 地址
};

而在列表这样的增删查改的场景下,一般还需要一个唯一标识来作为标记,这里使用 id,用最简单的方式来拷贝出 20 个数据:

// 此处先以 tableItem 为数据源,拷贝生成 20 个数据,然后再根据索引 index 添加上 id
const tableData = Array(20)
  .fill(tableItem)
  .map((x, i) => {
    return { id: i + 1, ...x };
  });
console.log(tableData[1]);
// 例如第 2 个数据为:
/* {
    address: "深圳市南山区滨海大道 888 号"
    date: "2019-05-20"
    id: 2
    name: "被删"
    phone: "13888888888"
} */

# 12.1.3 方法

关于 Vue 的 methods 方法,如果说数据是状态机的话,那事件大概可以当成状态机的扭转。这里以列表作为举例吧,例如新增、删除、上移、下移,我们只需要处理数据就好了:

export default {
  data() {
    // 绑定数据
    return {
      menus: menus, // 菜单数据
      tableData: tableData // 列表数据
    };
  },
  methods: {
    // 新增一个数据
    addTableItem(item = {}) {
      // 添加到列表中,同时自增 id
      this.tableData.push({ ...item, id: this.tableData.length + 1 });
    },
    // 删除一个数据
    deleteTableItem(id) {
      // 查找到对应的索引,然后删除
      const index = this.tableData.findIndex(x => x.id === id);
      this.tableData.splice(index, 1);
    },
    // 移动一个数据
    moveTableItem(id, direction) {
      const dataLength = this.tableData.length;
      // 查找到对应的索引,然后取出,再插入
      const index = this.tableData.findIndex(x => x.id === id);
      switch (direction) {
        // 上移
        case "up":
          if (index > 0) {
            // 第一个不进行上移
            const item = this.tableData.splice(index, 1)[0];
            this.tableData.splice(index - 1, 0, item);
          }
          break;
        // 下移
        case "down":
          if (index < dataLength - 1) {
            // 最后一个不进行下移
            const item = this.tableData.splice(index, 1)[0];
            this.tableData.splice(index + 1, 0, item);
          }
          break;
      }
    }
  }
};

当我们把数据更新了之后,Vue 会自动帮我们更新到页面里,具体是怎么实现的,可以参考第1章里数据绑定的实现、虚拟 DOM 的相关内容。

# 12.2 组件快速开发

既然设计好了数据结构和状态改变,我们可以直接绑定 HTML 了。因为使用的 Element 的组件,其实我们只需要从他们的网站上找到合适的组件,然后根据他们提供的配置说明,来搭建出我们想要的效果就好了。

# 12.2.1 菜单绑定

我们先来看看 Elmenet 里的菜单是怎么用的,可以参考 Element-NavMenu 导航菜单文档,这里我们只列出我们可能用到的一些配置(感兴趣的小伙伴,也可以去 Github 上翻一下源码看看别人的组件是怎么抽象和配置化管理的),<el-menu><el-submenu><el-menu-item>的配置如下:

表 12-1 <el-menu>参数配置

参数 说明 类型 可选值 默认值
mode 模式 string horizontal/vertical vertical
collapse 是否水平折叠收起菜单(仅在 mode 为 vertical 时可用) boolean false
background-color 菜单的背景色(仅支持 hex 格式) string #ffffff
text-color 菜单的文字颜色(仅支持 hex 格式) string #303133
active-text-color 当前激活菜单的文字颜色(仅支持 hex 格式) string #409EFF
default-active 当前激活菜单的 index string
default-openeds 当前打开的 sub-menu 的 index 的数组 Array
unique-opened 是否只保持一个子菜单的展开 boolean false

表 12-2 <el-submenu>参数配置

参数 说明 类型 可选值 默认值
index 唯一标志 string/null null

表 12-3 <el-menu-item>参数配置

参数 说明 类型 可选值 默认值
index 唯一标志 string

我们来看看代码,可以仔细看注释噢:

<!-- default-openeds 为默认展开的菜单项,以 index 序号 -->
<el-menu :default-openeds="['1', '3']">
  <!-- el-submenu 为带子菜单的父菜单,index 为每组菜单的序号 -->
  <el-submenu index="1">
    <!-- 下面是父菜单内容,包括父菜单 icon 和父菜单名字 -->
    <template slot="title"><i class="el-icon-message"></i>导航一</template>
    <el-menu-item-group>
      <!-- 子菜单选项,包括 index 序号和子菜单名字 -->
      <el-menu-item index="1-1">选项1</el-menu-item>
      <el-menu-item index="1-2">选项2</el-menu-item>
    </el-menu-item-group>
  </el-submenu>
  <!-- el-menu-item 为不带子菜单的父菜单,index 为每组菜单的序号 -->
  <el-menu-item index="2">
    <!-- 父菜单内容,包括父菜单 icon 和父菜单名字 -->
    <i class="el-icon-menu"></i>
    <span slot="title">导航二</span>
  </el-menu-item>
</el-menu>

绑定数据之后,就会变成这样:

<!-- 顺便调整了下颜色 -->
<el-menu
  :default-openeds="['0', '1']"
  class="el-menu-vertical-demo"
  background-color="#545c64"
  text-color="#fff"
  active-text-color="#ffd04b"
>
  <!-- 遍历生成父菜单选项 -->
  <template v-for="menu in menus">
    <!-- 有子菜单的时候,就用 el-submenu,再绑个序号 index -->
    <el-submenu
      v-if="menu.subMenus && menu.subMenus.length"
      :index="menu.index"
      :key="menu.index"
    >
      <template slot="title">
        <!-- 绑个父菜单的 icon -->
        <i :class="menu.icon"></i>
        <!-- 再绑个父菜单的名称 text -->
        <!-- slot 其实类似于占位符,可以去 Vue 官方文档了解一下插槽 -->
        <span slot="title">{{menu.text}}</span>
      </template>
      <el-menu-item-group>
        <!-- 子菜单也要遍历,同时绑上子菜单名称 text,也要绑个序号 index -->
        <el-menu-item
          v-for="subMenu in menu.subMenus"
          :key="subMenu.index"
          :index="subMenu.index"
          >{{subMenu.text}}</el-menu-item
        >
      </el-menu-item-group>
    </el-submenu>
    <!-- 没子菜单的时候,就用 el-menu-item,也要绑个序号 index -->
    <el-menu-item v-else :index="menu.index" :key="menu.index">
      <!-- 绑个父菜单的 icon -->
      <i :class="menu.icon"></i>
      <!-- 再绑个父菜单的名称 text -->
      <span slot="title">{{menu.text}}</span>
    </el-menu-item>
  </template>
</el-menu>

我们之前的 menus 并没有index,这里可以顺便遍历生成一下:

menus = menus.map((x, i) => {
  return {
    ...x,
    // 子菜单就拼接${父菜单index}-${子菜单index}
    subMenus: (x.subMenus || []).map((y, j) => {
      return { ...y, index: `${i}-${j}` };
    }),
    // 父菜单就把 index 加上好了
    index: `${i}`
  };
});

看~菜单成功生成了:
图 12-4 菜单实现效果

# 12.2.2 列表绑定

列表使用的组件是 Element-Table,以及需要按钮 Button 操作。由于 Table 的属性太多,这里就不再贴出来占篇幅,大家可以自行去 Element 的官网(https://element.eleme.cn)里查看。我们把数据绑上,还有按钮以及对应的事件都绑上,就获得这样一份代码:

<!-- data 里绑定表格数据,同时这里调整了下样式 -->
<el-table
  stripe
  :data="tableData"
  style="border: 1px solid #ebebeb;border-radius: 3px;margin-top: 10px;"
>
  <!-- prop 传绑定 tableData 的数据 id,表头名称 id,同时设了下宽度 -->
  <el-table-column prop="id" label="id" width="100"></el-table-column>
  <!-- prop 传绑定 tableData 的数据 date,表头名称日期 -->
  <el-table-column prop="date" label="日期" width="200"></el-table-column>
  <!-- prop 传绑定 tableData 的数据 name,表头名称姓名 -->
  <el-table-column prop="name" label="姓名" width="200"></el-table-column>
  <!-- prop 传绑定 tableData 的数据 phone,表头名称电话 -->
  <el-table-column prop="phone" label="电话" width="200"></el-table-column>
  <!-- prop 传绑定 tableData 的数据 address,表头名称地址 -->
  <el-table-column prop="address" label="地址"></el-table-column>
  <!-- 该列固定在右侧,表头名称操作 -->
  <el-table-column fixed="right" label="操作" width="300">
    <template slot-scope="scope">
      <!-- 添加了个删除按钮,绑定了前面定义的删除事件 deleteTableItem,传入参数 id -->
      <el-button
        @click="deleteTableItem(scope.row.id)"
        type="danger"
        size="small"
        >删除</el-button
      >
      <!-- 分别添加了上移和下移按钮,绑定了前面定义的移动事件 moveTableItem,传入参数 id 和移动方向 -->
      <el-button @click="moveTableItem(scope.row.id, 'up')" size="small"
        >上移</el-button
      >
      <el-button @click="moveTableItem(scope.row.id, 'down')" size="small"
        >下移</el-button
      >
    </template>
  </el-table-column>
</el-table>

然后我们就顺利获得了这样一个列表:
图 12-5 列表实现效果

# 12.2.3 表单绑定

有列表的地方,当然也少不了表单。那么,同样的方法,我们直接使用 Element-Form 组件。因为这里打算用弹窗的方式来装这个表单的内容,所以我们也使用了 Element-Dialog 组件,请自行查阅组件属性和配置噢。

有了前面数据设计和绑定的基础,这里可以直接给出我们的代码:

<!-- 找个地方添加一个新增的按钮,点击的时候出现表单的弹窗,以及把表单内容设置为初始值 -->
<el-button type="primary" @click="dialogFormVisible = true;form = {};"
  >新增</el-button
>
<!-- Form -->
<!-- el-dialog 是弹窗样式,title 绑定弹窗的标题内容,visible 绑定弹窗是否展示 -->
<el-dialog title="新增" :visible.sync="dialogFormVisible">
  <el-form :model="form">
    <!-- el-form-item 绑定表单样式,label 表单的名称,formLabelWidth 设置 label 的宽度 -->
    <el-form-item label="日期" :label-width="formLabelWidth">
      <!-- 里面装载表单元素,这里装了个选择日期的组件,v-model 绑定选择值,value-format设置绑定值的格式,type 设置选择的范围,这里 date 表示到天 -->
      <el-date-picker
        v-model="form.date"
        value-format="yyyy-MM-dd"
        type="date"
        placeholder="选择日期"
      ></el-date-picker>
    </el-form-item>
    <el-form-item label="姓名" :label-width="formLabelWidth">
      <el-input v-model="form.name"></el-input>
    </el-form-item>
    <el-form-item label="电话" :label-width="formLabelWidth">
      <el-input v-model="form.phone" type="tel"></el-input>
    </el-form-item>
    <el-form-item label="地址" :label-width="formLabelWidth">
      <el-input v-model="form.address"></el-input>
    </el-form-item>
  </el-form>
  <div slot="footer" class="dialog-footer">
    <!-- 点击取消的时候,设置弹窗不可见 -->
    <el-button @click="dialogFormVisible = false">取 消</el-button>
    <!-- 点击确定的时候,设置弹窗不可见,同时添加一项内容 -->
    <el-button
      type="primary"
      @click="dialogFormVisible = false; addTableItem(form)"
      >确 定</el-button
    >
  </div>
</el-dialog>

我们需要新增的数据变量包括:

export default {
  data() {
    return {
      dialogFormVisible: false, // 弹窗是否出现
      form: {}, // 用作表单绑定的内容
      formLabelWidth: "120px" // 表单 label 的宽度
    };
  }
};

这样,我们的表单就写好了:
图 12-6 表单实现效果

# 12.3 设计页面与路由

在直接讲我们的路由怎么配置前,我们需要先知道我们的应用要怎么划分,路由和页面路径是一一对应的,所以我们需要先设计应用的页面逻辑。我们要知道怎么设计一个应用,或者说根据已有的产品、设计交互,怎么规划我们项目的结构。

我们看看前面内容的页面效果:

图 12-7 表单实现效果

# 12.3.1 页面结构设计

这是常用的一种管理端页面结构,我们可以基于这样的页面设计几种类型的页面拼装:

表 12-4 几种常用的管理端页面类型

序号 页面形式 页面能力
1 登录页 只有用户名和密码的输入
2 列表 + 表单 单页可以完成某类服务的增删查改
3 列表页 只有列表展示,提供查和删服务,需要配合 4 的表单页完成增和改
4 表单页 只有表单编辑内容,可提供新增、修改等能力给 3 使用

上述序号 2 到 4 结构的页面,可以配合路由,整理出这样的菜单信息:
图 12-8 几种常用的管理端页面类型

# 12.3.2 页面路由设计

上述情况下,以/作为根路由(对应的组件为 App.vue),我们设计这么几种路由和页面:

表 12-5 页面路由设计

路由 页面内容 页面对应的 Component 页面组成
/login 登录页 Login 表单,包括usernamepassword
/home 应用首页 Home 左侧菜单<Menu>,右侧路由内容<router-view>
/home/service 服务信息页 Service 为 Home 的子路由组件,包括列表和表单
/home/product 产品容器页 Product 为 Home 的子路由组件,包括<router-view>
/home/product/list 产品信息页 ProductList 为 Product 的子路由组件,包括列表
/home/product/edit 产品编辑页 ProductEdit 为 Product 的子路由组件,包括表单

页面结构和路由嵌套管理,其实是这样的:

/login                     /home                     /home/service
+------------------+       +-----------------+       +-----------------+
| App              |       | App             |       | App             |
| +--------------+ |       | +-------------+ |       | +-------------+ |
| | Login        | |       | | Home        | |       | | Home        | |
| |              | |       | |             | |       | | +---------+ | |
| |              | |  +--) | |<router-view>| |  +--) | | | Service | | |
| |              | |       | |  无对应内容  | |       | | |列表+表单 | | |
| |              | |       | |             | |       | | +---------+ | |
| +--------------+ |       | +-------------+ |       | +-------------+ |
+------------------+       +-----------------+       +-----------------+


      /home/product                /home/product/list              /home/product/edit
      +---------------------+      +------------------------+      +------------------------+
      | App                 |      | App                    |      | App                    |
      | +-----------------+ |      | +--------------------+ |      | +--------------------+ |
      | | Home            | |      | | Home               | |      | | Home               | |
      | | +-------------+ | |      | | +----------------+ | |      | | +----------------+ | |
 +--) | | | Product     | | | +--) | | | Product        | | | +--) | | | Product        | | |
      | | |<router-view>| | |      | | | +------------+ | | |      | | | +------------+ | | |
      | | |  无对应内容  | | |      | | | | ProductList| | | |      | | | | ProductEdit| | | |
      | | |             | | |      | | | | 单列表页    | | | |      | | | | 单表单页    | | | |
      | | |             | | |      | | | +------------+ | | |      | | | +------------+ | | |
      | | +-------------+ | |      | | +----------------+ | |      | | +----------------+ | |
      | +-----------------+ |      | +--------------------+ |      | +--------------------+ |
      +---------------------+      +------------------------+      +------------------------+

我们能看到,这里包括了层层的路由嵌套关系,我们后面在配置路由的时候也能看到这样的结构。

# 12.3.3 目录结构划分

我们看到上面的路由划分示意图,使用框框框起来的代表一个 Vue 组件。而在 Vue 中,其实一切皆组件(页面是特殊的组件),那我们要怎么区分页面和组件呢,一般可以使用项目目录来简单做一些划分:

├─dist                      // 编译之后的项目文件
├─src                       // 开发目录
│  ├─assets                 // 静态资源
│     ├─less                // 公共less
│     ├─img                 // 图片资源
│  ├─components             // **放这里是组件**
│  ├─pages                  // **放这里是页面** 根据路由结构划分
│  ├─utils                  // 工具库
│  ├─App.vue                // 启动页面,最外层容器组件
│  ├─main.js                // 入口脚本
├─babel.config.js          // babel 配置文件
├─vue.config.js            // vue 自定义配置,与 webpack 配置相关
├─package.json             // 项目配置
├─README.md                // 项目说明

目录结构清晰,其实对一个项目的可维护性非常重要,一眼看去你就知道这个项目大概包括了哪些内容,分别都放在哪里。好看的目录结构和命名,和好看的代码结构和命名一样,已经是天然的说明了,这是很好的编码习惯。前面第9章也有详细讲述这一块的内容,大家可以去复习下。

好了,项目目录和路由结构我们划分好了,我们来看看怎么根据上面的设计来配置路由,以及实现相互跳转。

# 12.4 路由配置与开发

关于应用路由和 Vue Router 的介绍和使用,已经在第7章详细介绍过,这里我们就直接使用了。

# 12.4.1 配置路由信息

根据以上的嵌套关系,我们可以设置最外层的根路由为"/",加上其他嵌套子路由配置为:

// 配置路由信息
// 每个路由应该映射一个组件。 其中"component" 可以是
// 通过 Vue.extend() 创建的组件构造器,
// 或者,只是一个组件配置对象。
const routes = [
  {
    path: "/", // 父路由路径
    component: App, // 父路由组件,传入 vue component
    name: "App", // 路由名称
    // 设置子路由
    children: [
      {
        path: "login", // 子路由路径
        component: Login, // 子路由组件,会替换父组件中<router-view>中的内容
        name: "Login" // 路由名称
      },
      {
        // 应用首页
        path: "home",
        component: Home,
        name: "Home",
        children: [
          // 服务列表
          { path: "service", component: Service, name: "Service" },
          // 产品容器
          {
            path: "product",
            component: Product,
            name: "Product",
            children: [
              // 子路由内容
              // 产品列表
              { path: "list", component: ProductList, name: "ProductList" },
              // 产品新增
              { path: "add/0", component: ProductEdit, name: "ProductAdd" },
              // 产品编辑
              // 我们能看到,新增和编辑其实最终使用的是同一个组件,所以后面会有一些需要兼容处理的地方
              // :id可匹配任意值,且可在组件中通过this.$route.params.id获取该值
              { path: "edit/:id", component: ProductEdit, name: "ProductEdit" }
            ]
          }
        ]
      }
    ]
  }
];

# 12.4.2 Vue 中加载 vue-router 和路由信息

路由配置设计好之后,我们可以通过将 router 配置参数注入路由,让整个应用都有路由功能:

// 加载路由信息
const router = new VueRouter({
  // mode: 路由模式,'hash' | 'history'
  // routes:传入路由配置信息,后面会讲怎么配置
  routes // (缩写)相当于 routes: routes
});
// 启动一个 Vue 应用
new Vue({
  el: "#app",
  router, // 传入路由能力
  render: h => h(App)
});

这里 vue-router 的路由模式mode包括两种: (1) hash

  • 使用 URL hash 值来作路由。支持所有浏览器,包括不支持 HTML5 History Api 的浏览器
  • 例如上面说的a.com/#/pageone,便是 hash 模式
    (2) history
  • 充分利用history.pushState API 来完成 URL 跳转而无须重新加载页面
  • 例如a.com/pageone,如果觉得 hash 模式丑可以使用这种
  • **注意!!**该模式依赖 HTML5 History API 和服务器配置

到这里,我们路由配置和启动的部分已经完成,可以在 https://github.com/godbasin/vue-ebook/blob/vue-sourcecode/12/1-element-manage-app/src/main.js 文件查看完整代码。

# 12.4.3 <router-view>使用

<router-view>组件是一个 functional 组件,渲染路径匹配到的视图组件。它渲染的组件还可以内嵌自己的<router-view>,根据嵌套路径,渲染嵌套组件。我们来看看<router-view>的使用,这里以 App.vue 和 Home.vue 作为例子:

<!-- 这里是最外层 /app 路由的组件,App.vue -->
<template>
  <!-- 使用 <router-view></router-view> 来嵌套路由 -->
  <router-view></router-view>
</template>

<!-- 这里是 /app/home 路由的组件,Home.vue -->
<!-- 这里采用了简写,省略了一些非关键内容,更多内容可以参考上一节 -->
<template>
  <el-container>
    <!-- 左侧菜单, Menu.vue -->
    <menu></menu>
    <!-- 右侧内容 -->
    <el-container>
      <!-- 上边的头部栏 -->
      <el-header></el-header>
      <!-- 子路由页面的内容 -->
      <router-view></router-view>
    </el-container>
  </el-container>
</template>

前面我们拼了这么一个页面:

图 12-9 目前的页面效果

这里左侧的菜单点击没有什么反应,因为我们还没有加上路由。那么现在就使用这里的菜单,来展示下<router-link>的使用吧。

<!-- 这里是 Menu.vue,即上一节内容种拼的左侧菜单 -->
<!-- 这里主要针对路由相关内容,更多的注释省略了,有需要可查看最终代码 -->
<template>
  <!-- 此处有个 default-active 属性需要注意,是用来设置菜单的选中样式,我们需要根据当前路由情况来选中 -->
  <el-menu
    :collapse="isMenuCollapse"
    :default-openeds="['0', '1']"
    :default-active="activeIndex"
  >
    <!-- 遍历生成父菜单选项 -->
    <template v-for="menu in menus">
      <!-- 有子菜单的时候 -->
      <el-submenu
        v-if="menu.subMenus && menu.subMenus.length"
        :index="menu.index"
        :key="menu.index"
      >
        <template slot="title">
          <i :class="menu.icon"></i>
          <span slot="title">{{menu.text}}</span>
        </template>
        <el-menu-item-group>
          <!-- 使用 router-link 组件来导航. -->
          <!-- 通过传入 `to` 属性指定链接. -->
          <!-- <router-link> 默认会被渲染成一个 `<a>` 标签 -->
          <router-link
            tag="div"
            v-for="subMenu in menu.subMenus"
            :key="subMenu.index"
            :to="{name: subMenu.routerName}"
          >
            <el-menu-item :index="subMenu.index">{{subMenu.text}}</el-menu-item>
          </router-link>
        </el-menu-item-group>
      </el-submenu>
      <!-- 只有单个父菜单的时候,也要给这个父菜单添加路由,同样的 to 指向要去的地方 -->
      <router-link
        v-else
        :index="menu.index"
        :key="menu.index"
        tag="div"
        :to="{name: menu.routerName}"
      >
        <!-- 没子菜单的时候,就用 el-menu-item,也要绑个序号 index -->
        <el-menu-item>
          <i :class="menu.icon"></i>
          <span slot="title">{{menu.text}}</span>
        </el-menu-item>
      </router-link>
    </template>
  </el-menu>
</template>

<router-link>的使用,除了通过 name 来跳转之外,还可通过 path 跳转、带上参数、激活样式、tag 设置等:

表 12-6 <router-link>属性配置说明

配置 说明
to 一个路径字符串, 或者一个对象 location descriptor
tag 渲染成的 html 元素类型,默认是<a>
exact 用于控制当前激活项的行为
append 控制相对链接路径的追加方式
replace 替代而不是作为历史条目压榨
active-class 当链接项激活时增加的 CSS 样式

更多的大家可以参考官网 router-link 的 API。也可以在 https://github.com/godbasin/vue-ebook/blob/vue-sourcecode/12/1-element-manage-app/src/components/Menu.vue 文件查看Menu组件的完整代码。

# 12.4.5 使用 watch 监控路由变化

对应的,我们需要在 menus 里加上 routerName,用来跳转:

// routerName 为对应的路由的路由名称
const menus = [
  {
    text: "服务管理",
    icon: "el-icon-setting",
    subMenus: [{ text: "服务信息", routerName: "Service" }]
  },
  {
    text: "产品管理",
    icon: "el-icon-menu",
    subMenus: [
      { text: "产品信息", routerName: "ProductList" },
      { text: "新增", routerName: "ProductAdd" }
    ]
  },
  // 日志信息这里为空,则不会进行跳转
  { text: "日志信息", icon: "el-icon-message", routerName: "" }
].map((x, i) => {
  // 添加 index,可用于默认展开 default-openeds 属性,和激活状态 efault-active 属性的设置
  return {
    ...x,
    // 子菜单就拼接${父菜单index}-${子菜单index}
    subMenus: (x.subMenus || []).map((y, j) => {
      return { ...y, index: `${i}-${j}` };
    }),
    // 父菜单就把 index 加上好了
    index: `${i}`
  };
});

根据前面<el-menu>的配置我们知道,default-active属性需要设置当前激活菜单的 index,因此我们需要监控路由的变化,并根据路由情况调整绑定的激活 index。

// 下面是 Vue 组件
export default {
  data() {
    return {
      menus, // menus: menus 的简写
      activeIndex: ""
    };
  },
  watch: {
    // 为了设置 el-menu 的 default-active 属性,需要获取到路由状态
    $route(to) {
      // 对路由变化作出响应...
      let activeIndex;
      // 找到匹配的 routerName
      this.menus.forEach(x => {
        if (x.routerName === to.name) {
          activeIndex = x.index;
        } else {
          const subMenuItem = x.subMenus.find(y => y.routerName === to.name);
          if (subMenuItem) {
            activeIndex = subMenuItem.index;
          }
        }
      });
      // 并将 activeIndex 设置为对应的 菜单 index
      if (activeIndex) {
        this.activeIndex = activeIndex;
      }
    }
  }
};

我们看到,这里使用了 watch 属性,监听了$route的变化。关于 Vue 中监听属性watch$route的变化,前面也介绍了,这里就不过多介绍了。

# 12.4.6 路由跳转

除了使用<router-link>来定义导航链接,我们还可以借助 router 的实例方法,通过编写代码来实现。在 Vue 实例内部,我们可以通过\$router访问路由实例,例如我们在 ProductList 页面需要跳转到 ProductEdit 页面来编辑/新增选项内容:

export default {
  // ...其他省略
  methods: {
    // 新增/修改一个数据
    updateTableItem(id = 0) {
      // 跳转到编辑页面,新增则传id为0,否则为编辑
      // 可以通过 this.$router 访问路由实例
      if (id !== 0) {
        // 传参 name 为路由名字,params 为我们定义的路由 path 的参数,变成 /edit/xxx
        // 还有另外一种传参方式 query,带查询参数,变成 /edit?id=xxx
        this.$router.push({ name: "ProductEdit", params: { id } });
      } else {
        this.$router.push({ name: "ProductAdd" });
      }
    }
  }
};

router 实例的使用和<router-link>其实很相像,也挺简单的,可以参考第7章内容,或者上 Vue Router 官网查阅。

# 12.5 给路由添加鉴权

既然我们这一次设计了登录页和应用首页,一般来说,我们会设计只有当登录完成之后,才可以进入应用里面的其他页面。下面是具体的实现方式。

# 12.5.1 设置简单的全局数据

一般来说,在 Vue 中会使用 Vuex 来管理数据状态。基于篇幅关系,Vuex 在第11章详细介绍,所以这里我们简单设计一个全局数据的管理库:

// globalData.js
// globalData 用来存全局数据
let globalData = {};

// 获取全局数据
// 传 key 获取对应的值
// 不传 key 获取全部值
export function getGlobalData(key) {
  return key ? globalData[key] : globalData;
}

// 设置全局数据
export function setGlobalData(key, value) {
  // 需要传键值对
  if (key === undefined || value === undefined) {
    return;
  }
  globalData = { ...globalData, [key]: value };
  return globalData;
}

// 清除全局数据
// 传 key 清除对应的值
// 不传 key 清除全部值
export function clearGlobalData(key) {
  // 需要传键值对
  if (key === undefined) {
    globalData = {};
  } else {
    delete globalData[key];
  }
  return globalData;
}

使用这种方式的全局数据,是会在页面刷新之后丢失的。而如果用来存用户的登录态信息,为了避免频繁登录,更好的方式是存到 cookie 或者缓存里。

# 12.5.2 登录页面登录

拼好的页面可以查看 https://github.com/godbasin/vue-ebook/blob/vue-sourcecode/12/1-element-manage-app/src/pages/Login.vue 文件,这里我们只看保存数据和跳转的部分:

import { setGlobalData } from "utils/globalData";

// 下面是 Vue 组件
export default {
  // ...其他省略
  methods: {
    // 提交新增/修改表单
    onSubmit() {
      // 校验表单,用户名和密码都必须填入
      // Element 表单校验规则配置,请查看https://element.eleme.cn/#/zh-CN/component/form
      this.$refs["form"].validate(valid => {
        if (valid) {
          // 校验通过
          // 设置用户名
          setGlobalData("username", this.form.username);
          // 跳转到里页
          this.$router.push({ name: "Home" });
        } else {
          // 校验失败
          return false;
        }
      });
    }
  }
};

# 12.5.3 鉴权进入内页

这里,我们需要使用 vue-router 的router.beforeEach导航守卫能力,当用户未登录时,则拒绝进入其他路由页面里:

// main.js
import { getGlobalData } from "utils/globalData";

router.beforeEach((to, from, next) => {
  if (to.name !== "Login") {
    // 非 login 页面,检查是否登录
    // 这里简单前端模拟是否填写了用户名,真实环境需要走后台登录,缓存到本地
    if (!getGlobalData("username")) {
      next({ name: "Login" });
    }
  }
  // 其他情况正常执行
  next();
});

点击此处查看页面效果 (opens new window) 点击此处查看源码 (opens new window)

这样,我们就可以在用户未登录时,拦截所有通往内页的操作,并重定向到登录页面。当然,这只是个静态页面,距离真正上线,我们还需要进行接口的联调、代码打包、发布上线等工作。但到这里,我们整个拥有登录、导航、数据增删改查的能力都开发完成了。