实现一个后台管理系统常见的标签页导航(TagsView)

前言

标签页导航在现代Web应用,尤其是后台管理系统中,逐渐成为一种常见的用户体验设计。它能够帮助用户在多个页面间快速切换,保持工作上下文,从而提升操作效率。本文将详细介绍一个完整的TagsView实现方案,从Vuex状态管理到组件实现过程。

效果预览:

图一:

图二:

功能设计

完整的TagsView功能由以下几部分组成:

  • Vuex模块:集中管理标签的状态,包括添加、删除、更新和固定标签等操作。

  • 数据持久化:通过本地存储(localStorage)实现标签状态的持久化,确保页面刷新后标签状态不丢失。

  • TagsView组件:负责标签的渲染、交互以及展示,支持添加和关闭功能。

  • 路由集成:与Vue Router深度集成,自动根据路由变化动态添加和处理标签,确保标签与路由状态同步。

Vuex标签模块实现

每个标签的数据结构如下:

  • name: 标签的唯一标识符,是路由模块的名字。

  • path: 标签对应的路由路径。

  • fullPath: 完整的路由路径,可能包含查询参数或哈希。

  • title: 标签的显示名称,用于展示在页面上。

  • affix: 是否固定标签(不可关闭),通常用于常驻标签(如首页)。

定义一个vuex模块来管理标签状态:

js 复制代码
import { VISITED_VIEWS } from '@/constants/tagsView' // 字符串常量 'visitedViews'
import { localStg } from '@/utils/storage' 

const state = {
  visitedViews: localStg.get(VISITED_VIEWS) || []
}

const mutations = {
  // 添加浏览视图
  ADD_VISITED_VIEW(state, view) {
    // 不可重复添加
    if (state.visitedViews.some(v => v.path === view.path)) return

    state.visitedViews.push({
      name: view.name,
      path: view.path,
      fullPath: view.fullPath,
      title: view.meta.title || 'no-name',
      affix: view.meta.affix || false
    })
  },
  // 删除单个浏览视图
  DEL_VISITED_VIEW(state, view) {
    for (const [i, v] of state.visitedViews.entries()) {
      if (v.path === view.path) {
        state.visitedViews.splice(i, 1)
        break
      }
    }
  },
  // 删除其他浏览视图(除固定标签以外)
  DEL_OTHER_VISITED_VIEWS(state, view) {
    // 保持固定标签删除其他标签
    state.visitedViews = state.visitedViews.filter(v => {
      return v.affix || v.path === view.path
    })
  },
  // 删除所有浏览视图(除固定标签以外)
  DEL_ALL_VISITED_VIEWS(state) {
    const affixTags = state.visitedViews.filter(v => v.affix)

    state.visitedViews = affixTags
  },
  //本地存储标签数据,实现数据持久化。
  UPDATE_VISITED_VIEWS(state) {
    localStg.set(VISITED_VIEWS, state.visitedViews)
  }
}

const actions = {
  addVisitedView({ commit }, view) {
    commit('ADD_VISITED_VIEW', view)
    commit('UPDATE_VISITED_VIEWS')
  },
  delVisitedView({ commit }, view) {
    commit('DEL_VISITED_VIEW', view)
    commit('UPDATE_VISITED_VIEWS')
  },
  delOtherVisitedViews({ commit }, view) {
    commit('DEL_OTHER_VISITED_VIEWS', view)
    commit('UPDATE_VISITED_VIEWS')
  },
  delAllVisitedViews({ commit }) {
    commit('DEL_ALL_VISITED_VIEWS')
    commit('UPDATE_VISITED_VIEWS')
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

这个Vuex模块提供了所有必要的状态管理功能:

  • 添加新标签
  • 删除标签
  • 关闭其他标签
  • 关闭所有标签
  • 持久化标签(确保用户刷新页面后,标签依然展示。)

TagsView组件实现

有了Vuex状态管理,接下来是TagsView组件的实现:

html 复制代码
<template>
  <div id="tags-view-container" class="tags-view-container">
    <div class="tags-view-wrapper">
      <router-link
        class="tags-view-item"
        v-for="tag in visitedViews"
        :key="tag.path"
        :to="{ path: tag.fullPath }"
        :class="isActive(tag) ? 'active' : ''"
        @click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
        @contextmenu.prevent.native="openMenu(tag, $event)"
      >
        {{ tag.title }}
        <span
          v-if="!isAffix(tag)"
          class="el-icon-close"
          @click.prevent.stop="closeSelectedTags(tag)"
        />
      </router-link>
    </div>
    <ul
      v-show="contextMenuVisible"
      :style="{ left: left + 'px', top: top + 'px' }" <!-- 动态设置菜单的位置 -->
      class="contextmenu"
    >
      <li @click="refreshSelectedTagTags(selectedTag)">重新加载</li>
      <li
        v-show="!isAffix(selectedTag)"
        @click="closeSelectedTags(selectedTag)"
      >
        关闭
      </li>
      <li @click="closeOthersTags(selectedTag)">关闭其他</li>
      <li @click="closeAllTags(selectedTag)">关闭所有</li>
    </ul>
  </div>
</template>

TagsView组件主要由两部分组成:

  1. 标签列表 :使用router-link实现标签导航
  2. 右键菜单:提供常用操作(刷新、关闭、关闭其他、关闭所有)

组件数据与计算属性

javascript 复制代码
export default {
  data() {
    return {
      contextMenuVisible: false, // 控制右键菜单的显示与隐藏   
      top: 0, // 右键菜单的顶部位置
      left: 0, //	 // 右键菜单的左侧位置 
      selectedTag: {}, // 当前选中的标签
      affixTags: [] // 固定标签集合
    }
  },
  computed: {
    ...mapState('tagsView', ['visitedViews'])
  },
  // ...
}

组件通过Vuex的mapState获取visitedViews,同时维护右键菜单和固定标签的状态。

组件生命周期与侦听器

js 复制代码
watch: {
  $route() {
    this.addTags()
  },
  contextMenuVisible(value) {
    if (value) {
      document.body.addEventListener('click', this.closeMenu)
    } else {
      document.body.removeEventListener('click', this.closeMenu)
    }
  }
},
mounted() {
  this.initAffixTags()
  this.addTags()
},

组件在mounted时初始化固定标签并添加当前路由对应的标签,同时监听路由变化自动添加新标签。

核心方法实现

判断标签状态和初始化固定标签

js 复制代码
// 是否选中
isActive(tag) {
  return tag.path === this.$route.path
},
// 是否是固定标签
isAffix(tag) {
  return tag && tag.affix
},
// 过滤固定标签
fillterAffixTags(routes, basePath) {
  let affixTags = []

  routes.forEach(route => {
    if (route.meta && route.meta.affix) {
      const tagPath = path.resolve(basePath, route.path)

      affixTags.push({
        name: route.name,
        path: tagPath,
        fullPath: tagPath,
        meta: { ...route.meta }
      })
    }

    if (route.children) {
      const tempTags = this.fillterAffixTags(route.children, route.path)
      affixTags = [...affixTags, ...tempTags]
    }
  })
  return affixTags
},
// 初始化固定标签
initAffixTags() {
  // vuex的user模块中获取已经缓存好的路由信息
  const routes = this.$store.state.user.routes
  const affixTags = (this.affixTags = this.fillterAffixTags(routes, '/'))
 
  // 添加固定标签数据
  affixTags.forEach(tag => {
    if (tag.name) {
      this.$store.dispatch('tagsView/addVisitedView', tag)
    }
  })
},

这些方法负责判断标签状态和初始化固定标签。fillterAffixTags方法递归遍历路由配置,找出所有设置了meta.affixtrue的路由,构造固定标签数据。

标签操作

javascript 复制代码
// 添加标签
addTags() {
  const { name } = this.$route

  if (name) {
    this.$store.dispatch('tagsView/addVisitedView', this.$route)
  }
},
// 刷新标签
refreshSelectedTagTags() {
  this.$router.go(0)
},
// 关闭标签
closeSelectedTags(view) {
  this.$store.dispatch('tagsView/delVisitedView', view)

  if (this.isActive(view)) {
    this.toLastView()
  }
},
// 关闭其他标签
closeOthersTags(view) {
  this.$store.dispatch('tagsView/delOtherVisitedViews', view)

  if (!this.affixTags.some(v => v.path === view.path)) {
    this.toLastView()
  }
},
// 关闭所有标签
closeAllTags(view) {
  this.$store.dispatch('tagsView/delAllVisitedViews')

  if (!this.affixTags.some(v => v.path === view.path)) {
    this.toLastView()
  }
},

这些方法通过调用Vuex的actions实现标签的添加、删除、刷新页面等操作。关闭当前活动标签时,会自动导航到最后一个标签。

右键菜单

javascript 复制代码
// 打开右键菜单
openMenu(tag, e) {
  const menuMinWidth = 105
  const offsetLeft = this.$el.getBoundingClientRect().left 
  const offsetWidth = this.$el.offsetWidth 
  const maxLeft = offsetWidth - menuMinWidth 
  const left = e.clientX - offsetLeft + 15 

  if (left > maxLeft) {
    this.left = maxLeft
  } else {
    this.left = left
  }

  this.top = 15
  this.contextMenuVisible = true
  this.selectedTag = tag
},
// 关闭右键菜单
closeMenu() {
  this.contextMenuVisible = false
}

智能导航

关闭当前标签后,自动跳转到最后一个访问的标签:

javascript 复制代码
toLastView() {
  const visitedViews = this.visitedViews
  const latestView = visitedViews.at(-1)

  if (latestView) {
    this.$router.push(latestView.fullPath)
  } else {
    this.$router.push('/')
  }
}

完整代码:

vue 复制代码
<template>
  <div id="tags-view-container" class="tags-view-container">
    <div class="tags-view-wrapper">
      <router-link
        v-for="tag in visitedViews"
        :key="tag.path"
        :to="{ path: tag.fullPath }"
        class="tags-view-item"
        :class="isActive(tag) ? 'active' : ''"
        @click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : ''"
        @contextmenu.prevent.native="openMenu(tag, $event)"
      >
        {{ tag.title }}
        <span
          v-if="!isAffix(tag)"
          class="el-icon-close"
          @click.prevent.stop="closeSelectedTags(tag)"
        />
      </router-link>
    </div>
    <ul
      v-show="contextMenuVisible"
      :style="{ left: left + 'px', top: top + 'px' }"
      class="contextmenu"
    >
      <li @click="refreshSelectedTagTags(selectedTag)">重新加载</li>
      <li
        v-show="!isAffix(selectedTag)"
        @click="closeSelectedTags(selectedTag)"
      >
        关闭
      </li>
      <li @click="closeOthersTags(selectedTag)">关闭其他</li>
      <li @click="closeAllTags(selectedTag)">关闭所有</li>
    </ul>
  </div>
</template>

<script>
import { mapState } from 'vuex'
import path from 'path'

export default {
  data() {
    return {
      contextMenuVisible: false,
      top: 0,
      left: 0,
      selectedTag: {},
      affixTags: []
    }
  },
  computed: {
    ...mapState('tagsView', ['visitedViews'])
  },
  watch: {
    $route() {
      this.addTags()
    },
    contextMenuVisible(value) {
      if (value) {
        document.body.addEventListener('click', this.closeMenu)
      } else {
        document.body.removeEventListener('click', this.closeMenu)
      }
    }
  },
  mounted() {
    this.initAffixTags()
    this.addTags()
  },
  created() {},
  methods: {
    // 是否选中
    isActive(tag) {
      return tag.path === this.$route.path
    },
    // 是否是固定标签
    isAffix(tag) {
      return tag && tag.affix
    },
    fillterAffixTags(routes, basePath) {
      let affixTags = []

      routes.forEach(route => {
        if (route.meta && route.meta.affix) {
          const tagPath = path.resolve(basePath, route.path)

          affixTags.push({
            name: route.name,
            path: tagPath,
            fullPath: tagPath,
            meta: { ...route.meta }
          })
        }

        if (route.children) {
          const tempTags = this.fillterAffixTags(route.children, route.path)
          affixTags = [...affixTags, ...tempTags]
        }
      })
      return affixTags
    },
    initAffixTags() {
      const routes = this.$store.state.user.menuList
      const affixTags = (this.affixTags = this.fillterAffixTags(routes, '/'))

      affixTags.forEach(tag => {
        if (tag.name) {
          this.$store.dispatch('tagsView/addVisitedView', tag)
        }
      })
    },
    // 添加标签
    addTags() {
      const { name } = this.$route

      if (name) {
        this.$store.dispatch('tagsView/addVisitedView', this.$route)
      }
    },
    // 刷新标签
    refreshSelectedTagTags() {
      this.$router.go(0)
    },
    // 关闭标签
    closeSelectedTags(view) {
      this.$store.dispatch('tagsView/delVisitedView', view)

      if (this.isActive(view)) {
        this.toLastView()
      }
    },
    toLastView() {
      const visitedViews = this.visitedViews // 从组件的状态中获取访问的视图

      const latestView = visitedViews.at(-1)

      if (latestView) {
        this.$router.push(latestView.fullPath)
      } else {
        this.$router.push('/')
      }
    },
    // 关闭其他标签
    closeOthersTags(view) {
      this.$store.dispatch('tagsView/delOtherVisitedViews', view)

      if (!this.affixTags.some(v => v.path === view.path)) {
        this.toLastView()
      }
    },
    // 关闭所有标签
    closeAllTags(view) {
      this.$store.dispatch('tagsView/delAllVisitedViews')

      if (!this.affixTags.some(v => v.path === view.path)) {
        this.toLastView()
      }
    },
    // 打开右键菜单
    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 = 15
      this.contextMenuVisible = true
      this.selectedTag = tag
    },
    // 关闭右键菜单
    closeMenu() {
      this.contextMenuVisible = false
    }
  }
}
</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 {
    cursor: pointer;
    .tags-view-item {
      display: inline-block;
      position: relative;
      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;
      }
    }
  }
  .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: 10px;
      height: 10px;
      border-radius: 50%;
      text-align: center;
    }
  }
}
</style>

总结

实现了一个功能完善的标签页导航组件,具有标签持久化、固定标签、右键菜单、智能导航等特性,为用户提供了高效、便捷的页面切换体验。

相关推荐
Apifox7 分钟前
如何在 Apifox 中通过 Runner 运行包含云端数据库连接配置的测试场景
前端·后端·ci/cd
树上有只程序猿35 分钟前
后端思维之高并发处理方案
前端
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
黄毛火烧雪下1 小时前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox1 小时前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞2 小时前
Firefox默认在新标签页打开收藏栏链接
前端·firefox
高达可以过山车不行2 小时前
Firefox账号同步书签不一致(火狐浏览器书签同步不一致)
前端·firefox
m0_593758102 小时前
firefox 136.0.4版本离线安装MarkDown插件
前端·firefox
掘金一周2 小时前
金石焕新程 >> 瓜分万元现金大奖征文活动即将回归 | 掘金一周 4.3
前端·人工智能·后端
三翼鸟数字化技术团队2 小时前
Vue自定义指令最佳实践教程
前端·vue.js