vue 后台管理系统实现页面多tab,右键菜单关闭、刷新等功能

实现效果如下:

image.png

一、点击左边菜单,实现右边tabs列表的加载

  • 我们已经将左边菜单栏跟tabs列表分别单独抽成组件。这里暂且将左边叫做菜单组件(nav-menu.vue)。右边顶部tabs这是又是一个组件我们暂且叫 tabs组件(tags.vue)

很明显这两个兄弟组件是有交互的,点击菜单的时候要加载tabs列表。实现原理如下:

  • 方式1

1.左侧菜单组件中每次点击的时候使用调用了tabs组件的方法,给tabs组件里面tagsList数组赋值。
2.模板遍历tagsList生成tab

其实这种方式有点小问题,耦合性有点高,涉及到了兄弟组件直接的交互.所以我们接下来看下方式2

  • 方式2:原理如下

1、点击菜单组件的时候会跳转路由
2、我们在tabs组件里面其实可以监控路由变化,然后再做tabsList赋值操作等。这样就实现了两个组件间的解耦。代码后面会贴出来。

二、 实现右键弹出菜单,必须先要禁用浏览器默的右键菜单弹出,

利用contextmenu事件阻止浏览器原生的菜单栏出现,然后绑定我们的显示菜单栏的自定义事件。在vue3中使用修饰符即可实现@contextmenu.prevent="setSelectedTag(item,$event)"

  • 第一种右键弹出菜单我们自己写,不借助插件,稍微麻烦

我们先来看看tabs组件模板里面的内容

<template>
  <ul v-show="contextMenuVisible" class="right-menu" :style="{left: menuLeft, top: menuTop}" >
    <li>
      <a href="javascript:;" @click="refresh">刷新页面</a>
    </li>
    <li>
      <a v-if="tagsList.length > 1" href="javascript:;" @click="closeTag">关闭当前</a>
    </li>
    <li>
      <a v-if="tagsList.length > 1" href="javascript:;" @click="closeOtherTag">关闭其他</a>
    </li>
    <li>
      <a v-if="tagsList.length > 1" href="javascript:;" @click="closeAllTag">关闭所有</a>
    </li>
  </ul>


  <div v-if="showTags" class="tags">
    <ul ref="scrollContainer" @wheel.prevent="ulWheel">
      <li
        v-for="(item, index) in tagsList"
        :key="index"
        class="tags-li"
        :class="{ active: isActive(item.path) }"
        @contextmenu.prevent="setSelectedTag(item,$event)"
      >
        <router-link
          :ref="setTagRef"
          :key="item.path + item.title"
          :to="item.path"
          class="tags-li-title"
        >{{ item.title }}</router-link>
        <span v-show="isShowClose(item.path)" class="tags-li-icon" @click="closeTags(index)">
          <i class="el-icon-close"></i>
        </span>
      </li>
    </ul>
  </div>
</template>

1.模板中整个ul就是右键菜单的内容了、contextMenuVisible控制右键菜单显示隐藏。:style动态设置菜单显示的位置,因为我们要让他跟随每个tag附近显示
2.从模板中可以看到我们遍历了tagsList,给每个tag都禁用了默认的右键弹出菜单,并且绑定到了自定义事件setSelectedTag(item,$event)

接下来我们再看看js里面的关键部分:

data() {
    return {
      tagsList: [],
      selectedTag: undefined, // 右键选中的tag
      tagRefs: [],
      contextMenuVisible: false, // 是否显示菜单
      menuLeft: '', // 右键菜单距离浏览器左边的距离
      menuTop: '' // 右键菜单距离浏览器上边的距离
    }
  },

setSelectedTag(tag,event) {
  const e = event || window.event
  const target = e.target
  this.menuLeft = e.layerX + 20 + 'px' // 菜单出现的位置距离左侧的距离
  this.menuTop = e.layerY + 20 + 'px' // 菜单出现的位置距离顶部的距离
  this.contextMenuVisible = true // 显示菜单
  this.selectedTag = tag// 当前右击的菜单信息
}

再看CSS部分

.right-menu{
    width: 80px;
    text-align: center;
    position: absolute;
    z-index: 100000;
    border: 1px solid #ccc;
    background: #f2f2f2;
    border-radius: 5px;
    padding: 5px;
    font-size: 14px;
}

所以可以看到我们自己实现这个右键弹出菜单很繁琐是吧,主要是告诉大家如何不用插件去实现的思路而已。好了我们开始用我们的插件吧。

  • 第二种使用ContextMenu component for Vue 3插件实现右键弹出菜单

[文档地址](https://vuejsexamples.com/contextmenu-component-for-vue-3/

1.运行 $ npm i -S v-contextmenu@next 或者 yarn add v-contextmenu@next

安装完以后我们来直接上代码吧
tags.vue 完整代码

<template>
  <v-contextmenu ref="contextmenu">
    <v-contextmenu-item @click="refreshSelectedTag"
    >刷新页面</v-contextmenu-item
    >
    <v-contextmenu-item v-if="tagsList.length > 1" @click="closeCurrent"
    >关闭当前</v-contextmenu-item
    >
    <v-contextmenu-item v-if="tagsList.length > 1" @click="closeOther"
    >关闭其他</v-contextmenu-item
    >
    <v-contextmenu-item v-if="tagsList.length > 1" @click="closeAll"
    >关闭所有</v-contextmenu-item
    >
  </v-contextmenu>
  <div v-if="showTags" class="tags">
    <ul ref="scrollContainer" @wheel.prevent="ulWheel">
      <li
        v-for="(item, index) in tagsList"
        :key="index"
        v-contextmenu:contextmenu
        class="tags-li"
        :class="{ active: isActive(item.path) }"
        @contextmenu.prevent="setSelectedTag(item)"
      >
        <router-link
          :ref="setTagRef"
          :key="item.path + item.title"
          :to="item.path"
          class="tags-li-title"
        >
          {{ item.title }}
        </router-link>
        <span
          v-show="isShowClose(item.path)"
          class="tags-li-icon"
          @click="closeTags(index)"
        >
          <i class="el-icon-close"></i>
        </span>
      </li>
    </ul>
  </div>
</template>

<script>
import { directive, Contextmenu, ContextmenuItem } from 'v-contextmenu'
import 'v-contextmenu/dist/themes/default.css'
import bus from 'lib@/utils/bus'
import { mapActions, mapGetters } from 'vuex'
const noTagRouteList = ['notFound', 'noAuthority', 'systemError']
const tagAndTagSpacing = 4 // tag标签移动的间距
const defaultPage = '/home' // 默认展示的页面,tags中至少有一个默认的tag
export default {
  directives: {
    contextmenu: directive,
  },

  components: {
    [Contextmenu.name]: Contextmenu,  
    [ContextmenuItem.name]: ContextmenuItem,
  },
  data() {
    return {
      tagsList: [],
      selectedTag: undefined, // 右键选中的tag
      tagRefs: [],
    }
  },
  computed: {
    showTags() {
      return this.tagsList.length > 0
    },
    ...mapGetters('tags', ['getTagsList']),
    scrollWrapper() {
      // tag滚动容器
      return this.$refs.scrollContainer
    },
  },
  watch: {
    $route(newValue) {
      this.setTags(newValue)
      this.moveToCurrentTag()
    },
  },
  created() {
    this.setTags(this.$route)
  },
  mounted() {
    bus.$on('closeCurrentTag', () => {
      this.closeCurrent()
    })
    if (Array.isArray(this.getTagsList) && this.getTagsList.length > 0) {
      this.tagsList = this.getTagsList
    }
  },
  beforeUpdate() {
    this.tagRefs = []
  },
  updated() {
    console.log(this.tagRefs)
  },
  methods: {
    ...mapActions('tags', ['updatedTags']),
    isActive(path) {
      return path === this.$route.fullPath
    },
    isShowClose(path) {
      return path !== defaultPage
    },
    // 关闭单个标签
    closeTags(index) {
      const delItem = this.tagsList.splice(index, 1)[0]
      const item = this.tagsList[index]
        ? this.tagsList[index]
        : this.tagsList[index - 1]
      if (item) {
        delItem.path === this.$route.fullPath && this.$router.push(item.path)
      } else {
        this.$router.push(defaultPage)
      }
      bus.$emit('tags', this.tagsList)
    },
    closeCurrent() {
      const index = this.tagsList.findIndex(
        (el) => el.path === this.selectedTag.path
      )
      if (index > -1) {
        this.closeTags(index)
      }
    },
    // 关闭全部标签
    closeAll() {
      this.tagsList = []
      bus.$emit('tags', this.tagsList)
      this.$router.push(defaultPage)
    },
    // 关闭其他标签
    closeOther() {
      const curItem = this.tagsList.filter((item) => {
        return item.path === this.$route.fullPath || item.path === defaultPage
      })
      this.tagsList = curItem
      bus.$emit('tags', this.tagsList)
    },
    // 设置标签
    setTags(route) {
      if (noTagRouteList.findIndex((item) => item === route.name) > -1) return
      //返回数组满足条件指定存在的路由
      const isExist = this.tagsList.some((item) => {
        return item.path === route.fullPath
      })

      // 获取二层子路由组件名称作为 keep-alive 需要缓存组件
      let name = route.matched[1]
        ? route.matched[1].components.default.name
        : route.name

      if (!isExist) {
        this.tagsList.push({
          title: route.meta.title,
          path: route.fullPath,
          name: name,
        })
        bus.$emit('tags', this.tagsList)
      }
    },

    // 刷新当前页面
    refreshSelectedTag() {
      this.$nextTick(() => {
        this.$router.replace({
          path: '/redirect' + this.selectedTag.path,
        })
      })
      this.updatedTags(this.tagsList)
    },
    handleTags(command) {
      command === 'other' ? this.closeOther() : this.closeAll()
    },
    setSelectedTag(tag) {
      this.selectedTag = tag
    },

    // 用于实现tags出现滚动条的时候左右滑动
    ulWheel(e) {
      const eventDelta = e.wheelDelta || -e.deltaY * 40
      const $scrollWrapper = this.scrollWrapper
      $scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
    },

    /**计算滚动条位置,动态滚动对应位置 */
    calculateTagsScroll(currentTag) {
      const $container = this.scrollWrapper
      const $containerWidth = $container.offsetWidth
      const $scrollWrapper = this.scrollWrapper
      const tagArr = this.tagRefs

      let firstTag = null
      let lastTag = null

      if (tagArr.length > 0) {
        firstTag = tagArr[0]
        lastTag = tagArr[tagArr.length - 1]
      }

      // 如果当前tag是数组中第一个,滚动到最左边
      if (firstTag === currentTag) {
        $scrollWrapper.scrollLeft = 0
      } else if (lastTag === currentTag) {
        // 如果当前tag是数组中最后一个,则滚动到最右边
        $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
      } else {
        const currentIndex = tagArr.findIndex((item) => item === currentTag)
        const prevTag = tagArr[currentIndex - 1]
        const nextTag = tagArr[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
        }
      }
    },

    // 定位tag元素位置,用于出现横向滚筒条的时候tag滚动条自动滚动位置
    moveToCurrentTag() {
      this.$nextTick(() => {
        for (const tag of this.tagRefs) {
          if (tag.to === this.$route.path) {
            this.calculateTagsScroll(tag)
            break
          }
        }
      })
    },
    // 获取tag元素
    setTagRef(el) {
      if (el) {
        this.tagRefs.push(el)
      }
    },
  },
}
</script>

<style lang="less">
.tags {
  position: relative;
  width: 100%;
  background: #fff;
  border-bottom: 1px solid #d8dce5;
}
.tags ul {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  display: flex;
  overflow-x: auto;
  height: 36px;
}

.tags ul::-webkit-scrollbar-track-piece {
  background-color: transparent;
  width: 2px;
}
.tags-li {
  display: flex;
  margin-left: 8px;
  border-radius: 3px;
  font-size: 12px;
  cursor: pointer;
  height: 23px;
  line-height: 23px;
  border: 1px solid #e9eaec;
  background: #fff;
  padding: 2px 5px 2px 12px;
  vertical-align: middle;
  color: #666;
  -webkit-transition: all 0.3s ease-in;
  -moz-transition: all 0.3s ease-in;
  transition: all 0.3s ease-in;
}

.tags-li:not(.active):hover {
  background: #f8f8f8;
}

.tags-li.active {
  border: 1px solid #409eff;
  background-color: #409eff;
  .tags-li-icon {
    color: #fff;
  }
}

.tags-li-title {
  float: left;
  max-width: 80px;
  min-width: 28px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  margin-right: 5px;
  color: #666;
}

.tags-li.active .tags-li-title {
  color: #fff;
}

.v-contextmenu-item {
  padding: 10px 16px;
  font-size: 12px;
  &--hover {
    background-color: #eee;
    color: #333;
  }
}
</style>

用于vuex 里面tags.js标签存储内容(用于右键菜单刷新页面的时候重新加载tags标签)

export default {
  namespaced: true,
  state: {
    tagsList: [],
  },
  getters: {
    getTagsList: (state) => {
      return state.tagsList
    },
  },
  mutations: {
    SET_TAGS_LIST(state, list) {
      state.tagsList = list
    },
    CLEAR_TAGS_LIST(state) {
      state.tagsList = []
    },
  },
  actions: {
    /* 赋值tags */
    updatedTags({ commit }, list = []) {
       // 去掉添加的重定向路由
      const arr = list.filter((o) => {
        return o.path.indexOf('/redirect/') < 0
      })
      commit('SET_TAGS_LIST', arr)
    },
    /* 清除tabs信息 */
    clearTags({ commit }) {
      commit('CLEAR_TAGS_LIST')
    },
  },
}

我们来看看ulWheel跟moveToCurrentTag可能不太好理解。函数主要是是为了做什么解决什么问题。看图
image.png

ulWheel函数是为了tags太多出现滚动条的时候,鼠标滚轮可以控制滚动条左右移动

moveToCurrentTag就是切换的时候每次重新计算,计算滚动条位置,动态滚动对应位置

以上就实现页面多tab,右键菜单关闭、刷新等功能所有内容了、虽然简单但是踩了不少坑跟改了不少bug

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容