vue多标签页实现(菜单,标签页,地址栏可联动)

作者: bkbtp 分类: 前端 发布时间: 2018-10-29 19:21

blob.jpg

如图,需要实现的效果是:
1、点击左侧菜单,会打开相应的标签页,如果,已存在打开的该标签页,则切换到该页。这些标签可以关闭,可以切换;
2、切换标签页,左侧菜单选中状态联动,一级菜单对应折叠或展开;
3、关闭标签页,如果是关闭当前页,自动跳到最后打开的标签页并且菜单联动;如果关闭的不是当前页,则保持不动,或者根据整体宽度左右滑动到合适位置;
4、直接在地址栏输入url,对应菜单展开高亮,如果对应标签页已打开,则切换到该页,否则直接打开相应标签页;
5、标签页过多时,可通过点击左右按钮进行横向滚动;

本例使用到了 vue + iview + vuex + vue-router

首先,在 vuex 文件夹中新建一个保存状态的 tagNav.js

import * as types from '../types'

const state = {
  visitedTag: JSON.parse(sessionStorage.getItem('visitedTag')) || [] // 存放所有浏览过的且不重复的路由数据
}

const actions = {
  addTag({ commit }, response) {
    commit(types.ADD_TAG, response)
  },
  delTag({ commit }, response) { // 删除数组存放的路由之后,需要再去刷新路由,这是一个异步的过程,需要有回掉函数,所以使用并返回promise对象,也可以让组件在调用的时候接着使用.then的方法
    // commit(types.DEL_TAG, response)
    return new Promise((resolve) => { // resolve方法:未来成功后回掉的方法
      commit(types.DEL_TAG, response)
      resolve(state.visitedTag)
    })
  }
}

const getters = {
  visitedTag: state => state.visitedTag
}

const mutations = {
  [types.ADD_TAG](state, response) { // 打开新页签--添加路由数据的方法
    if (state.visitedTag.some(item => item.path === response.path) || response.name === 'index') { return }
    state.visitedTag.push({
      name: response.name,
      path: response.path,
      title: response.meta.title || response.name || 'no-title'
    })
    sessionStorage.setItem('visitedTag', JSON.stringify(state.visitedTag))
  },
  [types.DEL_TAG](state, response) { // 关闭页签--删除路由数据的方法
    for (let [index, item] of Object.entries(state.visitedTag)) {
      if (item.path === response.path) {
        state.visitedTag.splice(index, 1)
        sessionStorage.setItem('visitedTag', JSON.stringify(state.visitedTag))
        break
      }
    }
  }
}

export default {
  state,
  actions,
  getters,
  mutations
}

具体的vuex文件夹目录结构可以参考另外一篇文章 vuex个人目录结构与用法


然后,在 components 文件夹下建一个组件 tagNav.vue

<template>
  <div class="tag-nav-container">
    <div class="tag-nav">
      <div class="btn-con left-btn">
        <Button type="text"
                @click="handleScroll(240)">
          <Icon :size="18"
                type="ios-arrow-back" />
        </Button>
      </div>
      <div class="btn-con right-btn">
        <Button type="text"
                @click="handleScroll(-240)">
          <Icon :size="18"
                type="ios-arrow-forward" />
        </Button>
      </div>
      <div class="scroll-outer"
           ref="scrollOuter"
           @DOMMouseScroll="handlescroll"
           @mousewheel="handlescroll">
        <div class="scroll-body"
             ref="scrollBody"
             :style="{left: tagBodyLeft + 'px'}">
          <Tag type="dot"
               :class="{active: isIndex}"
               ref="tagIndex"
               :style="{marginRight: 0}"
               @click.native="toIndex">首页</Tag>
          <Tag type="dot"
               v-for="tag in visitedTag"
               :key="tag.path"
               :name="tag.name"
               :class="isActive(tag)?'active':''"
               closable
               ref="tagsPageOpened"
               @on-close="delSelectTag(tag)"
               @click.native="handleClick(tag)">{{tag.title}}</Tag>
        </div>
      </div>
    </div>
  </div>
</template>

<script type="text/ecmascript-6">
import { mapActions, mapGetters } from 'vuex'
export default {
  data() {
    return {
      isIndex: true,
      tagBodyLeft: 0,
      outerPadding: 4
    }
  },
  computed: {
    ...mapGetters(['visitedTag'])
  },
  created() {
    this.changeTag()
    this.$route.name === 'index' ? this.isIndex = true : this.isIndex = false
  },
  mounted() {
    setTimeout(() => {
      this.getTagElementByName(this.$route.name)
    }, 200)
  },
  methods: {
    ...mapActions(['addTag', 'delTag']),
    isActive(tag) {
      return tag.path === this.$route.path
    },
    changeTag() { // 路由改变时执行的方法
      this.addTag(this.$route)
    },
    delSelectTag(tag) { // 先提交删除数据的方法,数组删除出掉数据后,如果关闭的是当前打开的路由需要将路由改为数组最后一次push进去的路由
      this.delTag(tag).then((response) => {
        if (this.isActive(tag)) { // 只有在关闭当前打开的标签页才会有影响
          let lastView = response.slice(-1)[0] // 选取路由数组中的最后一位
          if (lastView) {
            this.$router.push({ path: lastView.path })
          } else {
            this.$router.push({ name: 'index' })
          }
        }
      })
    },
    // select(name) {
    //   this.$router.push({ name: name })
    // },
    handleClick(tag) {
      // console.log(tag)
      this.$router.push({ path: tag.path })
    },
    toIndex() {
      this.$router.push({ name: 'index' })
    },
    handlescroll(e) {
      var type = e.type
      let delta = 0
      if (type === 'DOMMouseScroll' || type === 'mousewheel') {
        delta = (e.wheelDelta) ? e.wheelDelta : -(e.detail || 0) * 40
      }
      this.handleScroll(delta)
    },
    handleScroll(offset) {
      const outerWidth = this.$refs.scrollOuter.offsetWidth
      const bodyWidth = this.$refs.scrollBody.offsetWidth
      if (offset > 0) {
        this.tagBodyLeft = Math.min(0, this.tagBodyLeft + offset)
      } else {
        if (outerWidth < bodyWidth) {
          if (this.tagBodyLeft < -(bodyWidth - outerWidth)) {
            this.tagBodyLeft = this.tagBodyLeft
          } else {
            this.tagBodyLeft = Math.max(this.tagBodyLeft + offset, outerWidth - bodyWidth)
          }
        } else {
          this.tagBodyLeft = 0
        }
      }
    },
    moveToView(tag) {
      const outerWidth = this.$refs.scrollOuter.offsetWidth
      const bodyWidth = this.$refs.scrollBody.offsetWidth
      if (bodyWidth < outerWidth) {
        this.tagBodyLeft = 0
      } else if (tag.offsetLeft < -this.tagBodyLeft) {
        // 标签在可视区域左侧
        this.tagBodyLeft = -tag.offsetLeft + this.outerPadding
      } else if (tag.offsetLeft > -this.tagBodyLeft && tag.offsetLeft + tag.offsetWidth < -this.tagBodyLeft + outerWidth) {
        // 标签在可视区域
        this.tagBodyLeft = Math.min(0, outerWidth - tag.offsetWidth - tag.offsetLeft - this.outerPadding)
      } else {
        // 标签在可视区域右侧
        this.tagBodyLeft = -(tag.offsetLeft - (outerWidth - this.outerPadding - tag.offsetWidth))
      }
    },
    getTagElementByName(name) {
      this.$nextTick(() => {
        if (name === 'index') {
          this.moveToView(this.$refs.tagIndex.$el)
        } else {
          this.refsTag = this.$refs.tagsPageOpened
          this.refsTag.forEach((item, index) => {
            if (name === item.name) {
              let tag = this.refsTag[index].$el
              this.moveToView(tag)
            }
          })
        }
      })
    }
  },
  watch: {
    $route() {
      this.changeTag()
      this.$route.name === 'index' ? this.isIndex = true : this.isIndex = false
      this.getTagElementByName(this.$route.name)
    }
  }
}
</script>

<style lang="css">
.tag-nav-container {
  background: #f0f0f0;
  height: 40px;
  padding: 0 15px;
  user-select: none;
}
.tag-nav-container .tag-nav {
  border-bottom: 1px solid #f0f0f0;
  border-top: 1px solid #f0f0f0;
  position: relative;
  height: 100%;
  width: 100%;
  overflow: hidden;
}
.tag-nav-container .btn-con {
  background: #fff;
  height: 100%;
  position: absolute;
  top: 0;
  z-index: 10;
  padding-top: 3px;
}
.tag-nav-container .btn-con.left-btn {
  left: 0;
}
.tag-nav-container .btn-con.right-btn {
  right: 0;
}
.tag-nav-container .btn-con button {
  line-height: 14px;
  padding: 6px 4px;
  text-align: center;
}
.tag-nav-container .scroll-outer {
  position: absolute;
  left: 28px;
  right: 28px;
  top: 0;
  bottom: 0;
  box-shadow: inset 0 0 4px 2px #e3e3e3;
}
.tag-nav-container .scroll-body {
  display: inline-block;
  height: calc(100% - 1px);
  overflow: visible;
  padding: 1px 4px 0;
  position: absolute;
  transition: left 0.3s ease;
  white-space: nowrap;
}
.tag-nav-container .ivu-tag-dot.active .ivu-tag-dot-inner {
  background: #2d8cf0;
}
</style>


其中,标签页样式及滚动功能参考的 iview-admin
tagNav 组件可以放到任意合适的地方。

最后,base.vue 页面中需要控制左侧菜单对应的展开高亮

<template>
  <div class="layout-container">
    <Layout>
      <Header :style="{position: 'fixed', left: 0, right: 0, zIndex: 1000, padding: '0 30px 0 20px', backgroundColor: '#558AFB', boxShadow: '0 2px 3px 2px rgba(0,0,0,.1)'}">
        <h1>
          <router-link class="logo"
                       :to="{name: 'index'}">LOGO</router-link>
        </h1>
        <div class="fr">
          <img src="/static/img/icon_user.png"
               class="user-img"
               alt="">
          <span class="user-name">{{userName}}</span>
          <span class="user-divider">|</span>
          <a href="javascript:;"
             class="exit"
             title="退出"
             @click="logout"></a>
        </div>
      </Header>
      <div style="height: 64px;"></div>
      <Layout>
        <Sider :style="{position: 'fixed', height: 'calc(100vh - 64px)', left: 0, overflowY: 'auto', overflowX: 'hidden', zIndex: 1, backgroundColor: '#fff', userSelect: 'none'}">
          <Menu ref="menu"
                :active-name="activeName"
                theme="light"
                width="auto"
                :open-names="openName"
                @on-select="select">
            <template v-for="(item, index) in menuList">
              <template v-if="item.children && item.children.length > 0">
                <Submenu :name="index"
                         :key="item.title">
                  <template slot="title">
                    {{item.title}}
                  </template>
                  <MenuItem v-for="citem in item.children"
                            :key="citem.title"
                            :name="citem.name"
                            ref="menuitem">{{citem.title}}</MenuItem>
                </Submenu>
              </template>
              <template v-else>
                <MenuItem :name="item.name"
                          :key="item.tile">{{item.title}}</MenuItem>
              </template>
            </template>
          </Menu>
        </Sider>
        <Layout :style="{marginLeft: '200px'}">
          <tags-nav></tags-nav>
          <Content :style="{padding: '0 15px 15px', height: 'calc(100vh - 104px)', overflowY: 'auto', overflowX: 'hidden', background: '#f5f7f9'}">
            <div style="min-height: 100%;background: #fff;padding: 15px;">
              <router-view />
            </div>
          </Content>
        </Layout>
      </Layout>
    </Layout>
  </div>
</template>

<script type="text/ecmascript-6">
import AccountService from '@/services/accountService'
import tagsNav from 'components/tagNav'
import { getCamelCase } from '@/filters/filters'
// import { mapGetters } from 'vuex'
export default {
  components: {
    tagsNav
  },
  data() {
    return {
      activeName: getCamelCase(this.$route.fullPath.split('/')[2]),
      openName: [0],
      menuList: [],
      userName: ''
    }
  },
  // computed: {
  //   ...mapGetters(['userInfo'])
  // },
  created() {
    this.menuList = this.userInfo.menuList
  },
  mounted() {
    this.getName()
    this.userName = this.userInfo.user.mobile
  },
  methods: {
    select(name) {
      this.$router.push({ name: name })
    },
    getName() {
      this.$nextTick(() => {
        this.refsmenu = this.$refs.menuitem
        if (this.refsmenu) {
          this.refsmenu.forEach(item => {
            if (item.active === true) {
              this.openName = [item.$parent.name]
            }
          })
          this.$nextTick(() => {
            this.$refs.menu.updateOpened()
          })
        }
      })
    },
    logout() {
      AccountService.logout().then(data => {
        this.$store.state.tagsNav.visitedTag = []
        localStorage.removeItem('userInfo')
        sessionStorage.removeItem('visitedTag')
        this.$router.push('/login')
      })
    }
  },
  watch: {
    $route() {
      this.activeName = getCamelCase(this.$route.fullPath.split('/')[2])
      this.getName()
    }
  }
}
</script>

<style lang="css" scoped>
.layout-container {
  background: #f5f7f9;
  position: relative;
  overflow: hidden;
}

.layout-container .logo {
  float: left;
  width: 226px;
  height: 64px;
  background: url(/static/img/logo.png) no-repeat center;
  text-indent: 110%;
  white-space: nowrap;
  overflow: hidden;
}

.layout-container .user-img {
  width: 32px;
  height: 32px;
  border-radius: 50%;
}

.layout-container .user-name {
  margin-left: 6px;
  color: #fff;
}

.layout-container .user-divider {
  margin: 0 20px;
  font-size: 18px;
  color: #4973cd;
}

.layout-container .exit {
  display: inline-block;
  vertical-align: middle;
  width: 16px;
  height: 16px;
  background: url(/static/img/icon_exit.png) no-repeat center;
}
</style>


上面 getName 方法是,展开高亮对应的菜单;这里iview提供的相应api updateOpened 官网没有demo,自己摸索一番就能明白个大概。

这里,需要注意的是,整个项目路由命名规则必须统一,不然路由和标签页匹配规则无法成立。我这里路由统一都是path为短横线分隔,对应的name为驼峰命名。也可以采用所有的path和name都为相同的驼峰命名等方案,总之,路由命名不可胡乱起名字,必须有章可循,平时项目养成习惯,很多问题就会有相应思路,很多看似复杂的东西也会迎刃而解。

以上,差不多就这些吧,小细节比较多,具体代码,慢慢分析就好。
整体思路就是, 通过 vuex 存储标签页状态,然后通过特定路由规则去匹配就好。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

知识共享许可协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。