my-pro/src/Pro.vue

<template>
  <my-layout :class="classes" :direction="direction" :west="west" :fit="fixed" :north="north">
    <template v-if="header" v-slot:north>
      <my-navbar v-bind="navbarOptions"
                 @select="handleMenuSelect"
                 :collapsed.sync="collapsed">
        <Breadcrumb v-if="breadcrumbVisible && breadcrumbList && breadcrumbList.length"
                    :class="`my-pro__breadcrumb is-${navbarOptions.theme}`"
                    :data="breadcrumbList"></Breadcrumb>
        <slot v-if="$slots.navbar" name="navbar"></slot>
        <template v-slot:actions>
          <slot name="actions"></slot>
        </template>
      </my-navbar>
    </template>

    <template v-if="sidebarOptions" v-slot:west>
      <my-sidebar v-bind="sidebarOptions"
                  v-clickoutside="handleClickOutside"
                  :menus="sidebarMenus"
                  :menuProps="menuProps"
                  @select="handleMenuSelect"
                  :collapsed.sync="collapsed">
        <slot name="sidebar"></slot>
        <template v-slot:append>
          <slot name="append"></slot>
        </template>
      </my-sidebar>
    </template>

    <Tabs v-if="tab && tabs.length"
          :data="tabs"
          :active="activeTabValue"
          @select="handleTabSelect"
          @icon-click="handleTabClick"
          @command="handleTabCommand"
          @remove="handleTabRemove"></Tabs>

    <div class="my-pro__main" :class="mainClasses">
      <slot></slot>
    </div>

  </my-layout>
</template>

<script>
  /**
   * 后台布局框架组件
   * @module $ui/components/my-pro
   */
  import {MyLayout, MyNavbar, MySidebar} from '$ui'
  import Breadcrumb from './Breadcrumb'
  import Tabs from './Tabs'
  import clickoutside from 'element-ui/lib/utils/clickoutside'
  import responsive from '$ui/utils/responsive'
  import defaultLogo from '$ui/assets/logo.png'

  const {IconAction, UserAction, FullScreenAction, Action} = MyNavbar

  /**
   * 插槽
   * @member slots
   * @property {string} default 默认插槽,定义主体内容,通常放 router-view
   * @property {string} navbar 顶部菜单附加内容
   * @property {string} actions 顶部菜单操作区内容,如放置:IconAction, UserAction, FullScreenAction
   * @property {string} sidebar 左侧菜单前置内容
   * @property {string} append 左侧菜单后置内容
   *
   */
  export default {
    IconAction,
    UserAction,
    FullScreenAction,
    Action,
    name: 'MyPro',
    components: {
      MyLayout,
      MyNavbar,
      MySidebar,
      Breadcrumb,
      Tabs
    },
    directives: {
      clickoutside
    },
    provide() {
      return {
        myPro: this
      }
    },
    /**
     * 属性参数
     * @member props
     * @property {string} [logo] 品牌logo图片地址
     * @property {string} [title=MyWeb] 品牌名称
     * @property {string} [version] 版本号徽标
     * @property {string} [href] 点击logo调转的链接
     * @property {Array} [menus] 菜单数据数组,与MyMenu组件一致
     * @property {string} [menus.icon] 图标className
     * @property {string} [menus.text] 菜单项标题文字
     * @property {number|object} [menus.badge] 徽标配置
     * @property {string} [menus.index] 菜单项标识,可以是路由path
     * @property {string} [menus.trigger] 菜单打开页面方式,可选值:route 路由打开、href 链接打开、blank 新窗口打开链接、event 触发事件
     * @property {boolean} [menus.disabled] 禁用菜单项
     * @property {Item[]} [menus.children] 子菜单
     * @property {boolean} [menus.group] 是否分组
     * @property {string} [menus.title] 分组标题
     * @property {Object} [menuProps] 菜单组件配置,参考MyMenu
     * @property {string} [mode=sidebar] 导航模式: sidebar(侧边菜单布局)、navbar(顶部菜单布局)、both(侧边+顶部菜单布局)
     * @property {Boolean} [sidebarCollaps=false] 默认sidebar是否收起(默认为否)
     * @property {string} [sidebarTheme=light] 侧边栏主题,可选值:'light', 'dark', 'primary', 'gradual'
     * @property {string} [navbarTheme=light] 顶部导航主题,可选值:'light', 'dark', 'primary', 'gradual'
     * @property {Object} [navbarProps] MyNavbar组件附加参数,mode=both 时有效
     * @property {Boolean} [menusLevelSplit] 菜单数据第一层拆分放到navbar,与sidebar联动,mode=both 时有效
     * @property {Boolean} [navbarActive] navbar 菜单激活函数,menusLevelSplit=true时有效
     * @property {boolean} [fixed=false] 固定菜单栏(包括侧边和顶部)
     * @property {boolean} [colorWeak=false] 色弱模式
     * @property {boolean} [collapsible=true] 开启折叠切换
     * @property {boolean} [header=true] 显示头部
     * @property {boolean} [rainbow=true] 顶部导航显示海虹边框
     * @property {Object|Function} [breadcrumb] 面包屑数据按路由path的映射对象或构建函数
     * @property {string|Function} [documentTitle] 默认页面标题文本,如设置该属性,即开启按路由meta的title属性设置页面标题
     * @property {Object|Function} [tab] 默认选项卡导航,如果设置该属性,即开始按路由meta的tab属性设置导航选项卡,对象字段{label, value, icon}
     * @property {string} tab.label 选项卡标题
     * @property {string} tab.value 选项卡标识,实用路由path
     * @property {string|object} [tab.icon] 选项卡图标配置
     */
    props: {
      logo: {
        type: String,
        default: defaultLogo
      },
      title: {
        type: String,
        default: 'MyWeb'
      },
      version: {
        type: String
      },
      href: String,
      menus: {
        type: Array
      },
      menuProps: Object,
      // 导航模式: sidebar(侧边菜单布局)、navbar(顶部菜单布局)、both(侧边+顶部菜单布局)
      mode: {
        type: String,
        default: 'sidebar',
        validator(val) {
          return ['sidebar', 'navbar', 'both'].includes(val)
        }
      },
      sidebarCollaps: {
        type: Boolean,
        default: false
      },
      sidebarTheme: String,
      navbarTheme: String,
      // 顶部菜单,在 mode=both 有效
      navbarProps: Object,
      menusLevelSplit: Boolean,
      // navbar 菜单激活函数,menusLevelSplit=true时有效
      navbarActive: {
        type: Function
      },
      // 固定菜单栏(包括侧边和顶部)
      fixed: Boolean,
      // 色弱模式
      colorWeak: Boolean,
      // 开启折叠切换
      collapsible: {
        type: Boolean,
        default: true
      },
      // 显示头部
      header: {
        type: Boolean,
        default: true
      },
      // 顶部导航显示海虹边框
      rainbow: {
        type: Boolean,
        default: true
      },
      breadcrumb: [Object, Function],
      // 页面默认标题,如果设置,即开启动态页面标题功能
      documentTitle: [String, Function],
      // 初始tab,设置即开始tabs功能
      tab: [Object, Function]
    },
    data() {
      return {
        collapsed: this.sidebarCollaps,
        screen: {},
        breadcrumbList: [],
        tabs: [],
        activeTabValue: ''
      }
    },
    computed: {
      classes() {
        const screen = {}
        Object.keys(this.screen).forEach(n => {
          screen[`is-${n}`] = this.screen[n]
        })
        return {
          'my-pro': true,
          'is-color-weak': this.colorWeak,
          'is-collapsed': this.collapsed,
          [`my-pro--${this.mode}`]: true,
          'is-rainbow': this.rainbow,
          [`my-pro--${this.sidebarTheme}`]: !!this.sidebarTheme,
          'is-fixed': this.fixed,
          'is-menus-level-split': this.menusLevelSplit,
          ...screen
        }
      },
      direction() {
        return this.mode === 'sidebar' ? 'horizontal' : 'vertical'
      },
      west() {
        const {xs} = this.screen
        return {
          width: this.collapsed ? (xs ? 0 : 65) : 256
        }
      },
      north() {
        return {
          height: ((this.mode === 'both' && this.rainbow) ||
            this.mode === 'sidebar' ||
            (this.mode === 'navbar' && this.rainbow))
            ? 61
            : 60
        }
      },
      navbarOptions() {
        let opt = null
        const {xs} = this.screen
        switch (this.mode) {
          case 'sidebar':
            opt = {
              title: null,
              logo: null,
              collapsible: this.collapsible,
              theme: this.navbarTheme,
              shadow: false
            }
            break
          case 'navbar':
            opt = {
              title: xs ? null : this.title,
              logo: this.logo,
              version: xs ? null : this.version,
              href: this.href,
              collapsible: false,
              theme: this.navbarTheme,
              menus: xs ? null : this.menus,
              menuProps: this.menuProps,
              shadow: true
            }
            break
          case 'both':
            opt = {
              title: xs ? null : this.title,
              logo: this.logo,
              version: xs ? null : this.version,
              href: this.href,
              collapsible: !!xs,
              theme: this.navbarTheme,
              shadow: true,
              menus: this.getTopLevelMenus(),
              menuProps: {
                router: true,
                defaultActive: this.defaultNavbarActive()
              },
              ...(this.navbarProps || {})
            }
            break
        }
        return opt
      },
      sidebarOptions() {
        let opt = null
        // const {xs} = this.screen
        switch (this.mode) {
          case 'sidebar':
            opt = {
              title: this.title,
              logo: this.logo,
              href: this.href,
              version: this.version,
              theme: this.sidebarTheme,
              collapsible: false,
              shadow: true
            }
            break
          case 'both':
            opt = {
              title: null,
              logo: null,
              version: null,
              collapsible: this.collapsible,
              theme: this.sidebarTheme,
              shadow: false
            }
            break
        }
        return opt
      },
      breadcrumbVisible() {
        if (!this.breadcrumb) return false
        const {xs} = this.screen
        return !xs
      },
      mainClasses() {
        return {
          'is-fixed': this.fixed,
          'has-tabs': !!this.tab
        }
      },
      sidebarMenus() {
        if (!this.menus) return []
        if (this.mode === 'both' && this.menusLevelSplit) {
          const path = this.$route.path
          const item = this.menus.find(n => path.startsWith(n.index))
          return item?.children || []
        } else {
          return this.menus
        }
      }
    },
    watch: {
      collapsed(val) {
        /**
         * 折叠发送改变时触发
         * @event collapse
         * @param {boolean} collapsed 是否折叠
         */
        this.$emit('collapse', val)
      },
      $route: {
        immediate: true,
        handler() {
          const matched = this.$route.matched.slice(0).pop()
          this.$nextTick(() => {
            this.updateBreadcrumbList(matched)
            const tab = this.collectTab(this.$route.fullPath, matched)
            tab && this.addTab(tab)
          })
          this.updateDocumentTitle(matched)
        }
      },
      tab: {
        immediate: true,
        handler() {
          this.addDefaultTab()
        }
      }
    },
    methods: {
      handleMenuSelect(item) {
        /**
         * 菜单项选中时触发
         * @event select
         * @param {Object} item 菜单项对象
         */
        this.$emit('select', item)
      },
      updateScreen(screen) {
        this.screen = screen
        if (this.mode === 'sidebar') {
          if (this.screen.sm && !this.collapsed) {
            this.collapsed = true
          }
          if (this.screen.lg && this.collapsed) {
            this.collapsed = false
          }
        }

        if (this.mode === 'both') {
          if (this.screen.sm && !this.collapsed) {
            this.collapsed = true
          }
          if (this.screen.lg && this.collapsed) {
            this.collapsed = false
          }
        }


      },
      handleClickOutside() {
        if (this.mode === 'sidebar' && this.screen.xs && !this.collapsed) {
          this.collapsed = true
        }
      },
      updateBreadcrumbList(matched) {
        if (!this.breadcrumb) return
        if (!matched) {
          this.breadcrumbList = null
          return
        }
        if (typeof this.breadcrumb === 'function') {
          this.breadcrumbList = this.breadcrumb(matched)
        } else {
          this.breadcrumbList = this.breadcrumb[matched.path] || []
        }
      },
      updateDocumentTitle(matched) {
        if (!this.documentTitle) return

        document.title = typeof this.documentTitle === 'function'
          ? this.documentTitle(matched)
          : ((matched.meta || {}).title || this.documentTitle)
      },
      addDefaultTab() {
        if (!this.tab) return
        const isFunction = typeof this.tab === 'function'
        if (isFunction) {
          const tab = this.tab()
          tab && this.addTab({
            ...tab,
            closable: false
          })
        } else {
          this.addTab({
            ...this.tab,
            closable: false
          })
        }

      },
      collectTab(fullPath, matched) {
        if (!this.tab) return null
        const isFunction = typeof this.tab === 'function'
        if (isFunction) {
          const tab = this.tab(fullPath, matched)
          if (!tab) return null
          return {
            closable: true,
            ...tab
          }
        } else {
          const isDefault = fullPath === this.tab.value
          const {tab, icon} = matched.meta || {}
          if (!tab) return null
          return {
            label: tab,
            value: fullPath,
            icon: icon,
            path: matched.path,
            closable: true,
            ...(isDefault
              ? {
                ...this.tab,
                closable: false
              }
              : null)
          }
        }


      },
      /**
       * 添加选项卡
       * @method addTab
       * @param {Object} tab 选项卡配置
       * @param {string} tab.label 标题
       * @param {string} tab.value 页面访问路由path
       * @param {string|object} [tab.icon] 图标
       * @param {boolean} [tab.closable] 能否关闭
       * @param {string} [tab.path] 页面路由原始path
       */
      addTab(tab) {
        if (!tab) return
        tab.path = tab.path || tab.value
        const index = this.tabs.findIndex(n => n.value === tab.value || n.path === tab.path)
        if (index > -1) {
          this.tabs.splice(index, 1, tab)
        } else {
          this.tabs.push(tab)
        }
        this.activeTabValue = tab.value

      },
      /**
       * 更新当前选项卡配置
       * @method setTab
       * @param {object} tab 选项卡配置 {label,value,closable,path,icon}
       */
      setTab(tab) {
        this.$nextTick(() => {
          const fullPath = this.$route.fullPath
          const tabIndex = this.tabs.findIndex(t => t.value === fullPath)
          if (tabIndex > -1) {
            const newTab = {
              ...this.tabs[tabIndex],
              ...tab
            }
            this.tabs.splice(tabIndex, 1, newTab)
          }
        })
      },
      /**
       * 关闭选项卡
       * @method closeTabs
       * @param {string|Function} value 选项卡的value 或 path,也可是查找函数,return true 关闭
       */
      closeTabs(value) {
        const filter = typeof value === 'function'
          ? value
          : tab => tab.path === value || tab.value === value
        this.tabs = this.tabs.filter((tab, index) => !filter(tab, index))
      },
      handleTabSelect(tab, index) {
        if (tab.value) {
          this.$router.push(tab.value).catch(e => e)
        }
        /**
         * 选择选项卡时触发
         * @event tab-select
         * @param {object} tab 选项卡配置对象
         * @param {number} index 选项的索引
         */
        this.$emit('tab-select', tab, index)
      },
      handleTabClick(tab, index) {
        this.$emit('tab-click', tab, index)
      },
      handleTabRemove(tab, index) {
        this.tabs.splice(index, 1)
        // 删除的是选择的tab
        if (tab.value === this.activeTabValue) {
          const current = this.tabs[index] || this.tabs[this.tabs.length - 1]
          current && this.$router.push(current.value).catch(e => e)
        }
        /**
         * 关闭选项卡时触发
         * @event tab-remove
         * @param {object} tab 选项卡配置对象
         * @param {number} index 选项的索引
         */
        this.$emit('tab-remove', tab, index)
      },
      handleTabCommand(command, tab, index) {
        let removeTab
        switch (command) {
          case 'left':
            removeTab = this.tabs.splice(1, index - 1)
            this.$emit('tab-remove', removeTab, index - 1)
            break
          case 'right':
            removeTab = this.tabs.splice(index + 1, this.tabs.length)
            this.$emit('tab-remove', removeTab, this.tabs.length)
            break
          case 'other':
            if (index === 0) {
              this.tabs.splice(1)
            } else {
              this.tabs = [this.tabs[0], this.tabs[index]]
            }
            this.$emit('tab-remove-other', this.tabs)
            break
          case 'all':
            this.tabs.splice(1)
            if (this.tabs[0]) {
              this.$router.push(this.tabs[0].value).catch(e => e)
            }
            this.$emit('tab-remove-all', this.tabs)
            break
        }
      },
      getTopLevelMenus() {
        return this.menusLevelSplit
          ? this.menus.map(n => {
            const {icon, index, text} = n
            return {
              icon,
              index,
              text
            }
          })
          : null
      },
      defaultNavbarActive() {
        const paths = this.$route.path.split('/')
        if (paths.length > 2) {
          return paths.slice(0, 2).join('/')
        }
        return null
      }
    },
    created() {
      this.token = responsive.on(e => {
        this.updateScreen(e)
      })


    },
    updated() {
      this.$nextTick(() => {
        const matched = this.$route.matched.slice(0).pop()
        this.updateBreadcrumbList(matched)
      })
    },
    beforeDestroy() {
      this.token && responsive.off(this.token)
    }
  }
</script>