实现一个后台管理系统常见的标签页导航(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>

总结

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

相关推荐
郭尘帅66617 分钟前
vue3基础学习(上) [简单标签] (vscode)
前端·vue.js·学习
njsgcs31 分钟前
opencascade.js stp vite webpack 调试笔记
开发语言·前端·javascript
T0uken1 小时前
【前端】:单 HTML 去除 Word 批注
前端·html·word
st紫月2 小时前
用vue和go实现登录加密
前端·vue.js·golang
岁岁岁平安2 小时前
Vue3学习(组合式API——计算属性computed详解)
前端·javascript·vue.js·学习·computed·计算属性
HWL56792 小时前
Express项目解决跨域问题
前端·后端·中间件·node.js·express
刺客-Andy3 小时前
React 第三十九节 React Router 中的 unstable_usePrompt Hook的详细用法及案例
前端·javascript·react.js
Go_going_3 小时前
【js基础笔记] - 包含es6 类的使用
前端·javascript·笔记
浩~~3 小时前
HTML5 浮动(Float)详解
前端·html·html5
AI大模型顾潇4 小时前
[特殊字符] 本地大模型编程实战(29):用大语言模型LLM查询图数据库NEO4J(2)
前端·数据库·人工智能·语言模型·自然语言处理·prompt·neo4j