刚刚接触项目的小伙伴 几乎都接触不到这一块的 因为入职 公司要么有骨干 要么是现有项目维护 所以 对于动态菜单 很好奇 今天带着小伙伴们一起来看看吧
可能有些人接触过 只是看看别人写的代码 觉得都没有问题 没有实际动手去做过 这就应对了那句 “看花容易绣花难” 其实我开始的时候 也是这种心理 直到我自己动手的时候 发现里面有很多坑 如果不涉及到tag的切换 估计看看element官网就知道了 关键就是带有tag切换 导致很多小伙伴无从下手
现在很多公司的项目都是基于vue-element-admin二开的 虽然vue-element-admin是经典 但是毕竟时间太久了 里面也存在一些缺陷 比如项目过大 所以想弄明白的小伙伴还是老老实实的自己走一遍
先看下这个项目目录结构 因为就是个demo 所以也没有全部都建出来
首先先和小伙伴分析一下 所谓的动态路由 不完全都是动态的 其实是有两部分组成 一部分是静态的 我们称之为静态路由(constantRoutes)例如login、404等 还有一部分是根据权限后台返回的 我们才称为动态路由(asyncRoutes)
其次既然是跟路由有关的 我们肯定要用到状态存储器 vuex
先看下路由接口 我也没有模拟数据 就暂时先写死的
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
import Layout from "@/layout/index"
export const constantRoutes = [
{
path: '/login',
component: () => import('@/views/login/index'),
hidden: true
},
{
path: '/redirect',
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect/index')
}
]
}
];
export const asyncRoutes =[
{
path: '/',
component: Layout,
redirect: 'dashboard',
children: [
{
path: '/dashboard',
component: () => import('@/views/dashboard/index'),
name: 'Dashboard',
meta: { title: '首页', icon: 'dashboard', affix: true }
}
]
},
{
path: '/card',
component: Layout,
children: [
{
path: '/card/index',
component: () => import('@/views/card/index'),
name: 'card',
meta: { title: '购物车', icon: 'icon', noCache: true }
}
]
},
{
path: '/phone',
component: Layout,
meta: {
title: '数码手机'
},
children: [
{
path: '/phone/apply',
component: () => import('@/views/phone/applyPhone/index'),
name: 'apply',
meta: { title: '苹果手机', icon: 'icon', noCache: true }
},
{
path: '/phone/hw',
component: () => import('@/views/phone/hwPhone/index'),
name: 'hw',
meta: { title: '华为手机', icon: 'icon', noCache: true }
}
]
}
]
const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes: constantRoutes,
});
export default router;
main.js 其实我这不是完整的 因为没有调用接口 数据暂时写死的 所以在touter.beforeEach里面 没有做token的判断 不过不影响后续流程
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.config.productionTip = false;
Vue.use(ElementUI)
router.beforeEach(async(to, from, next) => {
if (to.path === '/login') {
next()
} else {
const hasRoutes = store.getters.routes && store.getters.routes.length > 0
if (hasRoutes) {
next()
} else {
try {
const accessRoutes = await store.dispatch('permission/generateRoutes', ["admin"])
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
} catch (error) {
next(`/login?redirect=${to.path}`)
}
}
}
})
new Vue({
router,
store,
render: (h) => h(App),
}).$mount("#app");
侧边栏
<template>
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
background-color="red"
text-color="black"
:unique-opened="false"
active-text-color="yellow"
:collapse-transition="false"
router
mode="vertical"
>
<sidebar-item v-for="route in routes" :key="route.path" :item="route" :base-path="route.path" />
</el-menu>
</el-scrollbar>
</template>
<script>
import { mapGetters } from 'vuex'
import sidebarItem from "./sidebarItem.vue"
export default {
name: '',
components:{sidebarItem},
computed: {
...mapGetters([
'routes',
]),
activeMenu() {
const route = this.$route
const { meta, path } = route
if (meta.activeMenu) {
return meta.activeMenu
}
return path
},
showLogo() {
return this.$store.state.settings.sidebarLogo
},
isCollapse() {
return !this.sidebar.opened
}
},
mounted(){
}
}
</script>
<style lang='scss' scoped>
.sidebar{
height:100vh;
width:200px;
background: red;
}
</style>
侧边栏组件
<template>
<div v-if="!item.hidden">
<template v-if="hasOneShowingChild(item.children,item)">
<el-menu-item :index="onlyOneChild.path">
<span slot="title">{
{onlyOneChild.meta.title}}</span>
</el-menu-item>
</template>
<template v-else>
<el-submenu ref="subMenu" :index="item.path" popper-append-to-body>
<template slot="title">
<span>{
{item.meta.title}}</span>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="child.path"
/>
</el-submenu>
</template>
</div>
</template>
<script>
export default {
name: 'sidebarItem',
props: {
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
},
data() {
this.onlyOneChild = null
return {}
},
mounted(){
},
methods: {
hasOneShowingChild(children = [], parent) {
const showingChildren = children.filter(item => {
if (item.hidden) {
return false
} else {
this.onlyOneChild = item
return true
}
})
if (showingChildren.length === 1) {
return true
}
if (showingChildren.length === 0) {
this.onlyOneChild = { ... parent}
return true
}
return false
}
}
}
</script>
tags
<template>
<div id="tags-view-container" class="tags-view-container">
<scroll-pane scroll-paneref="scrollPane" ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
<router-link
v-for="tag in visitedViews"
ref="tag"
:key="tag.path"
:class="isActive(tag)?'active':''"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
class="tags-view-item"
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
@contextmenu.prevent.native="openMenu(tag,$event)"
>
{
{ tag.title }}
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
</router-link>
</scroll-pane>
<ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)">Refresh</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">Close</li>
<li @click="closeOthersTags">Close Others</li>
<li @click="closeAllTags(selectedTag)">Close All</li>
</ul>
</div>
</template>
<script>
import scrollPane from "./ScrollPane.vue"
import path from "path"
export default {
name: '',
components:{scrollPane},
data(){
return{
visible:false,
top: 0,
left: 0,
selectedTag: {},
affixTags: []
}
},
watch:{
$route() {
console.log(987)
this.addTags()
this.moveToCurrentTag()
},
visible(value) {
if (value) {
document.body.addEventListener('click', this.closeMenu)
} else {
document.body.removeEventListener('click', this.closeMenu)
}
}
},
computed:{
visitedViews() {
return this.$store.state.tagsView.visitedViews
},
routes() {
return this.$store.state.permission.routes
}
},
methods:{
isActive(route) {
return route.path === this.$route.path
},
isAffix(tag) {
return tag.meta && tag.meta.affix
},
addTags() {
const { name } = this.$route
if (name) {
this.$store.dispatch('tagsView/addView', this.$route)
}
return false
},
filterAffixTags(routes,basePath = '/'){
let tags = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
const tagPath = path.resolve(basePath, route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta }
})
}
if (route.children) {
const tempTags = this.filterAffixTags(route.children, route.path)
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags]
}
}
})
return tags
},
moveToCurrentTag() {
const tags = this.$refs.tag
this.$nextTick(() => {
for (const tag of tags) {
if (tag.to.path === this.$route.path) {
this.$refs.scrollPane.moveToTarget(tag)
if (tag.to.fullPath !== this.$route.fullPath) {
this.$store.dispatch('tagsView/updateVisitedView', this.$route)
}
break
}
}
})
},
refreshSelectedTag(view) {
this.$store.dispatch('tagsView/delCachedView', view).then(() => {
const { fullPath } = view
this.$nextTick(() => {
this.$router.replace({
path: '/redirect' + fullPath
})
})
})
},
closeSelectedTag(view) {
this.$store.dispatch('tagsView/delView', view).then(({ visitedViews }) => {
if (this.isActive(view)) {
this.toLastView(visitedViews, view)
}
})
},
closeOthersTags() {
this.$router.push(this.selectedTag)
this.$store.dispatch('tagsView/delOthersViews', this.selectedTag).then(() => {
this.moveToCurrentTag()
})
},
closeAllTags(view) {
this.$store.dispatch('tagsView/delAllViews').then(({ visitedViews }) => {
if (this.affixTags.some(tag => tag.path === view.path)) {
return
}
this.toLastView(visitedViews, view)
})
},
toLastView(visitedViews, view) {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
this.$router.push(latestView.fullPath)
} else {
if (view.name === 'Dashboard') {
this.$router.replace({ path: '/redirect' + view.fullPath })
} else {
this.$router.push('/')
}
}
},
openMenu(tag, e) {
const menuMinWidth = 105
const offsetLeft = this.$el.getBoundingClientRect().left
const offsetWidth = this.$el.offsetWidth
const maxLeft = offsetWidth - menuMinWidth
const left = e.clientX + 15
if (left > maxLeft) {
this.left = offsetLeft
} else {
this.left = left
}
this.top = e.clientY
this.visible = true
this.selectedTag = tag
},
closeMenu() {
this.visible = false
},
handleScroll() {
this.closeMenu()
},
initTags() {
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
for (const tag of affixTags) {
if (tag.name) {
this.$store.dispatch('tagsView/addVisitedView', tag)
}
}
},
},
mounted(){
this.initTags()
this.addTags()
}
}
</script>
<style lang='scss' scoped>
.tags-view-container {
height: 34px;
width: 100%;
background: #fff;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
&:first-of-type {
margin-left: 15px;
}
&:last-of-type {
margin-right: 15px;
}
&.active {
background-color: #42b983;
color: #fff;
border-color: #42b983;
&::before {
content: '';
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 2px;
}
}
}
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
</style>
scrollPane组件
<template>
<el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
<slot />
</el-scrollbar>
</template>
<script>
const tagAndTagSpacing = 4 // tagAndTagSpacing
export default {
name: 'ScrollPane',
data() {
return {
left: 0
}
},
computed: {
scrollWrapper() {
return this.$refs.scrollContainer.$refs.wrap
}
},
mounted() {
this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
},
beforeDestroy() {
this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
},
methods: {
handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40
const $scrollWrapper = this.scrollWrapper
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
},
emitScroll() {
this.$emit('scroll')
},
moveToTarget(currentTag) {
const $container = this.$refs.scrollContainer.$el
const $containerWidth = $container.offsetWidth
const $scrollWrapper = this.scrollWrapper
const tagList = this.$parent.$refs.tag
let firstTag = null
let lastTag = null
if (tagList.length > 0) {
firstTag = tagList[0]
lastTag = tagList[tagList.length - 1]
}
if (firstTag === currentTag) {
$scrollWrapper.scrollLeft = 0
} else if (lastTag === currentTag) {
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
} else {
const currentIndex = tagList.findIndex(item => item === currentTag)
const prevTag = tagList[currentIndex - 1]
const nextTag = tagList[currentIndex + 1]
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
}
}
}
}
}
</script>
<style lang="scss" scoped>
.scroll-container {
white-space: nowrap;
position: relative;
overflow: hidden;
width: 100%;
::v-deep {
.el-scrollbar__bar {
bottom: 0px;
}
.el-scrollbar__wrap {
height: 49px;
}
}
}
</style>
很多方法都是从vue-element-admin拿过来用的 但是关键的vuex数据获取 存储 给简化了 具体就不展示了 想要代码的小伙伴 私信我我上传到gitBub后给地址 自己下载看吧 希望对小伙伴的提升有所帮助!