<template>
<!-- vertical:false 横向滚动 @wheel.native.prevent 鼠标滚动-->
<el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
<slot />
</el-scrollbar>
</template>
<script>
const tagAndTagSpacing = 4 // tagAndTagSpacing 每个tag之间的空隙
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) {
// wheelDelta:获取滚轮滚动方向,向上120,向下-120,但为常量,与滚轮速率无关
// deltaY:垂直滚动幅度,正值向下滚动。电脑鼠标滚轮垂直行数默认值是3
// wheelDelta只有部分浏览器支持,deltaY几乎所有浏览器都支持
// -e.deltaY * 40 这个 *40 不知道是啥意思
const eventDelta = e.wheelDelta || -e.deltaY * 40
const $scrollWrapper = this.scrollWrapper
// 0到scrollLeft为滚动区域隐藏部分
// eventDelta / 4 这个/4 也不知道是什么意思
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
},
emitScroll() {
this.$emit('scroll')
},
// 移动到目标tag
moveToTarget(currentTag) {
// 获取滚动的元素
const $container = this.$refs.scrollContainer.$el
// 获取元素的宽度 包括padding border 的值
const $containerWidth = $container.offsetWidth
// 获取 wapper 组件
const $scrollWrapper = this.scrollWrapper
// 获取tag 组件
const tagList = this.$parent.$refs.tag
// 第一个tag 和最后一个tag
let firstTag = null
let lastTag = null
// 找到第一个tag 和最后一个tag
if (tagList.length > 0) {
firstTag = tagList[0]
lastTag = tagList[tagList.length - 1]
}
// 当前点击的tag 是否是第一个tag
if (firstTag === currentTag) {
// 如果是第一个tag 就滚动到0
$scrollWrapper.scrollLeft = 0
// 如果是最后一个tag
} else if (lastTag === currentTag) {
// 他的结构是tags-view-container > el-scrollbar__wrap
// 里面的wrap的宽度超出container 用里面的宽度 减去 外面的宽度
// 就是要向左滑动的宽度
// 滑动的距离是 el-scrollbar__wrap的总宽度 减去tags-view-container 元素的宽度
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
} else {
// 找到上一个tag和下一个tag
// 找到当前tag的索引值
const currentIndex = tagList.findIndex(item => item === currentTag)
const prevTag = tagList[currentIndex - 1]
const nextTag = tagList[currentIndex + 1]
// 下一个tag距离左边的距离 = 下一个tag距离左边的距离 + 下一个tag自身的距离 + tag之间空隙的距离
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
// 上一个tag距离左边的距离 = 上一个tag距离左边的距离 - tag之间空隙的距离
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
// 如果下一个tag距离左边的距离 大于 需要往左滑动的距离 + tags-view-containe 元素的宽度
// 是否点击的是右边边缘的tag 会滑动到下一个tag的距离
// 说明这个tag的下一个tag 已经超出了tags-view-containe 可见范围
// 所以会滑动到下一个tag的左边的距离
// 简单点说就是最右边的下一个tag是否在可见范围内 是否被遮挡
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
// 往左滑动的距离 = 下一个tag距离左边的距离 - tags-view-containe 元素的宽度
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
// 是否点击的是左边边缘的tag 会滑动到上一个tag的距离
// 上一个tag距离左边的距离是否 小于 往右滑动的距离
// 就会滑动到上一个tag距离左边的的距离
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
// 往左滑的距离 = 上一个tag距离左边的距离
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
}
}
}
}
}
</script>
<style lang="scss" scoped>
.scroll-container {
white-space: nowrap;
position: relative;
overflow: hidden;
width: 100%;
:deep(.el-scrollbar__bar) {
bottom: 0px;
}
:deep(.el-scrollbar__wrap) {
height: 49px;
}
}
</style>
<template>
<div id="tags-view-container" class="tags-view-container">
<scroll-pane
ref="scrollPane"
class="tags-view-wrapper"
@scroll="handleScroll"
>
<!--
@click.middle.native:监听鼠标中键 判断是否是固定tag 不是的话就删除当前tag
@contextmenu.prevent.native 监听鼠标右键 打开当前tag的菜单
-->
<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 }}
<!-- 不是固定tag就显示删除按钮 -->
<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)">{{ $t('刷新') }}</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
{{ $t('关闭') }}
</li>
<li @click="closeOthersTags">{{ $t('关闭其他') }}</li>
<li @click="closeAllTags(selectedTag)">{{ $t('关闭所有') }}</li>
</ul>
</div>
</template>
<script>
import ScrollPane from './ScrollPane'
import path from 'path'
export default {
components: { ScrollPane },
data() {
return {
visible: false,
top: 0,
left: 0,
selectedTag: {}, // 在右键打开菜单时赋值的
affixTags: [],
}
},
computed: {
visitedViews() {
// 获取所有的tags
return this.$store.state.tagsView.visitedViews
},
routes() {
// 获取所有的路由信息
return this.$store.state.permission.routes
},
},
watch: {
// 触发添加tag 条件就是是否触发了路由 触发了路由 就添加路由 并且前往当前的tag
$route() {
this.addTags()
this.moveToCurrentTag()
},
visible(value) {
if (value) {
document.body.addEventListener('click', this.closeMenu)
} else {
document.body.removeEventListener('click', this.closeMenu)
}
},
},
mounted() {
this.initTags()
this.addTags()
},
methods: {
// 是否是当前激活的状态
isActive(route) {
return route.path === this.$route.path
},
// 是否是固定tag
isAffix(tag) {
return tag.meta && tag.meta.affix
},
// 过滤出固定tag
filterAffixTags(routes, basePath = '/') {
let tags = []
// 循环 和 递归 找出固定tag的值
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
},
initTags() {
// 获取固定tag的数量
const affixTags = (this.affixTags = this.filterAffixTags(this.routes))
// 循环添加到tag里
for (const tag of affixTags) {
// tag必须有name值
if (tag.name) {
// 这个是只添加tag 没有添加tag的缓存
this.$store.dispatch('tagsView/addVisitedView', tag)
}
}
},
addTags() {
const { name } = this.$route
if (name) {
// 这个是tag 和缓存都添加 上下两个add的方法就差别在这
this.$store.dispatch('tagsView/addView', this.$route)
}
return false
},
moveToCurrentTag() {
// 获取当前所有的tag的值
const tags = this.$refs.tag
this.$nextTick(() => {
// 循环tag
for (const tag of tags) {
// 如果tag的path地址和当前的路由地址一致
if (tag.to.path === this.$route.path) {
// 就滚动到这个匹配到的path的tag位置
this.$refs.scrollPane.moveToTarget(tag)
// when query is different then update
// 当查询不同时,则更新 就是参数不同的话就会更新当前的路由信息
// pullPach 是全地址,携带路由参数 path 不带参数
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,
})
})
})
},
// 删除选中的tag
closeSelectedTag(view) {
this.$store
.dispatch('tagsView/delView', view)
.then(({ visitedViews }) => {
// 判断是否是当前激活的tag页面
if (this.isActive(view)) {
// 如果是当前选中的tag 删除后 跳到最后一个tag页面
// 传入tag数组和当前的tag值
this.toLastView(visitedViews, view)
}
})
},
// 关闭其他tag
closeOthersTags() {
// 先前往当前路由地址 当路由发生变化 就会触发addtag方法添加当前的tag信息
this.$router.push(this.selectedTag)
this.$store
.dispatch('tagsView/delOthersViews', this.selectedTag)
.then(() => {
// 移动到当前的tag页面
this.moveToCurrentTag()
})
},
// 关闭所有的除了固定的tag和缓存
closeAllTags(view) {
this.$store.dispatch('tagsView/delAllViews').then(({ visitedViews }) => {
// 如果是固定的tag 就直接return
if (this.affixTags.some((tag) => tag.path === view.path)) {
return
}
// 如果不是固定的tag 就去最后一个tag
this.toLastView(visitedViews, view)
})
},
// 去往最后一个tag页面
toLastView(visitedViews, view) {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
this.$router.push(latestView.fullPath)
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
// 我加了一个首页固定的tag标签 就不会走到这里
if (view.name === 'Dashboard') {
// to reload home page
this.$router.replace({ path: '/redirect' + view.fullPath })
} else {
this.$router.push({ path: '/teacher' })
}
}
},
openMenu(tag, e) {
const menuMinWidth = 105
const offsetLeft = this.$el.getBoundingClientRect().left // container margin left
const offsetWidth = this.$el.offsetWidth // container width
const maxLeft = offsetWidth - menuMinWidth // left boundary
const left = e.clientX - offsetLeft + 15 // 15: margin right
if (left > maxLeft) {
this.left = maxLeft
} else {
this.left = left
}
this.top = e.clientY
this.visible = true
this.selectedTag = tag
},
// 在滚动的时候 关闭tag菜单
closeMenu() {
this.visible = false
},
// 滚动的监听事件
handleScroll() {
this.closeMenu()
},
},
}
</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, 0.12), 0 0 3px 0 rgba(0, 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, 0.3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
</style>
<style lang="scss">
//reset element css of el-icon-close
.tags-view-wrapper {
.tags-view-item {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
}
</style>
const state = {
visitedViews: [], // tag的值
cachedViews: [] // 缓存起来的tag
}
const mutations = {
ADD_VISITED_VIEW: (state, view) => {
if (state.visitedViews.some(v => v.path === view.path)) return
state.visitedViews.push(
Object.assign({}, view, {
title: view.meta.title || 'no-name'
})
)
},
ADD_CACHED_VIEW: (state, view) => {
if (state.cachedViews.includes(view.name)) return
if (!view.meta.noCache) {
state.cachedViews.push(view.name)
}
},
DEL_VISITED_VIEW: (state, view) => {
for (const [i, v] of state.visitedViews.entries()) {
if (v.path === view.path) {
state.visitedViews.splice(i, 1)
break
}
}
},
// 从cachedViews删除当前tag的值
DEL_CACHED_VIEW: (state, view) => {
// 这个tag的值是否在cachedViews数组中 获取下标
const index = state.cachedViews.indexOf(view.name)
// 在这个数组中 并且根据下标删除当前项
index > -1 && state.cachedViews.splice(index, 1)
},
// 新的tag页面 = tag数组中过滤出 有固定tag的值 和当前选中的值
DEL_OTHERS_VISITED_VIEWS: (state, view) => {
state.visitedViews = state.visitedViews.filter(v => {
return v.meta.affix || v.path === view.path
})
},
// tag缓存页面 =
DEL_OTHERS_CACHED_VIEWS: (state, view) => {
// 找到当前选中tag的下标
const index = state.cachedViews.indexOf(view.name)
if (index > -1) {
// 找到下标 tag缓存数组 = slice截取出当前tag的唯一值
state.cachedViews = state.cachedViews.slice(index, index + 1)
} else {
// if index = -1, there is no cached tags
// 如果没找到下标 tag缓存数组就为空
state.cachedViews = []
}
},
// 过滤出固定tag
DEL_ALL_VISITED_VIEWS: state => {
// keep affix tags
const affixTags = state.visitedViews.filter(tag => tag.meta.affix)
state.visitedViews = affixTags
},
// 清空缓存
DEL_ALL_CACHED_VIEWS: state => {
state.cachedViews = []
},
// 循环tag数组 如果path路由地址相同
// 就将传进来的当前tag 深拷贝一份 重新赋值给 匹配到的v这一项的内容
UPDATE_VISITED_VIEW: (state, view) => {
for (let v of state.visitedViews) {
if (v.path === view.path) {
v = Object.assign(v, view)
break
}
}
}
}
const actions = {
// 添加缓存和tag
addView({ dispatch }, view) {
dispatch('addVisitedView', view)
dispatch('addCachedView', view)
},
// 只添加tag
addVisitedView({ commit }, view) {
commit('ADD_VISITED_VIEW', view)
},
// 只添加缓存
addCachedView({ commit }, view) {
commit('ADD_CACHED_VIEW', view)
},
// 将当前缓存页面的tag从缓存数组和显示tag标签的数组都删除
delView({ dispatch, state }, view) {
return new Promise(resolve => {
dispatch('delVisitedView', view)
dispatch('delCachedView', view)
resolve({
visitedViews: [...state.visitedViews],
cachedViews: [...state.cachedViews]
})
})
},
delVisitedView({ commit, state }, view) {
return new Promise(resolve => {
commit('DEL_VISITED_VIEW', view)
resolve([...state.visitedViews])
})
},
// 删除当前缓存的tag从cachedViews删除
// 在返回删除后剩余的tag的值
delCachedView({ commit, state }, view) {
return new Promise(resolve => {
commit('DEL_CACHED_VIEW', view)
resolve([...state.cachedViews])
})
},
// 删除其他的值
delOthersViews({ dispatch, state }, view) {
return new Promise(resolve => {
// 删除缓存和tag
dispatch('delOthersVisitedViews', view)
dispatch('delOthersCachedViews', view)
resolve({
visitedViews: [...state.visitedViews],
cachedViews: [...state.cachedViews]
})
})
},
// 删除除了当前tag信息的其他值的tag
delOthersVisitedViews({ commit, state }, view) {
return new Promise(resolve => {
commit('DEL_OTHERS_VISITED_VIEWS', view)
resolve([...state.visitedViews])
})
},
// 删除除了当前tag信息的缓存里其他的值
delOthersCachedViews({ commit, state }, view) {
return new Promise(resolve => {
commit('DEL_OTHERS_CACHED_VIEWS', view)
resolve([...state.cachedViews])
})
},
// 删除所有的缓存和tag
delAllViews({ dispatch, state }, view) {
return new Promise(resolve => {
dispatch('delAllVisitedViews', view)
dispatch('delAllCachedViews', view)
resolve({
visitedViews: [...state.visitedViews],
cachedViews: [...state.cachedViews]
})
})
},
delAllVisitedViews({ commit, state }) {
return new Promise(resolve => {
commit('DEL_ALL_VISITED_VIEWS')
resolve([...state.visitedViews])
})
},
delAllCachedViews({ commit, state }) {
return new Promise(resolve => {
commit('DEL_ALL_CACHED_VIEWS')
resolve([...state.cachedViews])
})
},
// 更新tags 传入当前路由信息
updateVisitedView({ commit }, view) {
commit('UPDATE_VISITED_VIEW', view)
}
}
export default {
namespaced: true,
state,
mutations,
actions
}