后台系统 tagView 实现

<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
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值