vue3.0+ts+element-plus多页签应用模板:头部工具栏(下)

系列文章

一、右键菜单

上一篇中,我们实现了标签栏的前三部分功能,这篇将会讲解剩下的右键菜单部分。我们先来看一下右键菜单需要包括哪些功能:

  • 刷新当前标签页
  • 关闭当前标签页
  • 关闭右侧标签页
  • 关闭其他标签页
  • 关闭所有标签页

那么,我们就开始一个个的去实现吧。

二、刷新当前标签页

众所周知,当我们点击浏览器的刷新按钮的时候,会直接刷新整个系统,而且会有白屏,用户体验也不好。所以,如果想要刷新某个标签页的话,我们需要使用局部刷新。vue给我们提供了三种局部强制刷新的方式:

  • 快速改变两次v-if的绑定值
  • key赋一个跟上次不一样的值
  • 获取组件的vm实例并调用$forceUpdate()方法

这里我采用第一种方式。不过 ,由于我们已经使用了keep-alive来缓存路由,如果这里我们直接改变v-if,是不会触发刷新的效果的。那么怎么办呢?其实很简单,我们在刷新之前,先将当前路由移出缓存数组,然后再刷新。刷新完毕之后,再将当前路由添加回缓存数组。下面我们来看一下关键代码吧:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive :include="cachedList">
      <component :is="Component" v-if="reload" :key="$route.name" />
    </keep-alive>
  </router-view>
</template>

<script lang="ts">
import { computed, defineComponent, provide, ref, nextTick } from 'vue'
import { useStore } from '@/store'
import { RouteRecordName, useRouter } from 'vue-router'

export default defineComponent({
  name: 'Index',
  setup() {
    const store = useStore()
    const router = useRouter()
    const cachedList = computed(() => store.state.tagModule.cachedList)
    const activeView = computed(() => store.state.tagModule.activeView)

    const reload = ref(true)
    const onReload = (routeName: string | RouteRecordName) => {
      store.commit('tagModule/REMOVE_CACHED_VIEW', routeName)
      reload.value = false
      nextTick(() => {
        if (activeView.value !== routeName) {
          router.push({ name: routeName })
        }
        reload.value = true
        store.commit('tagModule/ADD_CACHED_VIEW', routeName)
      })
    }
    provide('reload', onReload)

    return {
      reload,
      cachedList
    }
  }
})
</script>

解释一下,在上面的代码中:

  1. 通过mutation将当前路由移出缓存数组
  2. reload.value = false开始刷新
  3. 使用nextTick等待DOM变化完成
  4. 判断当前所在路由是否是要刷新的路由,如果不是就跳转到要刷新的路由
  5. reload.value = true刷新完成
  6. 通过mutation将当前路由添加回缓存数组
  7. 使用provideonReload方法提供给子组件使用

这样,我们的刷新方法就定义好了,我们只需要在子组件中inject('reload')(routeName)就可以触发刷新了。

三、关闭标签页

关闭单个标签页我们在前面已经实现过了,下面来看一下余下的几个是如何实现的:

import { Module } from 'vuex'
import { RouteRecord, RouteRecordName } from 'vue-router'
import { TagStateProps, RootStateProps, ViewListItemProps } from '../typings'
import router from '@/router'
import Utils from '@/utils'

const clearCache = (state: TagStateProps) => {
  state.cachedList = state.viewList.map((view) => Utils.getBigName(view.name as string))
}

const tagModule: Module<TagStateProps, RootStateProps> = {
  namespaced: true,
  state: {
    activeView: 'workbench',
    viewList: [{ name: 'workbench', label: '工作台' }],
    cachedList: []
  },
  mutations: {
    // 关闭右侧标签页
    REMOVE_RIGHT_VIEWS(state, routeName: string | RouteRecordName) {
      // 获取该view的索引值
      const viewIndex = state.viewList.findIndex((view) => view.name === routeName)
      state.viewList = state.viewList.slice(0, viewIndex + 1)
      // 如果目前存在的view中,没有已激活的页面,则将传进来的页面设置为目标页面
      if (!state.viewList.some((view) => view.name === state.activeView)) {
        state.activeView = routeName
        router.push({ name: routeName })
      }
      // 清除路由缓存
      clearCache(state)
    },
    // 关闭其他标签页
    REMOVE_OTHER_VIEWS(state, routeName: string | RouteRecordName) {
      // 获取该view
      const view = state.viewList.find((view) => view.name === routeName)!
      // 由于工作台永远都在第一位,所以直接将长度调整为1
      state.viewList.length = 1
      // 如果传进来的路由不是工作台,就将当前传进来的路由添加进去
      if (routeName !== 'workbench') {
        state.viewList.push(view)
      }
      // 如果目前存在的view中,没有已激活的页面,则将传进来的页面设置为目标页面
      if (!state.viewList.some((view) => view.name === state.activeView)) {
        state.activeView = routeName
        router.push({ name: routeName })
      }
      // 清除路由缓存
      clearCache(state)
    },
    // 关闭所有标签页
    REMOVE_ALL_VIEWS(state) {
      // 如果长度已经是1,不继续执行
      if (state.viewList.length === 1) {
        return
      }
      // 由于工作台永远都在第一位,所以直接将长度调整为1
      state.viewList.length = 1
      state.activeView = 'workbench'
      router.push({ name: 'workbench' })
      // 清除路由缓存
      clearCache(state)
    }
  }
}

export default tagModule

到这里,右键菜单的所有功能都已经实现完毕了,现在,让我们来创建组件吧。

四、编写Contextmenu组件

在写之前,我们需要先了解一个vue3的新内置组件:teleport(传送门)。也就是我们打游戏时经常说的tp技能,就是这个单词的缩写。顾名思义,这个组件可以将里面所包裹的内容,传送到其to属性中所指定的DOM节点之内。也就是说,无论我在哪引用这个组件,最终都会渲染在to属性指定的DOM节点之中。详细了解请看官方文档-teleport内置组件

<template>
  <teleport to="#contextmenu">
    <ul v-show="show" ref="contextmenuWrapper" class="contextmenu-wrapper" :style="style">
      <template v-for="item in menu" :key="item.label">
        <li
          v-if="item.disabled ? !item.disabled(routeName) : true"
          @click="item.onClick(routeName)"
        >
          {{ item.label }}
        </li>
      </template>
    </ul>
  </teleport>
</template>

<script lang="ts">
import { computed, defineComponent, PropType, watch, onMounted } from 'vue'
import { ContextMenuItemProps } from './typings'
import { CommonObject } from '@/typings'

export default defineComponent({
  name: 'Contextmenu',
  props: {
    visable: {
      type: Boolean,
      required: true
    },
    menu: {
      type: Array as PropType<ContextMenuItemProps[]>,
      required: true
    },
    style: Object as PropType<CommonObject>,
    routeName: {
      type: String,
      required: true
    }
  },
  emits: ['update:visable'],
  setup(props, context) {
    const show = computed({
      get: () => props.visable,
      set: (value) => context.emit('update:visable', value)
    })

    const hideMenu = () => show.value && (show.value = false)

    watch(show, (value) => {
      // 左键点击任意位置,隐藏菜单
      value && document.addEventListener('click', hideMenu)
      !value && document.removeEventListener('click', hideMenu)
    })
 
    onMounted(() => {
      let rootNode = document.getElementById('contextmenu')
      if (!rootNode) {
        rootNode = document.createElement('div')
        rootNode.id = 'contextmenu'
        document.body.appendChild(rootNode)
      }
    })

    return {
      show
    }
  }
})
</script>

<style lang="scss">
/*** 里面的sass变量就不贴了 ***/ 
.contextmenu-wrapper {
  width: fit-content;
  position: fixed;
  padding: $space--sm 0;
  background-color: $color--white;
  border: 1px solid $color--line;
  border-radius: 5px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  z-index: 9999;

  li {
    cursor: pointer;
    font-size: $font-size--sm;
    padding: $space--sm $space--lg;

    &:hover {
      background-color: rgba($color: $color--info, $alpha: 0.1);
    }
  }
}
</style>

五、引用Contextmenu组件

Contextmenu组件全局注册之后,我们来到TagsView组件中(为节省篇幅,只贴相关ts代码、html标签和属性):

<template>
  <div class="tagsView" v-bind="$attrs">
    <div
      class="route-tag"
      @contextmenu.prevent="onTagRightClick($event, view.name)"
    >
      <!-- 路由label -->
      <span class="tag-text">{{ view.label }}</span>
      <!-- 关闭图标 -->
      <i
        class="el-icon el-icon-close tag-close"
        @contextmenu.stop.prevent
      />
    </div>
  </div>
  <contextmenu
    v-model:visable="contextmenu.show"
    :menu="contextmenu.menu"
    :route-name="contextmenu.routeName"
    :style="contextmenu.style"
  />
</template>

<script lang="ts">
/* import语句省略 */
export default defineComponent({
  name: 'TagsView',
  setup() {
    const store = useStore()
    const reload = inject<Reload>('reload')!
    const contextmenu = reactive<{
      show: boolean
      style: { top: string; left: string }
      routeName: RouteRecordName | string
      menu: ContextMenuItemProps[]
    }>({
      show: false,
      style: { top: '0', left: '0' },
      routeName: '',
      menu: [
        {
          label: '刷新',
          onClick: (routeName: RouteRecordName) => reload(routeName)
        },
        {
          label: '关闭',
          disabled: (routeName) => routeName === 'workbench',
          onClick: (routeName) => {
            store.commit(Consts.MutationKey.REMOVE_VIEW, routeName)
          }
        },
        {
          label: '关闭右侧',
          onClick: (routeName) => {
            store.commit(Consts.MutationKey.REMOVE_RIGHT_VIEWS, routeName)
          }
        },
        {
          label: '关闭其他',
          onClick: (routeName) => {
            store.commit(Consts.MutationKey.REMOVE_OTHER_VIEWS, routeName)
          }
        },
        {
          label: '关闭所有',
          onClick: () => {
            store.commit(Consts.MutationKey.REMOVE_ALL_VIEWS)
          }
        }
      ]
    })
    const onTagRightClick = (event: MouseEvent, viewName: RouteRecordName) => {
      contextmenu.style.top = event.clientY + 'px'
      contextmenu.style.left = event.clientX + 'px'
      contextmenu.routeName = viewName
      contextmenu.show = true
    }

    return {
      contextmenu,
      onTagRightClick
    }
  }
})
</script>

解释一下,这里的右键菜单并不是每个标签都匹配渲染了一个,而是所有标签共用一个右键菜单。因为一旦标签开的很多的情况下,渲染过多的右键菜单将不会是一件好事。

那么怎么实现共用呢?其实很简单,我们在一开始的时候就将右键菜单渲染好,并设置其v-showfalse,也就是相当于给右键菜单的根节点加了个display: none。之后,当我们右键单击标签时,获取鼠标单击的位置,并将菜单移动至那个坐标,同时切换当前右键菜单的routeName属性,使其所有功能都为我鼠标单击的标签服务。最后,将v-show调整为true,菜单就成功显示了。

六、结语

到这里,本项目的所有核心功能就都讲述完毕了。希望喜欢的朋友们可以在github中star一下,这对我很重要。谢谢大家,完结撒花了!

  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

W先生-SirW

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值