最近开发后台,因为不想使用 ElementUI 和其他现成的 UI 框架,于是决定自己做。
碰到的第一个难题就是多级菜单。
因为之前没做过,第一次做起来还是有点难的,最后实现的效果是这样。注意看地址栏。
多级菜单的收缩,展开都是使用 CSS 控制,所以要配合 Vue 传值判断是否 active
在父组件加入 activeItem
告诉子组件哪个索引是活跃的。
菜单由于考虑是多级的,所以我们需要封装成一个组件,并且需要使用组件的递归调用自身已实现多级。
在父组件中,我们可以使用这种形式来记录菜单数据。
js
1data () {
2 return {
3items: [{
4 title: 'Dashboard', // 标题
5 icon: ['fas', 'tachometer-alt'], // fontawesome icon
6 path: '/dashboard' // route path
7 },
8 {
9 title: 'Moment',
10 icon: ['far', 'clock'],
11 path: '/moments'
12 }, {
13 title: '菜单测试',
14 icon: ['fas', 'vial'],
15 path: '/moments1',
16 subItems: [{
17 title: '菜单测试 1',
18 icon: ['fas', 'vial'],
19 path: '/moments',
20 subItems: [{
21 title: '菜单测试 1 - 1',
22 icon: ['fas', 'vial'],
23 path: '/moments',
24 subItems: [{
25 title: '菜单测试 1 - 1 - 1',
26 icon: ['fas', 'vial'],
27 path: '/moments',
28 subItems: [{
29 title: '菜单测试 1 - 1 - 1 - 1',
30 icon: ['fas', 'vial'],
31 path: '/moments',
32 }]
33 }]
34 }]
35 },
36 {
37 title: '菜单测试 2',
38 icon: ['fas', 'vial'],
39 path: '/moments2',
40 }]
41 }
42 ],
43 activeItems: 0
44 }
45}
COPY
Item 是一个菜单的每一个小项。他接受来自父组件的 items 数组,然后使用 v-for 渲染每一个子菜单(不是一级菜单,是多级菜单的递归渲染)。在父组件中,也通过 v-for 渲染一级菜单。
js
1// item.vue
2
3<template>
4 <div class="row-item" :class="{active: active}" ref="row-item">
5 <div class="item" @click="handleClick">
6 <div class="icon">
7 <font-awesome-icon :icon="item.icon" />
8 </div>
9 <div class="title">{{item.title}}</div>
10 <div class="down" v-if="hasChild">
11 <font-awesome-icon :icon="['fas','chevron-down']" />
12 </div>
13 </div>
14 <!-- 这里是子菜单 如果存在子菜单才会递归自身渲染 ->
15 <div
16 class="insider"
17 :style="active ? 'max-height: '+ height : ''"
18 ref="insider"
19 v-if="hasChild"
20 >
21 <item
22 :active="activeItems === index ? true : false"
23 :item="item"
24 :index="index"
25 v-for="(item, index) in item.subItems"
26 :key="index"
27 ref="item"
28 />
29 </div>
30 </div>
31</template>
32
33
34export default {
35 name: 'item', // 用于调用自身
36 props: {
37 active: Boolean,
38 item: {
39 type: Object,
40 required: true,
41 validator (val) {
42 return typeof (val.title) === "string"
43 && val.icon instanceof Array
44 && val.icon.length !== 0
45 }
46 },
47 index: Number
48 },
49 data () {
50 return {
51 height: 0,
52 activeItems: 0,
53
54 }
55 },
56}
COPY
子菜单中判断是否活跃一样是通过上级的 activeItem
是否等于 this.index
js
1// methods
2handleClick () {
3 this.$parent.activeItems = this.index
4 if (this.$parent.activeItems === this.index) {
5
6 this.$refs['row-item'].classList.toggle('hide') // 每次点击当前活跃的菜单 如有子菜单 则切换展开和收缩
7 }
8
9 },
COPY
js
1//import item from '@/components/Admin/sidebar/item.vue'
2
3// components: {
4// item
5// },
6
7 <item
8 :active="activeItems === index ? true : false"
9 :item="item"
10 :index="index"
11 v-for="(item, index) in items"
12 :key="index"
13/>
COPY
以上步骤已经实现了对菜单加入和取消 CSS类 active
和 hide
。
接下来就只要写这两个样式就行了。
这里就不说了,菜单的收缩可以使用 max-height
属性。
到这,我已经查了很多文章,也想了很久,可能是我比较笨吧,一直没想出来。
最后,我想到了点击菜单时,先判断是不是尾菜单,就是不含子菜单的菜单,不可再下拉。
如果是,就合并上一级菜单的 path
,(注意看前面的 path
那么只要在 handleClick
的时候加一层判断和跳转就行了。
js
1// item.vue
2// handleClick(){
3 this.$parent.activeItems = this.index
4 if (this.$parent.activeItems === this.index) {
5
6 this.$refs['row-item'].classList.toggle('hide')
7 }
8 if (!this.hasChild) {
9 let path = this.item.path
10 let item = this.$parent
11 for (; ;) {
12 // path += item.path
13 if (item.item && item.item.path) {
14 path = item.item.path + path
15 item = item.$parent
16 } else break
17 }
18 // console.log(path);
19 path = this.$root.$data.route + path
20 if (path === this.$route.fullPath) {
21 return
22 }
23 this.$router.push(path)
24 }
25}
COPY
最后贴一张想了很久画了很久的手稿,字丑勿喷。
vue
1// index.vue
2<template>
3 <div class="bg">
4 <div class="wrap">
5 <div class="side-bar">
6 <div class="title">Moment</div>
7 <div class="items">
8 <item
9 :active="activeItems === index ? true : false"
10 :item="item"
11 :index="index"
12 v-for="(item, index) in items"
13 :key="index"
14 />
15 </div>
16 <div class="user">
17 <div class="block">
18 <img :src="user.avatar" />
19 <div class="username" style="transform: translateY(5px)">{{user.username}}</div>
20 <div class="dot">.</div>
21 </div>
22 </div>
23 </div>
24 <div class="content">
25 <router-view></router-view>
26 </div>
27 </div>
28 </div>
29</template>
30
31<script>
32import { mapGetters } from 'vuex'
33
34import item from '@/components/Admin/sidebar/item.vue'
35export default {
36 name: 'admin',
37 computed: {
38 ...mapGetters(['user']),
39 },
40 components: {
41 item
42 },
43 created () {
44 this.$root.$data.route = '/master'
45 },
46 beforeDestroy () {
47 this.$root.$data.route = null
48 delete this.$root.$data.route
49 },
50 data () {
51 return {
52 path: '/',
53 items: [{
54 title: 'Dashboard',
55 icon: ['fas', 'tachometer-alt'],
56 path: '/dashboard'
57 },
58 {
59 title: 'Moment',
60 icon: ['far', 'clock'],
61 path: '/moments'
62 }, {
63 title: '菜单测试',
64 icon: ['fas', 'vial'],
65 path: '/moments1',
66 subItems: [{
67 title: '菜单测试 1',
68 icon: ['fas', 'vial'],
69 path: '/moments',
70 subItems: [{
71 title: '菜单测试 1 - 1',
72 icon: ['fas', 'vial'],
73 path: '/moments',
74 subItems: [{
75 title: '菜单测试 1 - 1 - 1',
76 icon: ['fas', 'vial'],
77 path: '/moments',
78 subItems: [{
79 title: '菜单测试 1 - 1 - 1 - 1',
80 icon: ['fas', 'vial'],
81 path: '/moments',
82 }]
83 }]
84 }]
85 },
86 {
87 title: '菜单测试 2',
88 icon: ['fas', 'vial'],
89 path: '/moments2',
90 }]
91 }
92 ],
93 activeItems: 0
94 }
95 },
96}
97</script>
98
99<style lang="scss" scoped>
100@import url(https://fonts.googleapis.com/css?family=McLaren&display=swap);
101$deepBg: #1681e1;
102$shallowbg: #1a9cf3;
103.bg {
104 position: fixed;
105 top: 0;
106 left: 0;
107 bottom: 0;
108 right: 0;
109 background-color: $deepBg;
110}
111
112.wrap {
113 position: fixed;
114 top: 0;
115 left: 0;
116 right: 0;
117 bottom: 0;
118 margin: 5rem;
119 background: linear-gradient(to bottom, #1188e8, #16aae7);
120 border-radius: 24px;
121 display: grid;
122 grid-template-columns: 17% auto;
123 box-shadow: 5px 24px 133px rgba(0, 0, 0, 0.3);
124
125 .side-bar {
126 $left-margin: 1.5rem;
127 color: #fff;
128 display: grid;
129 grid-template-rows: 6rem auto 6rem;
130 overflow: hidden;
131 > .title {
132 display: flex;
133 font-family: 'Josefin Sans', sans-serif;
134 justify-content: center;
135 align-items: center;
136 font-size: 1.4rem;
137 user-select: none;
138 }
139
140 .items {
141 margin-left: $left-margin;
142 box-sizing: border-box;
143 overflow: scroll;
144 }
145
146 .user {
147 margin: $left-margin;
148 background: #13afea;
149
150 // background-clip: content-box;
151 border-radius: 12px;
152 position: relative;
153 .block {
154 max-height: 100%;
155 display: grid;
156 grid-template-columns: 50px auto 20px;
157 margin: 0.5rem;
158 user-select: none;
159 * {
160 display: flex;
161 align-items: center;
162 justify-content: center;
163 }
164 .username {
165 font-family: 'Josefin Sans', sans-serif;
166 }
167
168 img {
169 max-width: 30px;
170 border-radius: 50%;
171 }
172 }
173 }
174 }
175 .content {
176 background-color: #fff !important;
177 border-radius: 0 24px 24px 0;
178 }
179}
180</style>
COPY
vue
1// item.vue
2<template>
3 <div class="row-item" :class="{active: active}" ref="row-item">
4 <div class="item" @click="handleClick">
5 <div class="icon">
6 <font-awesome-icon :icon="item.icon" />
7 </div>
8 <div class="title">{{item.title}}</div>
9 <div class="down" v-if="hasChild">
10 <font-awesome-icon :icon="['fas','chevron-down']" />
11 </div>
12 </div>
13 <div
14 class="insider"
15 :style="active ? 'max-height: '+ height : ''"
16 ref="insider"
17 v-if="hasChild"
18 >
19 <item
20 :active="activeItems === index ? true : false"
21 :item="item"
22 :index="index"
23 v-for="(item, index) in item.subItems"
24 :key="index"
25 ref="item"
26 />
27 </div>