文章目录
前言
在开发后台管理类项目中发现,前端菜单栏路由是由后端返回的动态路由,为了更好的了解实现的过程,所以研究了一下,写一下开发的思路。项目中主要使用的技术栈是vue3 、element-plus、vite、vuex
一、项目地址
二、项目思路描述
1.后台返回一个json格式的路由表,我这里直接写死了数据,使用Promise返回,大家可参考,也可以自己造;
2.因为后端传回来的都是字符串格式的,但是前端这里需要的是一个组件对象,所以要写个方法遍历一下,将字符串转换为组件对象;
3.利用vue-router的beforeEach、addRoutes、cookie、localStorage来配合上边两步实现效果;
4.左侧菜单栏根据拿到转换好的路由列表进行展示;
三、操作步骤
3.1拦截路由
在拦截路由之前,我们还得先定义好静态路由,然后从后端取到动态路由之后,进行合并。
静态路由
静态路由主要是登录页面和重定向页面等。代码如下
import {createRouter,createWebHistory } from 'vue-router';
import Layout from '@/layout'
//静态路由
export const constantRoutes = [
{
name:"/",
path: '/',//根目录路由为/
component: Layout,//指定使用Layout组件布局
redirect: '/',//重定向至/home页面
hidden:true,
children: [{//子菜单信息
path: '/',//路径
name: 'home',
component: () => import('@/views/home'),//指定组件
meta: { title: '首页', access: 0, affix: true }
}]
},
{
path: '/login',
name: 'login',
hidden: true,
component: () => import('@/views/login')
}
];
const router =createRouter({
history:createWebHistory(),
routes:constantRoutes,
//使用浏览器的回退或者前进时,重新返回时保留页面滚动位置,跳转页面的话,不触发。
scrollBehavior(to,from,savePosition){
if(savePosition){
return savePosition;
}else{
return {top:0};
}
}
});
export default router;
后端动态路由
这个路由在实际项目中是通过后端接口返回,在日常的开发中可以根据后端格式写手动写死,前后端联调的时候换成接口就行了。
//获取后台路由(动态路由)
export function getRouters(){
return new Promise((resolve,reject)=>{
const menuList = [
{
"name": "Tool",
"path": "/tool",
// "redirect": "noRedirect",
"component": "Layout",
"alwaysShow": true,
"meta": {
"title": "系统工具",
"icon": "tool",
"noCache": false,
"link": null
},
"children": [{
"name": "Build",
"path": "build",
"component": "tool/build/index",
"meta": {
"title": "表单构建",
"icon": "build",
"noCache": false,
"link": 'build'
}
}, {
"name": "Gen",
"path": "gen",
"component": "tool/gen/index",
"meta": {
"title": "代码生成",
"icon": "code",
"noCache": false,
"link": null
}
}, {
"name": "Swagger",
"path": "swagger",
"component": "tool/swagger/index",
"meta": {
"title": "系统接口",
"icon": "swagger",
"noCache": false,
"link": null
}
}]
},
{
"name":"",
"path":"",
"hidden":false,
"redirect":"/organization",
"component":"Layout",
// "alwaysShow":"true",
"meta":{
"title":"组织管理",
"icon":"tool",
"nocache":false,
"link":null
},
"children":[
{
"name":"Organization",
"path":"organization",
"hidden":false,
"component":"organization/index",
"meta":{
"title":"组织管理",
"icon":"tool",
"nocache":false,
"link":"organization"
}
}
]
},
{
"name":"",
"path":"",
"hidden":false,
"redirect":"noRedirect",
"component":"Layout",
// "alwaysShow":"true",
"meta":{
"title":"部门管理",
"icon":"tool",
"nocache":false,
"link":''
},
"children":[
{
"name":"Department",
"path":"department",
"hidden":false,
"component":"department/index",
"meta":{
"title":"部门管理",
"icon":"tool",
"nocache":false,
"link":"department"
}
}
]
},
{
"name":"",
"path":"",
"hidden":false,
"redirect":"noRedirect",
"component":"Layout",
// "alwaysShow":"true",
"meta":{
"title":"岗位管理",
"icon":"tool",
"nocache":false,
"link":''
},
"children":[
{
"name":"Station",
"path":"station",
"hidden":false,
"component":"station/index",
"meta":{
"title":"岗位管理",
"icon":"tool",
"nocache":false,
"link":"station"
}
}
]
},
{
"name":"",
"path":"",
"hidden":false,
"redirect":"noRedirect",
"component":"Layout",
// "alwaysShow":"true",
"meta":{
"title":"应用管理",
"icon":"tool",
"nocache":false,
"link":''
},
"children":[
{
"name":"Application",
"path":"application",
"hidden":false,
"component":"application/index",
"meta":{
"title":"应用管理",
"icon":"tool",
"nocache":false,
"link":"application"
}
}
]
},
{
"name":"",
"path":"",
"hidden":false,
"redirect":"noRedirect",
"component":"Layout",
// "alwaysShow":"true",
"meta":{
"title":"菜单管理",
"icon":"tool",
"nocache":false,
"link":''
},
"children":[
{
"name":"Menulist",
"path":"menulist",
"hidden":false,
"component":"menuList/index",
"meta":{
"title":"菜单管理",
"icon":"tool",
"nocache":false,
"link":"menulist"
}
}
]
},
{
"name":"DayRecord",
"path":"/dayRecord",
"hidden":false,
"redirect":"noRedirect",
"component":"Layout",
"alwaysShow":"true",
"meta":{
"title":"日志管理",
"icon":"tool",
"nocache":false,
"link":'dayRecord'
},
"children":[
{
"name":"LoginRecord",
"path":"loginRecord",
"hidden":false,
"component":"dayRecord/loginRecord/index",
"meta":{
"title":"登录日志",
"icon":"tool",
"nocache":false,
"link":"loginRecord"
}
},
{
"name":"HandleRecord",
"path":"handleRecord",
"hidden":false,
"component":"dayRecord/handleRecord/index",
"meta":{
"title":"操作日志",
"icon":"tool",
"nocache":false,
"link":"handleRecord"
}
},
]
},
{
"name":"",
"path":"",
"hidden":false,
"redirect":"noRedirect",
"component":"Layout",
// "alwaysShow":"true",
"meta":{
"title":"应用组管理",
"icon":"tool",
"nocache":false,
"link":''
},
"children":[
{
"name":"AppGroup",
"path":"appGroup",
"hidden":false,
"component":"appGroup/index",
"meta":{
"title":"应用组管理",
"icon":"tool",
"nocache":false,
"link":"appGroup"
}
},
{
"name":"AssignUser",
"path":"assignUser/:id",
"hidden":true,
"component":"appGroup/assignUser/index",
"meta":{
title:"分配用户",
"link":"assignUser/:id"
}
}
]
},
{
"name":"",
"path":"",
"hidden":false,
"redirect":"noRedirect",
"component":"Layout",
// "alwaysShow":"true",
"meta":{
"title":"用户管理",
"icon":"tool",
"nocache":false,
"link":''
},
"children":[
{
"name":"UserList",
"path":"userList",
"hidden":false,
"component":"user/index",
"meta":{
"title":"用户管理",
"icon":"tool",
"nocache":false,
"link":"userList"
}
}
]
},
]
resolve(menuList);
})
}
3.2登录表单验证
登录页面主要涉及密码的处理,使用的加密方法是
crypto-js
然后登录之后设置对应的token到浏览器作为判断是否登录的条件,同时前端可以加一个token过期时间。
<template>
<div class="app-container">
<div class="app-content">
<div class="app-image">产业大脑项目</div>
<el-form ref="ruleFormRef" :model="ruleForm" status-icon :rules="rules" label-width="120px" class="demo-ruleForm">
<el-form-item label="用户账号" prop="username">
<el-input v-model="ruleForm.username" type="username" autocomplete="off" />
</el-form-item>
<el-form-item label="用户密码" prop="password">
<el-input v-model="ruleForm.password" type="password" autocomplete="off"/>
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="ruleForm.code" />
</el-form-item>
<el-form-item prop="remember">
<el-checkbox v-model="ruleForm.remember">记住密码</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm()">登录</el-button>
<el-button @click="resetForm()">没有注册?,去注册</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import {toRefs,getCurrentInstance,reactive} from 'vue';
import {useStore} from "vuex";
import {useRouter} from "vue-router";
import Cookies from 'js-cookie';
import {encrypt} from "../../utils/jsencrypt.js";
const store = useStore();
const router = useRouter();
const {proxy} = getCurrentInstance();
const data = reactive({
ruleForm:{
username:'',
password:"",
code:'',
uuid:1,
remember:false
},
rules:{
username: [{ required: true, trigger: "blur", message: "请输入您的账号" }],
password: [{ required: true, trigger: "blur", message: "请输入您的密码" }],
}
});
function submitForm(){
proxy.$refs['ruleFormRef'].validate(valid=>{
if(valid){
if(ruleForm.value.remember){
// Cookies.set('username',ruleForm.value.username,{exprise:30})
// Cookies.set('password',encrypt(ruleForm.value.password),{exprise:30});
// Cookies.set('remember',ruleForm.value.remember,{exprise:30});
}else{
//移除
Cookies.remove('username');
Cookies.remove('password');
Cookies.remove('remember');
}
ruleForm.value.password = encrypt(ruleForm.value.password);
//调用 store里面actions登录方法
store.dispatch('Login',ruleForm.value).then(res=>{
console.log(res);
if(res.code === 200){
router.push('/');
}else{
router.push('/login');
// proxy.resetForm('ruleFormRef');不起作用,不知道为啥。
}
});
// store.dispatch('GenerateRoutes').then(accessRoutes=>{})
}
})
}
function resetForm(){
}
const {rules,ruleForm} = toRefs(data);
</script>
<style lang="scss" scoped>
.app-container{
width: 100%;
height: 100%;
.app-content{
position: relative;
width: 30%;
height: 200px;
margin: 0 auto;
padding-top:100px;
.app-image{
position: absolute;
top: 42px;
left: 230px;
font-weight: 700;
color: blue;
}
}
}
</style>
3.3permission.js文件
该文件要引入到main.js文件中使用,
可以看到在permission中也调用了store中的处理后端路由的方法,这个跟login中的不同的点就是,登录是处理路由和用户信息来实现登录,而permission.js中则是用来使用路由拦截的方式,如果没有登录而是在地址栏直接输入对应的地址的话,可以进行拦截判断是否合法登录,获取当前的用户信息和路由权限,如果没有权限获取没有用户信息和错误就会退出登录。重新登录
import router from './router'
import store from './store'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { getToken } from '@/utils/auth'
import { isHttp } from '@/utils/validate'
NProgress.configure({ showSpinner: false });
//白名单,不进行拦截处理
const whiteList = ['/login', '/auth-redirect', '/bind', '/register'];
//路由拦截
router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && store.dispatch('setTitle', to.meta.title)
/* has token*/
if (to.path === '/login') {
console.log(111)
next({ path: '' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) {
// isRelogin.show = true
// 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(() => {
// isRelogin.show = false
store.dispatch('GenerateRoutes').then(accessRoutes => {
// 根据roles权限生成可访问的路由表
accessRoutes.forEach(route => {
if (!isHttp(route.path)) {
router.addRoute(route) // 动态添加可访问路由表
}
})
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
})
}).catch(err => {
//捕捉错误,退出登录
store.dispatch('LogOut').then(() => {
ElMessage.error(err)
next({ path: '/' })
})
})
} else {
next()
}
}
} else {
// 没有token
if (whiteList.indexOf(to.path) !== -1) {
// 在免登录白名单,直接进入
next()
} else {
next(`/login`) // 否则全部重定向到登录页
NProgress.done()
}
}
})
router.afterEach(() => {
NProgress.done()
})
3.4取后台路由并处理
处理后端路由,通过调用
vuex
里的GenerateRoutes
方法,处理对应的路由信息,因为方法稍微负责,在这里就不细说了。
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout'
// 匹配views里面所有的.vue文件
const modules = import.meta.glob('./../../views/**/*.vue')
const permission = {
state: {
routes: [],
addRoutes: [],
defaultRoutes: [],
topbarRouters: [],
sidebarRouters: []
},
mutations: {
SET_ROUTES: (state, routes) => {
state.addRoutes = routes
state.routes = constantRoutes.concat(routes)
},
SET_DEFAULT_ROUTES: (state, routes) => {
state.defaultRoutes = constantRoutes.concat(routes)
},
SET_TOPBAR_ROUTES: (state, routes) => {
state.topbarRouters = routes
},
SET_SIDEBAR_ROUTERS: (state, routes) => {
state.sidebarRouters = routes;
},
},
actions: {
// 生成路由
GenerateRoutes({ commit }) {
return new Promise(resolve => {
getRouters().then((res) => {
console.log(res);
const sdata = JSON.parse(JSON.stringify(res))
const rdata = JSON.parse(JSON.stringify(res))
const defaultData = JSON.parse(JSON.stringify(res))
const sidebarRoutes = filterAsyncRouter(sdata)
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
const defaultRoutes = filterAsyncRouter(defaultData)
commit('SET_ROUTES', rewriteRoutes)
commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes))
commit('SET_DEFAULT_ROUTES', sidebarRoutes)
commit('SET_TOPBAR_ROUTES', defaultRoutes)
resolve(rewriteRoutes);
})
})
}
}
}
// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
return asyncRouterMap.filter(route => {
if (type && route.children) {
route.children = filterChildren(route.children)
}
if (route.component) {
// Layout ParentView 组件特殊处理
if (route.component === 'Layout') {
route.component = Layout
} else if (route.component === 'ParentView') {
// route.component = ParentView
console.log('ParentView')
} else if (route.component === 'InnerLink') {
// route.component = InnerLink
console.log('InnerLink')
} else {
route.component = loadView(route.component)
}
}
if (route.children != null && route.children && route.children.length) {
route.children = filterAsyncRouter(route.children, route, type)
} else {
delete route['children']
delete route['redirect']
}
return true
})
}
function filterChildren(childrenMap, lastRouter = false) {
var children = []
childrenMap.forEach((el, index) => {
if (el.children && el.children.length) {
if (el.component === 'ParentView' && !lastRouter) {
el.children.forEach(c => {
c.path = el.path + '/' + c.path
if (c.children && c.children.length) {
children = children.concat(filterChildren(c.children, c))
return
}
children.push(c)
})
return
}
}
if (lastRouter) {
el.path = lastRouter.path + '/' + el.path
}
children = children.concat(el)
})
return children
}
//这一步是取出来view里面的文件找到对应文件的懒加载函数,并执行。
export const loadView = (view) => {
let res;
for (const path in modules) {
const dir = path.split('views/')[1].split('.vue')[0];
if (dir === view) {
res = () => modules[path]();
}
}
return res;
}
export default permission
3.5 layout布局
layout布局是侧边栏菜单显示的重要的一步。
整体布局
//路径:src/layout/index.vue
<template>
<el-container class="app-wrapper" >
<el-aside :width="asideWidth" class="sidebar-container">
<div style="text-align:center;margin-top: 15px;color: #FFFFFF;width: 100%;height: 80px">
<el-icon><Avatar /></el-icon><div v-if="$store.getters.sidebarType" style="margin-left: 10px">后台管理系统</div>
</div>
<Menu />
</el-aside>
<el-container class="container" :class="{hidderContainer:!$store.getters.sidebarType}">
<el-header class="header-container">
<Header/>
</el-header>
<el-main style="background: #fff;margin: 0 15px">
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import Menu from './Menu'
import {computed, ref} from 'vue'
import Header from './header/index'
import {useStore} from "vuex";
const store = useStore();
const asideWidth = computed(()=>{
return store.getters.sidebarType === true? '180px' : '67px'
})
</script>
<style lang="scss" scoped>
.app-wrapper{
width: 100vw;
height:100vh;
background: #f5f5f5;
margin: unset;
}
.app-container {
position: relative;
width: 100%;
height: 100%;
}
.container {
width: calc(100% - $sideBarWidth);
height: 100%;
position: fixed;
top: 0;
right: 0;
z-index: 9;
transition: all 0.28s;
.header-container{
height: 50px;
line-height: 50px;
background: #fff;
margin-bottom: 15px;
padding: 0 10px;
::v-deep .el-breadcrumb{
line-height: unset;
}
}
&.hidderContainer {
width: calc(100% - $hideSideBarWidth);
}
}
::v-deep .el-header {
padding: 0;
}
::v-deep .el-sub-menu .el-menu-item{
min-width: unset;
}
.el-aside {
height: 100vh;
overflow-y: auto;
-ms-overflow-style: none; /* Edge */
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none; /* WebKit */
}
}
</style>
菜单栏
//路径:src/layout/menu.vue
<template>
<el-menu
active-text-color="#ffd04b"
background-color="#545c64"
class="el-menu-vertical-demo"
:default-active="defaultRouter"
text-color="#fff"
router
unique-opened
:collapse="!$store.getters.sidebarType"
>
<el-sub-menu :index="(index+1).toString()" v-for="(item,index) in menusList" :key="index">
<template #title>
<el-icon>
<component :is="iconList[index]"></component>
</el-icon>
<span>{{ item.meta.title }}</span>
</template>
<el-menu-item
:index= "item.path + '/' + it.path"
v-for="(it,index) in item.children"
:key="index"
@click="savePath(item.path,it.path)"
>
<template #title>
<el-icon>
<component :is="icon[index]"></component>
</el-icon>
<span>{{ it.meta.title }}</span>
</template>
</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { getRouters } from '@/api/menu'
import { ref } from 'vue'
const iconList = ref(['user','setting','shop','tickets','pie-chart','Bell','checked','chicken','coin']);
const icon = ref(['menu','Edit','Files','folder','fold']);
const defaultRouter = ref(sessionStorage.getItem('path')|| '/tool/build');
const menusList = ref([]);
const initMenusList = async () => {
menusList.value = await getRouters()
}
function savePath(x,y){
console.log(x,y);
sessionStorage.setItem('path',`${x}/${y}`);
}
initMenusList()
</script>
<style lang="scss" scoped></style>
头部实现
//路径:src/layout/header/index
<template>
<div class="nav">
<hamburger/>
<bread-crumb/>
<div class="nav-right">
<avatar/>
</div>
</div>
</template>
<script setup>
import Hamburger from './components/hamburger'
import BreadCrumb from "./components/breadCrumb";
import avatar from './components/avatar'
// 自定义图标
</script>
<style lang="scss" scoped>
.nav{
height: 50px;
line-height: 50px;
display: flex;
align-items: center;
justify-content: center;
.nav-right{
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
</style>
3.6其他功能
其他功能可以从
github
项目中下载,查看具体的代码。