一个半吊子的前端搭建用vue + element ui搭建的管理员后台

本文详细记录了一位服务端开发者学习前端,并使用Vue3和Element-Plus搭建一个管理员后台的过程。从零开始,通过解析服务端生成的路由树,动态生成菜单,实现了权限控制、面包屑导航、搜索、头像下拉等功能。同时讨论了权限与路由的关联方式,以及如何从服务端获取用户有权访问的路由。
摘要由CSDN通过智能技术生成

鄙人做服务端的,想学学前端,顺便用vue + element ui搭了一个管理员后台。

演示

在这里参考了官网提供的管理员后台模板vue-elemetn-admin
https://panjiachen.github.io/vue-element-admin-site/zh/guide/
https://github.com/PanJiaChen/vue-element-admin
官网的,自然更好看,功能更强大, 我做的只能算是超级阉割版,主要是为了学习。
我做这个管理员后台还有一个原因,权限问题。
https://juejin.cn/post/6844903478880370701

注意:这篇文章还是要一点基础才能看得懂的

先说说官方的权限与左侧菜单的思路。菜单与路由关联的,路由常亮加路由变量(登录后根据用户角色计算出的),菜单项与用户角色挂钩,拥有这个角色的用户才能看到这个菜单。
这里是维护了一个全量的异步路由表,需要权限判断的菜单项都与角色关联,用户登录成功后根据用户角色过滤这个全量的路由表,再动态挂在到路由上。这里就出现了一个比较严重的问题:正常情况下,一个平台或者系统不可能只有一两个权限或者角色,根据官方的这种思路,新增角色的话,就需要修改这个全量的异步路由表(JS里面),这样就显得不灵活了,而且不满足需要。所以我就自己做了一个管理员后台,从服务端加载用户有权限的路由,再异步挂载。

先说说我用户、角色、权限的实现思路,这篇文章不包括服务端相关内容,所有的数据我都是在js里面写死的,到时候对接服务端的时候需要稍作修改。
一个用户有多个角色,一个角色有多个权限,用户与权限不直接关联。权限与路由绑定,角色授权权限,用户授权角色,这样就能获取到用户拥有的权限了。

这里一样的保留了登录,可以跟oauth2的密码模式很好的结合。

首先简单看一下相关的版本,主要是vue3 + element-plus

{
  "name": "front-end",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "core-js": "^3.6.5",
    "element-plus": "^1.0.2-beta.44",
    "js-cookie": "^2.2.1",
    "nprogress": "^0.2.0",
    "vue": "^3.0.0",
    "vue-router": "^4.0.0-0",
    "vuex": "^4.0.0-0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-plugin-router": "^4.5.13",
    "@vue/cli-plugin-vuex": "^4.5.13",
    "@vue/cli-service": "~4.5.0",
    "@vue/compiler-sfc": "^3.0.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^7.0.0"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/vue3-essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

基本布局与说明

首先看一下布局
布局
最外层容器
左侧el-aside:左侧菜单栏
上方el-header:收缩、展开按钮,面包屑导航,搜索,头像下拉
主要,el-main:选项卡

再看代码结构,没有什么花里胡哨的,我是从零开始搭建的
代码结构

注意layout文件夹
dashboard:首页
NavBreadcrumb:面包屑导航
NavHeader:头
NavMenu:左侧菜单
NavTab:选项卡

左侧菜单

首先,服务端会生成一个树,需要还用这个解析树生成路由对象并且添加到异步路由。后台维护的component是字符串,这里需要解析为组件。
由于这个树可能会有很多层级,所以把el-menu-item单独抽出来生成一个组件,可以递归遍历树。

<template>
  <!-- 还有子菜单 -->
  <el-submenu v-if="item && item.children && item.children.length > 0" :index="parent === '' ? item.path : parent + '/' + item.path">
    <template #title>
      <i v-if="item.meta && item.meta.icon" :class="item.meta.icon" />
      <span>{{ item.meta.title }}</span>
    </template>
    <NavMenuItem v-for="temp in item.children" :key="temp.name" :item="temp" :parent="parent === '' ? item.path : parent + '/' + item.path" />
  </el-submenu>
  <!-- 显示和隐藏只针对最后一个菜单生效, hidden = true表示隐藏, 不显示 -->
  <el-menu-item
    v-else-if="item.meta.hidden === undefined || !item.meta.hidden"
    :index="parent === '' ? item.path : parent + '/' + item.path"
  >
    <template #title>
      <i v-if="item.meta.icon" :class="item.meta.icon" />
      <span>{{ item.meta.title }}</span>
    </template>
  </el-menu-item>
</template>

<script>
export default {
  name: 'NavMenuItem',
  props: {
    item: {
      required: false,
      type: Object,
      default: () => {}
    },
    // 这个表示父路劲
    parent: {
      required: false,
      type: String,
      default: '' // 这个值为空字符串表示一级路由
    },
    key: {
      required: false,
      type: String,
      default: ''
    }
  }
}
</script>

<style scoped>

</style>

使用组件

<NavMenuItem v-for="router in routers" :key="router.name" :item="router" />

这里遇到过一个bug,就是在menu-item外面包一层div,会出现收缩的时候效果不理想,F12调试会发现,左侧菜单内容与正常的菜单内容不一样。

展开、搜索

这是个状态保存在vuex里面,左侧菜单根据这个状态展开还是搜索
这个按钮

<!-- 展开 -->
        <el-button v-if="isCollapse" size="small" icon="el-icon-s-unfold" class="btn-folde" @click="fold(!isCollapse)" />
        <!-- 折叠 -->
        <el-button v-else size="small" icon="el-icon-s-fold" class="btn-folde" @c
        lick="fold(!isCollapse)" />

isCollapse 直接从状态里面获得

computed: {
	isCollapse() {
    	return this.$store.getters.isCollapse
    }
},
methods: {
	fold(isCollapse) {
	  // 更新状态
      this.$store.dispatch('navMenu/toggleCollapse', isCollapse)
    }
}

面包屑导航

这里是只读的,这样简单,充当一个展示效果

<template>
  <el-breadcrumb separator-class="el-icon-arrow-right">
    <el-breadcrumb-item v-for="item in activeItems" :key="item">{{ item }}</el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script>
import { analyseBreadcreumb } from '../../store/modules/permission'
export default {
  name: 'NavBreadcrumb',
  props: {
    max: {
      type: Number,
      default: 8
    }
  },
  computed: {
    // 面包屑数组
    activeItems() {
      // 解析生成面包屑
      return analyseBreadcreumb(this.$router.getRoutes(), this.$store.getters.activeItem)
    }
  }
}
</script>

<style scoped>

</style>

搜索

<el-autocomplete
          v-model="searchKeyword"
          class="inline-input"
          placeholder="请输入内容"
          prefix-icon="el-icon-search"
          size="small"
          :fetch-suggestions="querySearch"
          @select="handleSelect"
          @keyup.enter="handleSelect"
        />

下拉

<el-dropdown style="height: 50px">
          <div class="avatar">
            <el-avatar :size="40" hape="square" :src="src" />
          </div>
          <template #dropdown>
            <el-dropdown-menu>
              <router-link to="/dashboard">
                <el-dropdown-item>首页</el-dropdown-item>
              </router-link>
              <el-dropdown-item>个人资料</el-dropdown-item>
              <el-dropdown-item @click.native="logout">退出登录</el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>

选项卡

这里需要注意,选项卡头部要定格在最上方,不能与内容一起滚动,这里是用css样式控制的。

全部源码

代码里面备注写的很多了,应该能看懂的。

  • vue.config.js
const path = require('path')

function resolve(dir) {
  return path.join(__dirname, dir)
}

module.exports = {
  publicPath: '/',
  devServer: {
    port: 9527,
    open: false
  },
  configureWebpack: {
    // 设置标题
    name: 'Admin Template',
    resolve: {
      alias: {
        '@': resolve('src')
      }
    }
  }
}

  • package.json
{
  "name": "front-end",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "core-js": "^3.6.5",
    "element-plus": "^1.0.2-beta.44",
    "js-cookie": "^2.2.1",
    "nprogress": "^0.2.0",
    "vue": "^3.0.0",
    "vue-router": "^4.0.0-0",
    "vuex": "^4.0.0-0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-plugin-router": "^4.5.13",
    "@vue/cli-plugin-vuex": "^4.5.13",
    "@vue/cli-service": "~4.5.0",
    "@vue/compiler-sfc": "^3.0.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^7.0.0"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/vue3-essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

  • src/layout/index.vue
<template>
  <div class="common-layout">
    <el-container>
      <el-aside width="auto">
        <NavMenu />
      </el-aside>
      <el-container>
        <el-header height="50px">
          <NavHeader />
          <!--<el-radio-group v-model="isCollapse" style="margin-bottom: 20px;">
            <el-radio-button :label="false">展开</el-radio-button>
            <el-radio-button :label="true">收起</el-radio-button>
          </el-radio-group>-->
        </el-header>
        <el-main>
          <NavTab />
        </el-main>
      </el-container>
    </el-container>
  </div>
</template>

<script>
import NavMenu from './NavMenu'
import NavHeader from './NavHeader'
import NavTab from './NavTab/index'
export default {
  name: 'Layout',
  components: {
    NavTab,
    NavMenu,
    NavHeader
  }/*,
  computed: {
    isCollapse: {
      get() {
        return this.$store.getters.isCollapse
      },
      set(value) {
        this.$store.commit('navMenu/TOGGLE_COLLAPSE', value)
      }
    }
  }*/
}
</script>

<style scoped>
.common-layout,
.el-container {
  height: 100%;
}
.el-header {
  padding: 0;
  border-bottom: 1px solid #e5e5e5;
}
.el-aside::-webkit-scrollbar{
  width: 3px;
  background-color: #F5F5F5;
}
/*定义滚动条轨道 内阴影+圆角*/
.el-aside::-webkit-scrollbar-track {
  border-radius: 8px;
  background-color: #F5F5F5;
}
/*定义滑块 内阴影+圆角*/
.el-aside::-webkit-scrollbar-thumb{
  border-radius: 10px;
  box-shadow: inset 0 0 6px rgba(0, 0, 0, .1);
  background-color: #c8c8c8;
}
.el-main {
  padding: 0;
  overflow-y: hidden;
}
</style>

  • src/layout/NavBreadcrumb/index.vue
<template>
  <el-breadcrumb separator-class="el-icon-arrow-right">
    <el-breadcrumb-item v-for="item in activeItems" :key="item">{{ item }}</el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script>
import { analyseBreadcreumb } from '../../store/modules/permission'
export default {
  name: 'NavBreadcrumb',
  props: {
    max: {
      type: Number,
      default: 8
    }
  },
  computed: {
    // 面包屑数组
    activeItems() {
      // 解析生成面包屑
      return analyseBreadcreumb(this.$router.getRoutes(), this.$store.getters.activeItem)
    }
  }
}
</script>

<style scoped>

</style>

  • src/layout/NavHeader/index.vue
<template>
  <div class="header-contrainer">
    <el-row>
      <el-col :span="1">
        <!-- 展开 -->
        <el-button v-if="isCollapse" size="small" icon="el-icon-s-unfold" class="btn-folde" @click="fold(!isCollapse)" />
        <!-- 折叠 -->
        <el-button v-else size="small" icon="el-icon-s-fold" class="btn-folde" @click="fold(!isCollapse)" />
      </el-col>
      <el-col :span="18">
        <NavBreadcrumb :max="8" />
      </el-col>
      <el-col :span="4">
        <el-autocomplete
          v-model="searchKeyword"
          class="inline-input"
          placeholder="请输入内容"
          prefix-icon="el-icon-search"
          size="small"
          :fetch-suggestions="querySearch"
          @select="handleSelect"
          @keyup.enter="handleSelect"
        />
      </el-col>
      <el-col :span="1">
        <el-dropdown style="height: 50px">
          <div class="avatar">
            <el-avatar :size="40" hape="square" :src="src" />
          </div>
          <template #dropdown>
            <el-dropdown-menu>
              <router-link to="/dashboard">
                <el-dropdown-item>首页</el-dropdown-item>
              </router-link>
              <el-dropdown-item>个人资料</el-dropdown-item>
              <el-dropdown-item @click.native="logout">退出登录</el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </el-col>
    </el-row>
  </div>
</template>

<script>
import NavBreadcrumb from '../NavBreadcrumb/index'
export default {
  name: 'NavHeader',
  components: { NavBreadcrumb },
  data() {
    return {
      collapseIcon: '',
      searchKeyword: ''
    }
  },
  computed: {
    isCollapse() {
      return this.$store.getters.isCollapse
    },
    src() {
      // TODO 头像路径
      return require('@/assets/default-avatar.jpeg')
    }
  },
  methods: {
    fold(isCollapse) {
      this.$store.dispatch('navMenu/toggleCollapse', isCollapse)
    },
    // 退出登录
    logout() {
      this.$store.dispatch('user/resetToken')
        .then(() => {
          // 清空状态路由
          this.$store.dispatch('permission/clearRouter')
          // 清空选项卡
          this.$store.dispatch('tabItem/resetTabs')
          // 跳转到登录页面,直接跳转刷新
          location.href = '/login'
        }).catch((error) => {
          console.log('logout error', error)
        })
    },
    handleSelect() {
      if (this.searchKeyword && this.searchKeyword !== '') {
        const splits = this.searchKeyword.split(':')
        if (splits.length > 1) {
          this.$router.push({ path: splits[1] })
        }
      }
    },
    // 过滤
    createFilter(queryString) {
      return item => item.value.match(queryString)
    },
    // 过滤根,只保留叶子节点, routers这个是路由数组,这里需要转换一下
    getLeaves() {
      // 路由
      const routers = this.$router.getRoutes()
      const result = []
      // 报名单
      const writelist = ['/', '/login']
      routers.forEach(router => {
        if (!router.children || router.children.length === 0 || writelist.indexOf(router.path) === -1) {
          result.push({
            value: (router.meta && router.meta.title ? router.meta.title : '') + ':' + router.path,
            title: router.meta.title
          })
        }
      })
      return result
    },
    // 关键字过滤
    querySearch: function(queryString, cb) {
      const filterRoutes = this.getLeaves()
      if (queryString && queryString !== '') {
        cb(filterRoutes.filter(this.createFilter(queryString)))
      } else {
        if (filterRoutes.length > 10) {
          cb(filterRoutes.slice(0, 10))
        } else {
          cb(filterRoutes)
        }
      }
    }
  }
}
</script>

<style scoped>
.btn-folde {
  font-size: 24px;
  border: none;
  height: 50px;
  line-height: 50px;
  padding: 0;
  width: 50px;
  padding-top: 2px;
}
.header-contrainer {
  height: 50px;
  line-height: 50px;
  padding: 0 20px;
}
.el-breadcrumb {
  line-height: 50px;
}
/* 头像 */
.avatar {
  height: 50px;
  width: 50px;
}
.avatar {
  padding: 5px;
}
.router-link-active, a {
  text-decoration: none;
}
</style>

  • src/layout/NavMenu/index.vue
<template>
  <div class="nav-menu-contrainer">
    <el-menu
      unique-opened
      class="el-menu-vertical-demo"
      :collapse="isCollapse"
      background-color="#545c64"
      text-color="#FFFFFF"
      router
      :default-active="$route.path"
    >
      <el-submenu index="1">
        <template #title>
          <i class="el-icon-alarm-clock" />
          <span>一级菜单</span>
        </template>
        <el-menu-item index="2-1">二级菜单1</el-menu-item>
        <el-menu-item index="2-2">二级菜单2</el-menu-item>
        <el-submenu index="2-3">
          <template #title>二级菜单3</template>
          <el-menu-item index="2-3-1">三级菜单1</el-menu-item>
          <el-menu-item index="2-3-2">三级菜单2</el-menu-item>
          <el-submenu index="2-3-2">
            <template #title>三级带单3</template>
            <el-menu-item index="2-3-2-1">四级菜单1</el-menu-item>
            <el-menu-item index="2-3-2-2">四级菜单2</el-menu-item>
            <el-menu-item index="2-3-2-3">四级菜单3</el-menu-item>
          </el-submenu>
        </el-submenu>
      </el-submenu>
      <el-submenu index="2">
        <template #title>
          <i class="el-icon-alarm-clock" />
          <span>Nav2</span>
        </template>
        <el-menu-item index="/system/demo/manage">nav2-1</el-menu-item>
        <el-menu-item index="/system/demo/add">nav2-2</el-menu-item>
      </el-submenu>
      <NavMenuItem v-for="router in routers" :key="router.name" :item="router" />
    </el-menu>
  </div>
</template>

<script>
import NavMenuItem from './NavMenuItem'
export default {
  name: 'NavMenu',
  components: {
    NavMenuItem
  },
  data() {
    return {}
  },
  computed: {
    isCollapse() {
      return this.$store.getters.isCollapse
    },
    routers() {
      console.log('vuex route', this.$store.getters.routers)
      return this.$store.getters.routers
    }
  }
}
</script>

<style scoped>
  .el-menu-vertical-demo:not(.el-menu--collapse) {
    width: 200px;
    min-height: 400px;
  }
  .el-aside .nav-menu-contrainer{
    height: 100%;
  }
  .el-menu {
    height: 100%;
  }

</style>

  • src/layout/NavMenu/NavMenuItem/index.vue
<template>
  <!-- 还有子菜单 -->
  <el-submenu v-if="item && item.children && item.children.length > 0" :index="parent === '' ? item.path : parent + '/' + item.path">
    <template #title>
      <i v-if="item.meta && item.meta.icon" :class="item.meta.icon" />
      <span>{{ item.meta.title }}</span>
    </template>
    <NavMenuItem v-for="temp in item.children" :key="temp.name" :item="temp" :parent="parent === '' ? item.path : parent + '/' + item.path" />
  </el-submenu>
  <!-- 显示和隐藏只针对最后一个菜单生效, hidden = true表示隐藏, 不显示 -->
  <el-menu-item
    v-else-if="item.meta.hidden === undefined || !item.meta.hidden"
    :index="parent === '' ? item.path : parent + '/' + item.path"
  >
    <template #title>
      <i v-if="item.meta.icon" :class="item.meta.icon" />
      <span>{{ item.meta.title }}</span>
    </template>
  </el-menu-item>
</template>

<script>
export default {
  name: 'NavMenuItem',
  props: {
    item: {
      required: false,
      type: Object,
      default: () => {}
    },
    // 这个表示父路劲
    parent: {
      required: false,
      type: String,
      default: '' // 这个值为空字符串表示一级路由
    },
    key: {
      required: false,
      type: String,
      default: ''
    }
  }
}
</script>

<style scoped>

</style>

  • src/layout/NavTab/index.vue
<template>
  <div class="content-contrainer">
    <el-tabs v-model="activeItem" type="card" @tab-remove="removeTab" @tab-click="tabClick">
      <el-tab-pane v-for="(item, index) in openTabs" :key="item.path" :label="item.title" :name="item.path" :closable="index !== 0">
        <router-view />
      </el-tab-pane>
    </el-tabs>
  </div>
</template>

<script>
export default {
  name: 'NavTab',
  computed: {
    openTabs() {
      return this.$store.getters.openTabs
    },
    // 选中的选项卡
    activeItem: {
      get() {
        return this.$store.getters.activeItem
      },
      set(value) {
        this.$store.dispatch('tabItem/setActive', value)
      }
    }
  },
  watch: {
    $route(to, from) {
      // 添加选项卡
      this.$store.dispatch('tabItem/addItem', { path: to.path, title: to.meta.title, name: to.name }).then(result => {
        // 跳转
        this.$router.push({ path: result })
      })
    }
  },
  mounted() {
    // 刷新时以当前路由做为tab加入tabs
    // 当前路由不是首页时,添加首页以及另一页到store里,并设置激活状态
    // 当当前路由是首页时,添加首页到store,并设置激活状态
    if (this.$route.path !== '/' && this.$route.path !== '/dashboard') {
      this.$store.dispatch('tabItem/addItem', { path: '/dashboard', title: '首页', name: 'dashboard' })
      this.$store.dispatch('tabItem/addItem', { path: this.$route.path, title: this.$route.meta.title, name: this.$route.name })
      this.$store.dispatch('tabItem/setActive', this.$route.path)
    } else {
      this.$store.dispatch('tabItem/addItem', { path: '/dashboard', title: '首页', name: 'dashboard' })
      this.$store.dispatch('tabItem/setActive', '/dashboard')
    }
  },
  methods: {
    // 删除选项卡
    removeTab(targetName) {
      // 当前路径
      const currentPage = this.$route.path
      // 删除
      this.$store.dispatch('tabItem/deleteItem', targetName).then((result) => {
        // 删除的是当前选项卡
        if (currentPage === targetName) {
          // result是删除选项卡之后返回的上一个选项卡的路径
          this.$router.push({ path: result })
        }
      })
    },
    // 切换
    tabClick() {
      this.$router.push({ path: this.activeItem })
    }
  }

}
</script>

<style>
  .content-contrainer {
    height: 100%;
    overflow-y: auto;
  }
  .content-contrainer .el-tabs--card>.el-tabs__header {
    background-color: rgb(255 255 255);
    position: absolute;
    width: calc(100% - 201px);
    opacity: 1;
    z-index: 1;
  }
  .content-contrainer .el-tabs__content {
    height: 100%;
    padding-top: 44px;
  }
</style>

  • src/store/index.js
import { createStore } from 'vuex'
import navMenu from './modules/navMenu'
import permission from './modules/permission'
import user from './modules/user'
import tabItem from './modules/tabItem'
import getters from './getters'

export default createStore({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
    navMenu,
    permission,
    user,
    tabItem
  },
  getters
})

  • src/store/getters.js

const getters = {
  isCollapse: state => state.navMenu.isCollapse, // 左侧菜单是否展开
  routers: state => state.permission.routers, // 路由
  openTabs: state => state.tabItem.openTabs, // 选项卡
  activeItem: state => state.tabItem.activeItem // 当前选中菜单和选项卡,路径
}
export default getters

  • src/modules/navMenu.js
const state = {
  isCollapse: false
}

const mutations = {
  TOGGLE_COLLAPSE: (state, isCollapse) => {
    state.isCollapse = isCollapse
  }
}

const actions = {
  toggleCollapse({ commit }, isCollapse) {
    commit('TOGGLE_COLLAPSE', isCollapse)
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

  • src/store/modules/permission.js
/*
* 权限、路由相关
*/

import Layout from '@/layout'

const state = {
  routers: []
}

const mutations = {
  // 设置路由
  SET_ROUTER: (state, routers) => {
    state.routers = routers
  }
}

const actions = {
  // 清空路由
  clearRouter: ({ commit }) => {
    commit('SET_ROUTER', [])
  },
  // 生成路由
  generateRoute({ commit }) {
    return new Promise((resolve, reject) => {
      // TODO 自定义父子树比较组装复杂,由服务端生成
      const asyncRoute = [
        {
          name: 'SystemManage', // 唯一名称,与选项卡name对应
          title: '系统管理', // 菜单标题,与选项卡标题对应@deprecated
          path: '/system', // 路径
          // redirect: '/dashboard', // 重定向路径,TODO 不需要重定向,只有最后一个路由才会调转
          // component: '../../layout', // 对应组件路径,为空表示一级路由,以都放在src下面,记得以"/"开始,TODO 这里还需要解析一次生成对应的组件
          meta: {
            hidden: false, // 是否隐藏,添加页面、编辑页面等,默认不隐藏
            icon: 'el-icon-plus', // 图标,针对一级路由生效
            title: '系统管理' // 选项卡和菜单展示名称
          },
          // 子路由
          children: [
            {
              name: 'DemoManage',
              title: '测试管理',
              path: 'demo/manage',
              component: '/demo/manage/index',
              meta: {
                title: '系统管理'
              }
            },
            {
              name: 'DemoAdd',
              title: '测试添加',
              path: 'demo/add',
              component: '/demo/add/index',
              meta: {
                hidden: true,
                title: '测试添加'
              }
            },
            {
              name: 'DemoEdit',
              title: '测试编辑',
              path: 'demo/edit',
              component: '/demo/edit/index',
              meta: {
                hidden: true,
                title: '测试编辑'
              }
            }
          ]
        },
        {
          name: 'nested',
          path: '/nested',
          meta: {
            icon: 'el-icon-plus',
            title: '一级嵌套路由'
          },
          children: [
            {
              name: 'nested1',
              path: 'nested1',
              component: '/nested/index-1',
              meta: {
                title: '一级嵌套路由1'
              },
              children: [
                {
                  name: 'nested1-1',
                  path: 'nested1-1',
                  component: '/nested/nested/index-1-1',
                  meta: {
                    title: '二级嵌套路由1'
                  }
                },
                {
                  name: 'nested1-2',
                  path: 'nested1-2',
                  component: '/nested/nested/index-1-2',
                  meta: {
                    title: '二级嵌套路由2',
                    hidden: false
                  }
                }
              ]
            },
            {
              name: 'nested2',
              path: 'nested2',
              component: '/nested/index-2',
              meta: {
                title: '一级嵌套路由2'
              }
            }
          ]
        }
      ]

      // 状态保存
      commit('SET_ROUTER', asyncRoute)
      // todo 解析
      resolve(analyseRoute(asyncRoute))
    })
  }
}

/**
 * 导入vue组件
 * @param file
 * @returns {function(): *}
 * @private
 */
function _import(file) {
  return () => import('@/views' + file + '.vue')
}

/**
 * 路由树生成路由对象,component由字符串变为组件
 * @param routes
 */
function analyseRoute(routes) {
  // 最终生成的路由
  const result = []
  // 递归
  recurrenceRoute(result, routes)
  return result
}

/**
 * 递归
 * @param result 返回结果
 * @param routes 数组
 */
export function recurrenceRoute(result, routes) {
  routes.forEach(route => {
    const temp = {
      name: route.name,
      path: route.path,
      meta: {},
      component: route.component ? _import(route.component) : Layout
    }
    if (route.meta) {
      if (route.meta.icon) {
        temp.meta.icon = route.meta.icon
      }
      if (route.meta.hidden !== undefined) {
        temp.meta.hidden = route.meta.hidden
      }
      if (route.meta.title) {
        temp.meta.title = route.meta.title
      }
    }
    if (route.children) {
      temp.children = []
      recurrenceRoute(temp.children, route.children)
    }
    result.push(temp)
  })
}

/**
 * 解析面包屑数组
 * @param routers 路由直接获取
 * @param activeMenu 当前激活的菜单,路径
 */
export function analyseBreadcreumb(routers, activeMenu) {
  // 面包屑导航
  const array = []
  while (activeMenu && activeMenu.length > 0) {
    // 查找路由
    const route = searchByActiveMenu(routers, activeMenu)
    if (route && route.meta && route.meta.title) {
      array.unshift(route.meta.title)
    }
    activeMenu = activeMenu.substr(0, activeMenu.lastIndexOf('/'))
  }
  return array
}

/**
 * 检索第一条符合条件的路由
 * @param routers
 * @param activeMenu
 */
function searchByActiveMenu(routers, activeMenu) {
  for (let i = 0; i < routers.length; i++) {
    if (routers[i].path === activeMenu) {
      return routers[i]
    }
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

  • src/store/modules/tabItem.js
/*
* 选项卡
*/
const state = {
  openTabs: [], // 选项卡已有数据,【首页】为第一条
  activeItem: '' // 当选选中的选项卡,路径
}

const mutations = {
  /**
   * 添加选项卡,如果选项卡存在(根据路径确定),则选中该选项卡,不存在则添加
   * {
   *     name: '', 名称
   *     path: '/dashboard',  # 路由访问路径, 这个绝对路径
   *     title: ''   # 标题,选项卡展示的那个
   * }
   * @param state
   * @param item
   * @constructor
   */
  ADD_ITEM: (state, item) => {
    // 已有选项卡名称
    const pathes = state.openTabs.map(tab => tab.path)
    // 判断选项卡是否已存在
    if (pathes.indexOf(item.path) === -1) {
      // 选项卡不存在,添加
      state.openTabs.push(item)
    }
    // 选中菜单
    state.activeItem = item.path
  },
  // 设置当前选中的选项卡名称
  SET_ACTIVE_ITEM: (state, activeItem) => {
    state.activeItem = activeItem
  },
  // 根据index删除选项卡
  DELETE_ITEM: (state, index) => {
    // 删除
    state.openTabs.splice(index, 1)
  },
  // 重置选项卡
  RESET_TABS: (state) => {
    state.openTabs = []
    state.activeMenu = ''
  }
}

const actions = {
  // 添加选项卡
  addItem({ commit }, item) {
    return new Promise(resolve => {
      commit('ADD_ITEM', item)
      resolve(item.path)
    })
  },
  // 设置选项卡选中
  setActive({ commit }, activeItem) {
    commit('SET_ACTIVE_ITEM', activeItem)
  },
  // 删除选项卡,根据名称删除
  deleteItem({ commit, state }, path) {
    return new Promise((resolve, reject) => {
      for (let i = 0; i < state.openTabs.length; i++) {
        if (state.openTabs[i].path === path) {
          // 删除选项卡
          commit('DELETE_ITEM', i)
          resolve(state.openTabs[i - 1].path)
        }
      }
    })
  },
  // 重置选项卡
  resetTabs({ commit }) {
    commit('RESET_TABS')
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

  • src/store/modules/user.js
/*
* 用户基本操作
*/

import { removeToken, setToken } from '../../utils/auth'

const state = {
  // 头像
  avatar: '',
  // 用户名
  username: ''
}

const mutations = {
  SET_AVATAR: (state, avatar) => {
    state.avatar = avatar
  },
  SET_USERNAME: (state, username) => {
    state.username = username
  }
}

const actions = {
  // 登录操作, username + password,登录成功获取access_token并保存在cookie里面
  login({ commit }, userinfo) {
    const { username, password } = userinfo
    return new Promise((resolve, reject) => {
      // TODO 登录查询
      console.log('login username', username)
      console.log('login password', password)
      if (username !== 'admin') {
        reject('用户名密码错误')
      } else {
        setToken('this_is_admin_token')
        console.log('设置token')
        resolve()
      }
    })
  },
  // 重置token,主要是删除cookie里面的token
  resetToken({ commit }) {
    return new Promise(resolve => {
      // 清空cookie里面token
      removeToken()
      // 清空头像
      commit('SET_AVATAR', '')
      // 清空用户名
      commit('SET_USERNAME', '')
      resolve()
    })
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

  • src/utils/auth.js
/**
 * cookie存取用户登录token
 */
import Cookies from 'js-cookie'

const accessToken = 'access_token'

export function getToken() {
  return Cookies.get(accessToken)
}

export function setToken(token) {
  return Cookies.set(accessToken, token)
}

export function removeToken() {
  return Cookies.remove(accessToken)
}

  • src/views/login/index.vue
<template>
  <div class="login-contrainer">
    <div class="login-form-contrainer">
      <div class="form-header-tip">
        <span>欢迎您</span>
      </div>
      <div class="login-form">
        <el-form ref="form" :model="form" :rules="rules" label-width="auto">
          <el-form-item prop="username">
            <el-input v-model="form.username" prefix-icon="el-icon-user" placeholder="用户名" clearable />
          </el-form-item>
          <el-form-item prop="password">
            <el-input v-model="form.password" prefix-icon="el-icon-s-goods" show-password placeholder="密码" clearable @keyup.enter="login('form')" />
          </el-form-item>
          <el-button class="btn-login" type="primary" @click.native.prevent="login('form')">登录</el-button>
        </el-form>
      </div>
    </div>
  </div>
</template>

<script>

export default {
  name: 'Login',
  data() {
    return {
      form: {
        username: 'admin',
        password: '123456'
      },
      rules: {
        username: [
          { required: true, message: '请输入用户名', trigger: 'blur' }
        ],
        password: [
          { required: true, message: '请输入密码', trigger: 'blur' }
        ]
      },
      redirect: ''
    }
  },
  created() {
    // 设置重定向路由
    this.redirect = this.getRedirectUrl(this.$route.fullPath)
  },
  methods: {
    login(forName) {
      this.$refs[forName].validate(valid => {
        if (valid) {
          this.$store.dispatch('user/login', this.form)
            .then(() => {
              // 跳转
              this.$router.push({ path: this.redirect || '/' })
            })
            .catch((error) => {
              this.$message.error(error)
            })
        } else {
          return false
        }
      })
    },
    // 解析重定向路由
    getRedirectUrl(fullPath) {
      const urlParams = fullPath.substr(fullPath.lastIndexOf('?') + 1)
      const keyPairs = urlParams.split('&')
      keyPairs.forEach(keyPair => {
        const params = keyPair.split('=')
        if (params[0] === 'redirect') {
          this.redirect = params[1]
        }
      })
      return '/dashboard'
    }
  }
}
</script>

<style scoped>
html, body, #app, .login-contrainer{
  height: 100%;
}
.login-contrainer {
  background-size: 100% 100%;
  background: url('../../assets/login-bg-img.jpeg') no-repeat;
  position: relative;
}
.login-form-contrainer {
  border-radius: 3px;
  position: absolute;
  width: 500px;
  height: 300px;
  top: 50%;
  left: 50%;
  /* 用transform向左(上)平移它自己宽度(高度)的50%,也就达到居中效果了 */
  transform: translate(-50%, -50%);
  background-color: #FFFFFF;
  box-shadow: 3px 3px 10px #888888;
}
.form-header-tip{
  font-size: 30px;
  font-weight: bold;
  text-align: center;
  height: 100px;
  line-height: 100px;
}
.login-form {
  padding: 0 30px;
}
.btn-login {
  width: 100%;
}
</style>

由于遇到的问题很多,这里就没有意义描述了,这里贴出了全部源码

有需要的联系我的163邮箱,见url地址
或者到csdn下载https://download.csdn.net/download/admin_15082037343/18785160

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

流年ln

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值