Appearance
右键菜单 #
通过右键选择 关闭所有、 关闭其他、 关闭当前、刷新 对于 tag affix 为 true 的固定 tag 是不允许关闭删除的
效果图 右键关闭所有
自动切换到 dashboard 因为它是固定 tag
右键关闭其他
当前 tag 以及 affix 为 true 的 tag 是不能关闭,并且自动切换到当前右键的 tag
关闭后
关闭当前右键选中 tag
affix 为 true 的 tag 是不能关闭的
关闭后
右键刷新
刷新后 input 内容也没有了
2-1 修改 tagsView 组件 #
添加下拉菜单 #
需要使用 element dropdown 组件,给每一个 tag 添加。
src/layout/components/TagsView/index.vue
添加右键事件 #
右键菜单关闭所有事件
定义枚举类型
src/layout/components/TagsView/index.vue
vue
<template>
<div class="tags-view-container">
<scroll-panel>
<div class="tags-view-wrapper">
<router-link
class="tags-view-item"
:class="{
active: isActive(tag)
}"
v-for="(tag, index) in visitedTags"
:key="index"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
>
<el-dropdown
trigger="contextmenu"
@command="command => handleTagCommand(command, tag)">
<span>
{{ tag.meta.title }}
<!-- affix固定的路由tag是无法删除 -->
<span
v-if="!isAffix(tag)"
class="el-icon-close"
@click.prevent.stop="closeSelectedTag(tag)"
></span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="all">关闭所有</el-dropdown-item>
<el-dropdown-item command="other">关闭其他</el-dropdown-item>
<el-dropdown-item command="self">关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</router-link>
</div>
</scroll-panel>
</div>
</template>
<script lang="ts">
import path from 'path'
import { defineComponent, computed, watch, onMounted } from 'vue'
import { useRoute, RouteRecordRaw, useRouter } from 'vue-router'
import { useStore } from '@/store'
import { RouteLocationWithFullPath } from '@/store/modules/tagsView'
import { routes } from '@/router'
import ScrollPanel from './ScrollPanel.vue'
// 右键菜单
enum TagCommandType {
All = 'all',
Other = 'other',
Self = 'self',
}
export default defineComponent({
name: 'TagsView',
components: {
ScrollPanel
},
setup() {
const store = useStore()
const router = useRouter()
const route = useRoute()
// 可显示的tags view
const visitedTags = computed(() => store.state.tagsView.visitedViews)
// 从路由表中过滤出要affixed tagviews
const fillterAffixTags = (routes: Array<RouteLocationWithFullPath | RouteRecordRaw>, basePath = '/') => {
let tags: RouteLocationWithFullPath[] = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
// 把路由路径解析成完整路径,路由可能是相对路径
const tagPath = path.resolve(basePath, route.path)
tags.push({
name: route.name,
path: tagPath,
fullPath: tagPath,
meta: { ...route.meta }
} as RouteLocationWithFullPath)
}
// 深度优先遍历 子路由(子路由路径可能相对于route.path父路由路径)
if (route.children) {
const childTags = fillterAffixTags(route.children, route.path)
if (childTags.length) {
tags = [...tags, ...childTags]
}
}
})
return tags
}
// 初始添加affix的tag
const initTags = () => {
const affixTags = fillterAffixTags(routes)
for (const tag of affixTags) {
if (tag.name) {
store.dispatch('tagsView/addVisitedView', tag)
}
}
}
// 添加tag
const addTags = () => {
const { name } = route
if (name) {
store.dispatch('tagsView/addView', route)
}
}
// 路径发生变化追加tags view
watch(() => route.path, () => {
addTags()
})
// 最近当前router到tags view
onMounted(() => {
initTags()
addTags()
})
// 当前是否是激活的tag
const isActive = (tag: RouteRecordRaw) => {
return tag.path === route.path
}
// 让删除后tags view集合中最后一个为选中状态
const toLastView = (visitedViews: RouteLocationWithFullPath[], view: RouteLocationWithFullPath) => {
// 得到集合中最后一个项tag view 可能没有
const lastView = visitedViews[visitedViews.length - 1]
if (lastView) {
router.push(lastView.fullPath as string)
} else { // 集合中都没有tag view时
// 如果刚刚删除的正是Dashboard 就重定向回Dashboard(首页)
if (view.name === 'Dashboard') {
router.replace({ path: '/redirect' + view.fullPath as string })
} else {
// tag都没有了 删除的也不是Dashboard 只能跳转首页
router.push('/')
}
}
}
// 关闭当前右键的tag路由
const closeSelectedTag = (view: RouteLocationWithFullPath) => {
// 关掉并移除view
store.dispatch('tagsView/delView', view).then(() => {
// 如果移除的view是当前选中状态view, 就让删除后的集合中最后一个tag view为选中态
if (isActive(view)) {
toLastView(visitedTags.value, view)
}
})
}
// 是否是始终固定在tagsview上的tag
const isAffix = (tag: RouteLocationWithFullPath) => {
return tag.meta && tag.meta.affix
}
// 右键菜单
const handleTagCommand = (command: TagCommandType, view: RouteLocationWithFullPath) => {
switch (command) {
case TagCommandType.All:
handleCloseAllTag(view)
}
}
const handleCloseAllTag = (view: RouteLocationWithFullPath) => {
// 对于是affix的tag是不会被删除的
store.dispatch('tagsView/delAllView').then(() => {
// 关闭所有后 就让切换到剩下affix中最后一个tag
toLastView(visitedTags.value, view)
})
}
return {
visitedTags,
isActive,
closeSelectedTag,
isAffix,
handleTagCommand
}
}
})
</script>
<style lang="scss" scoped>
.tags-view-container {
height: 34px;
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);
overflow: hidden;
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
background: #fff;
color: #495060;
padding: 0 8px;
box-sizing: border-box;
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;
::v-deep {
.el-dropdown {
color: #fff;
}
}
&::before {
position: relative;
display: inline-block;
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
background: #fff;
}
}
}
}
}
</style>
<style lang="scss">
.tags-view-container {
.el-icon-close {
width: 16px;
height: 16px;
position: relative;
left: 2px;
border-radius: 50%;
text-align: center;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(.6);
display: inline-block;
vertical-align: -1px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
修改 store #
我们的 tag 列表和缓存列表都在 store 所有对于它们的增删改查需要 调用 action mutaions 来删除
src/store/modules/tagsView.ts
typescript
import { Module, ActionTree, MutationTree } from 'vuex'
import { RouteRecordRaw, RouteRecordNormalized, RouteRecordName } from 'vue-router'
import { IRootState } from '@/store'
// 携带fullPath
export interface RouteLocationWithFullPath extends RouteRecordNormalized {
fullPath?: string;
}
export interface ITagsViewState {
// 存放当前显示的tags view集合
visitedViews: RouteLocationWithFullPath[];
// 根据路由name缓存集合
cachedViews: RouteRecordName[];
}
// 定义mutations
const mutations: MutationTree<ITagsViewState> = {
// 添加可显示tags view
ADD_VISITED_VIEW(state, view) {
// 过滤去重
if (state.visitedViews.some(v => v.path === view.path)) return
// 没有titles时处理
state.visitedViews.push(Object.assign({}, view, {
title: view.meta.title || 'tag-name'
}))
},
// 如果路由meta.noCache没有 默认或为false代表进行缓存,为true不缓存
// 默认缓存所有路由
ADD_CACHED_VIEW(state, view) {
// 只有路由有name才可缓存集合keep-alive inludes使用
if (state.cachedViews.includes(view.name)) return
if (!view.meta.noCache) {
state.cachedViews.push(view.name)
}
},
DEL_VISITED_VIEW(state, view) {
const i = state.visitedViews.indexOf(view)
if (i > -1) {
state.visitedViews.splice(i, 1)
}
},
// 可删除指定的一个view缓存
DEL_CACHED_VIEW(state, view) {
const index = state.cachedViews.indexOf(view.name)
index > -1 && state.cachedViews.splice(index, 1)
},
// 清空可显示列表
DEL_ALL_VISITED_VIEWS(state) {
// 对于affix为true的路由 tag view 是不能删除的
const affixTags = state.visitedViews.filter(tag => tag.meta.affix)
state.visitedViews = affixTags
},
// 清空缓存列表
DEL_ALL_CACHED_VIEWS(state) {
state.cachedViews = []
}
}
// 定义actions
const actions: ActionTree<ITagsViewState, IRootState> = {
// 添加tags view
addView({ dispatch }, view: RouteRecordRaw) {
// 添加tag时也要判断该tag是否需要缓存
dispatch('addVisitedView', view)
dispatch('addCachedView', view)
},
// 添加可显示的tags view 添加前commit里需要进行去重过滤
addVisitedView({ commit }, view: RouteRecordRaw) {
commit('ADD_VISITED_VIEW', view)
},
// 添加可缓存的标签tag
addCachedView({ commit }, view: RouteRecordRaw) {
commit('ADD_CACHED_VIEW', view)
},
// 删除指定tags view
delView({ dispatch }, view: RouteRecordRaw) {
return new Promise(resolve => {
// 删除显示的路由tag
dispatch('delVisitedView', view)
// 删除缓存的路由
dispatch('delCachedView', view)
resolve(null)
})
},
// 从可显示的集合中 删除tags view
delVisitedView({ commit }, view: RouteRecordRaw) {
commit('DEL_VISITED_VIEW', view)
},
// 从缓存列表删除指定tag view
delCachedView({ commit }, view: RouteRecordRaw) {
return new Promise(resolve => {
commit('DEL_CACHED_VIEW', view)
resolve(null)
})
},
// 清空 可显示列表 和 缓存列表
delAllView({ dispatch }) {
return new Promise(resolve => {
// 删除显示的路由tag
dispatch('delAllVisitedView')
// 删除缓存的路由
dispatch('delAllCachedViews')
resolve(null)
})
},
// 清空可显示列表
delAllVisitedView({ commit }) {
commit('DEL_ALL_VISITED_VIEWS')
},
// 清空缓存列表
delAllCachedViews({ commit }) {
commit('DEL_ALL_CACHED_VIEWS')
}
}
const tagsView: Module<ITagsViewState, IRootState> = {
namespaced: true,
state: {
visitedViews: [],
cachedViews: []
},
mutations,
actions
}
export default tagsView
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
2-2 右键关闭其他和关闭 #
右键关闭其他 除了 affix tag 和 当前右键 tag 右键关闭 是处理 affix tag 下拉菜单不会显示此项
修改 tagsview #
src/layout/components/TagsView/index.vue
src/layout/components/TagsView/index.vue
vue
<template>
<div class="tags-view-container">
<scroll-panel>
<div class="tags-view-wrapper">
<router-link
class="tags-view-item"
:class="{
active: isActive(tag)
}"
v-for="(tag, index) in visitedTags"
:key="index"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
>
<el-dropdown
trigger="contextmenu"
@command="command => handleTagCommand(command, tag)">
<span>
{{ tag.meta.title }}
<!-- affix固定的路由tag是无法删除 -->
<span
v-if="!isAffix(tag)"
class="el-icon-close"
@click.prevent.stop="closeSelectedTag(tag)"
></span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="all">关闭所有</el-dropdown-item>
<el-dropdown-item command="other">关闭其他</el-dropdown-item>
<el-dropdown-item command="self" v-if="!tag.meta || !tag.meta.affix">关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</router-link>
</div>
</scroll-panel>
</div>
</template>
<script lang="ts">
import path from 'path'
import { defineComponent, computed, watch, onMounted } from 'vue'
import { useRoute, RouteRecordRaw, useRouter } from 'vue-router'
import { useStore } from '@/store'
import { RouteLocationWithFullPath } from '@/store/modules/tagsView'
import { routes } from '@/router'
import ScrollPanel from './ScrollPanel.vue'
// 右键菜单
enum TagCommandType {
All = 'all',
Other = 'other',
Self = 'self',
}
export default defineComponent({
name: 'TagsView',
components: {
ScrollPanel
},
setup() {
const store = useStore()
const router = useRouter()
const route = useRoute()
// 可显示的tags view
const visitedTags = computed(() => store.state.tagsView.visitedViews)
// 从路由表中过滤出要affixed tagviews
const fillterAffixTags = (routes: Array<RouteLocationWithFullPath | RouteRecordRaw>, basePath = '/') => {
let tags: RouteLocationWithFullPath[] = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
// 把路由路径解析成完整路径,路由可能是相对路径
const tagPath = path.resolve(basePath, route.path)
tags.push({
name: route.name,
path: tagPath,
fullPath: tagPath,
meta: { ...route.meta }
} as RouteLocationWithFullPath)
}
// 深度优先遍历 子路由(子路由路径可能相对于route.path父路由路径)
if (route.children) {
const childTags = fillterAffixTags(route.children, route.path)
if (childTags.length) {
tags = [...tags, ...childTags]
}
}
})
return tags
}
// 初始添加affix的tag
const initTags = () => {
const affixTags = fillterAffixTags(routes)
for (const tag of affixTags) {
if (tag.name) {
store.dispatch('tagsView/addVisitedView', tag)
}
}
}
// 添加tag
const addTags = () => {
const { name } = route
if (name) {
store.dispatch('tagsView/addView', route)
}
}
// 路径发生变化追加tags view
watch(() => route.path, () => {
addTags()
})
// 最近当前router到tags view
onMounted(() => {
initTags()
addTags()
})
// 当前是否是激活的tag
const isActive = (tag: RouteRecordRaw) => {
return tag.path === route.path
}
// 让删除后tags view集合中最后一个为选中状态
const toLastView = (visitedViews: RouteLocationWithFullPath[], view: RouteLocationWithFullPath) => {
// 得到集合中最后一个项tag view 可能没有
const lastView = visitedViews[visitedViews.length - 1]
if (lastView) {
router.push(lastView.fullPath as string)
} else { // 集合中都没有tag view时
// 如果刚刚删除的正是Dashboard 就重定向回Dashboard(首页)
if (view.name === 'Dashboard') {
router.replace({ path: '/redirect' + view.fullPath as string })
} else {
// tag都没有了 删除的也不是Dashboard 只能跳转首页
router.push('/')
}
}
}
// 关闭当前右键的tag路由
const closeSelectedTag = (view: RouteLocationWithFullPath) => {
// 关掉并移除view
store.dispatch('tagsView/delView', view).then(() => {
// 如果移除的view是当前选中状态view, 就让删除后的集合中最后一个tag view为选中态
if (isActive(view)) {
toLastView(visitedTags.value, view)
}
})
}
// 是否是始终固定在tagsview上的tag
const isAffix = (tag: RouteLocationWithFullPath) => {
return tag.meta && tag.meta.affix
}
// 右键菜单
const handleTagCommand = (command: TagCommandType, view: RouteLocationWithFullPath) => {
switch (command) {
case TagCommandType.All: // 右键删除标签导航所有tag 除了affix为true的
handleCloseAllTag(view)
break
case TagCommandType.Other: // 关闭其他tag 除了affix为true的和当前右键的tag
handleCloseOtherTag(view)
break
case TagCommandType.Self: // 关闭当前右键的tag affix为true的tag下拉菜单中无此项
closeSelectedTag(view)
}
}
// 删除所有tag 除了affix为true的
const handleCloseAllTag = (view: RouteLocationWithFullPath) => {
// 对于是affix的tag是不会被删除的
store.dispatch('tagsView/delAllView').then(() => {
// 关闭所有后 就让切换到剩下affix中最后一个tag
toLastView(visitedTags.value, view)
})
}
// 删除其他tag 除了当前右键的tag
const handleCloseOtherTag = (view: RouteLocationWithFullPath) => {
store.dispatch('tagsView/delOthersViews', view).then(() => {
if (!isActive(view)) { // 删除其他tag后 让该view路由激活
router.push(view.path)
}
})
}
return {
visitedTags,
isActive,
closeSelectedTag,
isAffix,
handleTagCommand
}
}
})
</script>
<style lang="scss" scoped>
.tags-view-container {
height: 34px;
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);
overflow: hidden;
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
background: #fff;
color: #495060;
padding: 0 8px;
box-sizing: border-box;
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;
::v-deep {
.el-dropdown {
color: #fff;
}
}
&::before {
position: relative;
display: inline-block;
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
background: #fff;
}
}
}
}
}
</style>
<style lang="scss">
.tags-view-container {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
store 里添加相关 action 和 mutation #
src/store/modules/tagsView.ts
typescript
import { Module, ActionTree, MutationTree } from 'vuex'
import { RouteRecordRaw, RouteRecordNormalized, RouteRecordName } from 'vue-router'
import { IRootState } from '@/store'
// 携带fullPath
export interface RouteLocationWithFullPath extends RouteRecordNormalized {
fullPath?: string;
}
export interface ITagsViewState {
// 存放当前显示的tags view集合
visitedViews: RouteLocationWithFullPath[];
// 根据路由name缓存集合
cachedViews: RouteRecordName[];
}
// 定义mutations
const mutations: MutationTree<ITagsViewState> = {
// 添加可显示tags view
ADD_VISITED_VIEW(state, view) {
// 过滤去重
if (state.visitedViews.some(v => v.path === view.path)) return
// 没有titles时处理
state.visitedViews.push(Object.assign({}, view, {
title: view.meta.title || 'tag-name'
}))
},
// 如果路由meta.noCache没有 默认或为false代表进行缓存,为true不缓存
// 默认缓存所有路由
ADD_CACHED_VIEW(state, view) {
// 只有路由有name才可缓存集合keep-alive inludes使用
if (state.cachedViews.includes(view.name)) return
if (!view.meta.noCache) {
state.cachedViews.push(view.name)
}
},
DEL_VISITED_VIEW(state, view) {
const i = state.visitedViews.indexOf(view)
if (i > -1) {
state.visitedViews.splice(i, 1)
}
},
// 可删除指定的一个view缓存
DEL_CACHED_VIEW(state, view) {
const index = state.cachedViews.indexOf(view.name)
index > -1 && state.cachedViews.splice(index, 1)
},
// 清空可显示列表
DEL_ALL_VISITED_VIEWS(state) {
// 对于affix为true的路由 tag view 是不能删除的
const affixTags = state.visitedViews.filter(tag => tag.meta.affix)
state.visitedViews = affixTags
},
// 清空缓存列表
DEL_ALL_CACHED_VIEWS(state) {
state.cachedViews = []
},
// 删除标签导航其他可显示tag 除了 affix为true 以及当前右键选中的view
DEL_OTHERS_VISITED_VIEWS(state, view: RouteRecordRaw) {
state.visitedViews = state.visitedViews.filter(tag => tag.meta.affix || (tag.path === view.path))
},
// 删除缓存列表里其他tag 除了当前右键选中的view
DEL_OTHERS_CACHED_VIEWS(state, view: RouteRecordRaw) {
state.cachedViews = state.cachedViews.filter(name => name !== view.name)
}
}
// 定义actions
const actions: ActionTree<ITagsViewState, IRootState> = {
// 添加tags view
addView({ dispatch }, view: RouteRecordRaw) {
// 添加tag时也要判断该tag是否需要缓存
dispatch('addVisitedView', view)
dispatch('addCachedView', view)
},
// 添加可显示的tags view 添加前commit里需要进行去重过滤
addVisitedView({ commit }, view: RouteRecordRaw) {
commit('ADD_VISITED_VIEW', view)
},
// 添加可缓存的标签tag
addCachedView({ commit }, view: RouteRecordRaw) {
commit('ADD_CACHED_VIEW', view)
},
// 删除指定tags view
delView({ dispatch }, view: RouteRecordRaw) {
return new Promise(resolve => {
// 删除显示的路由tag
dispatch('delVisitedView', view)
// 删除缓存的路由
dispatch('delCachedView', view)
resolve(null)
})
},
// 从可显示的集合中 删除tags view
delVisitedView({ commit }, view: RouteRecordRaw) {
commit('DEL_VISITED_VIEW', view)
},
// 从缓存列表删除指定tag view
delCachedView({ commit }, view: RouteRecordRaw) {
return new Promise(resolve => {
commit('DEL_CACHED_VIEW', view)
resolve(null)
})
},
// 清空 可显示列表 和 缓存列表
delAllView({ dispatch }) {
return new Promise(resolve => {
// 删除显示的路由tag
dispatch('delAllVisitedView')
// 删除缓存的路由
dispatch('delAllCachedViews')
resolve(null)
})
},
// 清空可显示列表
delAllVisitedView({ commit }) {
commit('DEL_ALL_VISITED_VIEWS')
},
// 清空缓存列表
delAllCachedViews({ commit }) {
commit('DEL_ALL_CACHED_VIEWS')
},
// 关闭其他tag
delOthersViews({ dispatch }, view: RouteRecordRaw) {
dispatch('delOthersVisitedViews', view)
dispatch('delOthersCachedViews', view)
},
// 关闭其他可显示tag
delOthersVisitedViews({ commit }, view: RouteRecordRaw) {
commit('DEL_OTHERS_VISITED_VIEWS', view)
},
// 关闭其他缓存tag
delOthersCachedViews({ commit }, view: RouteRecordRaw) {
commit('DEL_OTHERS_CACHED_VIEWS', view)
}
}
const tagsView: Module<ITagsViewState, IRootState> = {
namespaced: true,
state: {
visitedViews: [],
cachedViews: []
},
mutations,
actions
}
export default tagsView
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
2-3 右键刷新 #
vue
<template>
<div class="tags-view-container">
<scroll-panel>
<div class="tags-view-wrapper">
<router-link
class="tags-view-item"
:class="{
active: isActive(tag)
}"
v-for="(tag, index) in visitedTags"
:key="index"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
>
<el-dropdown
trigger="contextmenu"
@command="command => handleTagCommand(command, tag)">
<span>
{{ tag.meta.title }}
<!-- affix固定的路由tag是无法删除 -->
<span
v-if="!isAffix(tag)"
class="el-icon-close"
@click.prevent.stop="closeSelectedTag(tag)"
></span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="all">关闭所有</el-dropdown-item>
<el-dropdown-item command="other">关闭其他</el-dropdown-item>
<el-dropdown-item command="self" v-if="!tag.meta || !tag.meta.affix">关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</router-link>
</div>
</scroll-panel>
</div>
</template>
<script lang="ts">
import path from 'path'
import { defineComponent, computed, watch, onMounted, nextTick } from 'vue'
import { useRoute, RouteRecordRaw, useRouter } from 'vue-router'
import { useStore } from '@/store'
import { RouteLocationWithFullPath } from '@/store/modules/tagsView'
import { routes } from '@/router'
import ScrollPanel from './ScrollPanel.vue'
// 右键菜单
enum TagCommandType {
All = 'all',
Other = 'other',
Self = 'self',
Refresh = 'refresh'
}
export default defineComponent({
name: 'TagsView',
components: {
ScrollPanel
},
setup() {
const store = useStore()
const router = useRouter()
const route = useRoute()
// 可显示的tags view
const visitedTags = computed(() => store.state.tagsView.visitedViews)
// 从路由表中过滤出要affixed tagviews
const fillterAffixTags = (routes: Array<RouteLocationWithFullPath | RouteRecordRaw>, basePath = '/') => {
let tags: RouteLocationWithFullPath[] = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
// 把路由路径解析成完整路径,路由可能是相对路径
const tagPath = path.resolve(basePath, route.path)
tags.push({
name: route.name,
path: tagPath,
fullPath: tagPath,
meta: { ...route.meta }
} as RouteLocationWithFullPath)
}
// 深度优先遍历 子路由(子路由路径可能相对于route.path父路由路径)
if (route.children) {
const childTags = fillterAffixTags(route.children, route.path)
if (childTags.length) {
tags = [...tags, ...childTags]
}
}
})
return tags
}
// 初始添加affix的tag
const initTags = () => {
const affixTags = fillterAffixTags(routes)
for (const tag of affixTags) {
if (tag.name) {
store.dispatch('tagsView/addVisitedView', tag)
}
}
}
// 添加tag
const addTags = () => {
const { name } = route
if (name) {
store.dispatch('tagsView/addView', route)
}
}
// 路径发生变化追加tags view
watch(() => route.path, () => {
addTags()
})
// 最近当前router到tags view
onMounted(() => {
initTags()
addTags()
})
// 当前是否是激活的tag
const isActive = (tag: RouteRecordRaw) => {
return tag.path === route.path
}
// 让删除后tags view集合中最后一个为选中状态
const toLastView = (visitedViews: RouteLocationWithFullPath[], view: RouteLocationWithFullPath) => {
// 得到集合中最后一个项tag view 可能没有
const lastView = visitedViews[visitedViews.length - 1]
if (lastView) {
router.push(lastView.fullPath as string)
} else { // 集合中都没有tag view时
// 如果刚刚删除的正是Dashboard 就重定向回Dashboard(首页)
if (view.name === 'Dashboard') {
router.replace({ path: '/redirect' + view.fullPath as string })
} else {
// tag都没有了 删除的也不是Dashboard 只能跳转首页
router.push('/')
}
}
}
// 关闭当前右键的tag路由
const closeSelectedTag = (view: RouteLocationWithFullPath) => {
// 关掉并移除view
store.dispatch('tagsView/delView', view).then(() => {
// 如果移除的view是当前选中状态view, 就让删除后的集合中最后一个tag view为选中态
if (isActive(view)) {
toLastView(visitedTags.value, view)
}
})
}
// 是否是始终固定在tagsview上的tag
const isAffix = (tag: RouteLocationWithFullPath) => {
return tag.meta && tag.meta.affix
}
// 右键菜单
const handleTagCommand = (command: TagCommandType, view: RouteLocationWithFullPath) => {
switch (command) {
case TagCommandType.All: // 右键删除标签导航所有tag 除了affix为true的
handleCloseAllTag(view)
break
case TagCommandType.Other: // 关闭其他tag 除了affix为true的和当前右键的tag
handleCloseOtherTag(view)
break
case TagCommandType.Self: // 关闭当前右键的tag affix为true的tag下拉菜单中无此项
closeSelectedTag(view)
break
case TagCommandType.Refresh: // 刷新当前右键选中tag对应的路由
refreshSelectedTag(view)
}
}
// 删除所有tag 除了affix为true的
const handleCloseAllTag = (view: RouteLocationWithFullPath) => {
// 对于是affix的tag是不会被删除的
store.dispatch('tagsView/delAllView').then(() => {
// 关闭所有后 就让切换到剩下affix中最后一个tag
toLastView(visitedTags.value, view)
})
}
// 删除其他tag 除了当前右键的tag
const handleCloseOtherTag = (view: RouteLocationWithFullPath) => {
store.dispatch('tagsView/delOthersViews', view).then(() => {
if (!isActive(view)) { // 删除其他tag后 让该view路由激活
router.push(view.path)
}
})
}
// 右键刷新 清空当前对应路由缓存
const refreshSelectedTag = (view: RouteLocationWithFullPath) => {
// 刷新前 将该路由名称从缓存列表中移除
store.dispatch('tagsView/delCachedView', view).then(() => {
const { fullPath } = view
nextTick(() => {
router.replace('/redirect' + fullPath)
})
})
}
return {
visitedTags,
isActive,
closeSelectedTag,
isAffix,
handleTagCommand
}
}
})
</script>
<style lang="scss" scoped>
.tags-view-container {
height: 34px;
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);
overflow: hidden;
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
background: #fff;
color: #495060;
padding: 0 8px;
box-sizing: border-box;
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;
::v-deep {
.el-dropdown {
color: #fff;
}
}
&::before {
position: relative;
display: inline-block;
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
background: #fff;
}
}
}
}
}
</style>
<style lang="scss">
.tags-view-container {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
参考本节源码 #
右键关闭所有 https://gitee.com/brolly/vue3-element-admin/commit/293d91875e315cec0b489421f1404e05d77f8a70 右键关闭其他 https://gitee.com/brolly/vue3-element-admin/commit/2246c3d9e7d7b56d4ad8959e45f177652a6f3cce 右键刷新 https://gitee.com/brolly/vue3-element-admin/commit/c8c1d110074238f25946a028b694494e7109c2a7