鄙人做服务端的,想学学前端,顺便用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单独抽出来生成一个组件,可以递归遍历树。
{{ item.meta.title }}
{{ item.meta.title }}
使用组件
这里遇到过一个bug,就是在menu-item外面包一层div,会出现收缩的时候效果不理想,F12调试会发现,左侧菜单内容与正常的菜单内容不一样。
这是个状态保存在vuex里面,左侧菜单根据这个状态展开还是搜索
这个按钮
isCollapse 直接从状态里面获得
computed: {
isCollapse() {
return this.$store.getters.isCollapse
}
},
methods: {
fold(isCollapse) {
// 更新状态
this.$store.dispatch('navMenu/toggleCollapse', isCollapse)
}
}
这里是只读的,这样简单,充当一个展示效果
{{ item }}
首页
个人资料
退出登录
这里需要注意,选项卡头部要定格在最上方,不能与内容一起滚动,这里是用css样式控制的。
代码里面备注写的很多了,应该能看懂的。
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')
}
}
}
}
{
"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"
]
}
{{ item }}
首页
个人资料
退出登录
{{ item.meta.title }}
{{ item.meta.title }}
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
})
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
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
}
/*
* 权限、路由相关
*/
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
}
/*
* 选项卡
*/
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
}
/*
* 用户基本操作
*/
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
}
/**
* 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)
}
欢迎您
登录
由于遇到的问题很多,这里就没有意义描述了,这里贴出了全部源码
有需要的联系我的163邮箱,见url地址
或者到csdn下载https://download.csdn.net/download/admin_15082037343/18785160