From 484483f0e4379db3b5e5603d35056083a5281a64 Mon Sep 17 00:00:00 2001 From: Sendya <18x@loacg.com> Date: Fri, 5 Nov 2021 00:01:07 +0800 Subject: [PATCH] feat: multi-tab --- public/index.html | 2 +- src/components/MultiTab/APIEnums.js | 17 ++ src/components/MultiTab/MultiTab.jsx | 183 ++++++++++++++++++++++ src/components/MultiTab/MultiTab.vue | 162 ------------------- src/components/MultiTab/RouteAPI.js | 69 ++++++++ src/components/MultiTab/RouteContent.jsx | 89 +++++++++++ src/components/MultiTab/RouteKeepAlive.js | 183 ++++++++++++++++++++++ src/components/MultiTab/index.js | 49 ++---- src/components/MultiTab/index.less | 69 ++++++-- src/config/router.config.js | 1 + src/layouts/BasicLayout.vue | 7 +- 11 files changed, 611 insertions(+), 220 deletions(-) create mode 100644 src/components/MultiTab/APIEnums.js create mode 100644 src/components/MultiTab/MultiTab.jsx delete mode 100644 src/components/MultiTab/MultiTab.vue create mode 100644 src/components/MultiTab/RouteAPI.js create mode 100644 src/components/MultiTab/RouteContent.jsx create mode 100644 src/components/MultiTab/RouteKeepAlive.js diff --git a/public/index.html b/public/index.html index edd7fe6947..fab422d2be 100644 --- a/public/index.html +++ b/public/index.html @@ -18,7 +18,7 @@
-

Pro

+

Ant Design

diff --git a/src/components/MultiTab/APIEnums.js b/src/components/MultiTab/APIEnums.js new file mode 100644 index 0000000000..596a376402 --- /dev/null +++ b/src/components/MultiTab/APIEnums.js @@ -0,0 +1,17 @@ +export const TAB_BINDING = { + TAB_CLOSE: 'hook:tab:ck', + TAB_CLOSE_ALL: 'hook:tab:ca', + TAB_CLOSE_LEFT: 'hook:tab:cl', + TAB_CLOSE_RIGHT: 'hook:tab:cr', + TAB_CLOSE_OTHER: 'hook:tab:co', + TAB_NAME: 'hook:tab:rename', + TAB_ACTIVE: 'hook:tab:active' +} + +export const ROUTE_BINDING = { + R_OPEN: 'hook:open', + R_CLOSE: 'hook:close', + R_ACTIVE: 'hook:active', + R_REFRESH: 'hook:refresh', + R_GET_CACHES: 'hook:caches' +} diff --git a/src/components/MultiTab/MultiTab.jsx b/src/components/MultiTab/MultiTab.jsx new file mode 100644 index 0000000000..ae7210c985 --- /dev/null +++ b/src/components/MultiTab/MultiTab.jsx @@ -0,0 +1,183 @@ +import AppEvent from './events' +import { TAB_BINDING } from './APIEnums' +import { Menu, Dropdown, Button, Icon, Tabs } from 'ant-design-vue' +// import { i18nRender } from '@/locales' +import './index.less' + +const customStyle = { + background: '#FFF', + margin: 0, + paddingLeft: '16px', + paddingTop: '1px' +} + +const i18nRender = (context) => context + +const renderTabMenu = (h, path) => { + const props = { + on: { + click: ({ key, item, domEvent }) => { + // console.log('key', path) + } + } + } + return ( + + 关闭当前标签 + 关闭右侧 + 关闭左侧 + 关闭全部 + + ) +} + +const renderTabDropDown = (h, title, keyPath, handles) => { + const handleReload = () => { + handles['reload'](keyPath) + } + + const menus = renderTabMenu(h, keyPath) + return ( + + + { title } + + + + ) +} + +const MultiTab = { + name: 'MultiTab', + props: { + contentWidth: { + type: Boolean, + default: false + } + }, + data () { + return { + activeKey: '', + pages: [], + fullPathList: [] + } + }, + render (h) { + const { $data: { pages }, contentWidth } = this + const handles = { + cls: (keyPath) => { + // console.log('close', keyPath) + // this.closeThat(keyPath) + this.$tab.close(keyPath, false) + }, + reload: (keyPath) => { + // console.log('reload', keyPath) + this.$tab.refresh(keyPath) + } + } + const tabPanels = pages.map(page => { + const title = page.meta.customTitle || page.meta.title + return ( + 1} + > + ) + }) + + const edit = (targetKey, action) => { + // console.log('editTab:', targetKey, 'action:', action) + this[action](targetKey) + } + + const handleMenuClick = (e) => { + + } + + const controlMenu = ( + + 全部关闭 + 关闭当前 + 关闭其他 + + + ) + + return ( +
+
+ + {tabPanels} + {controlMenu} + +
+
+ ) + }, + created () { + const { pages, fullPathList } = this + AppEvent.$on(TAB_BINDING.TAB_CLOSE, val => { + this.closeThat(val) + }).$on('hook:tab:closeRight', val => { + // console.log('hook:tab:closeRight', val) + }).$on('hook:tab:closeLeft', val => { + // console.log('hook:tab:closeRight', val) + }).$on('hook:tab:closeAll', val => { + // console.log('hook:tab:closeRight', val) + }).$on('hook:tab:rename', val => { + // console.log('hook:tab:rename', val) + }) + + pages.push(this.$route) + fullPathList.push(this.$route.fullPath) + this.activeLastTab() + + this.$watch('$route', newVal => { + const { fullPath, params } = newVal + if (this.activeKey !== fullPath) { + this.activeKey = fullPath + } + if (this.fullPathList.indexOf(fullPath) < 0) { + this.fullPathList.push(fullPath) + if (params && params._tabName) { + const newPage = Object.assign({}, newVal, { + meta: { + customTitle: params._tabName + } + }) + this.pages.push(newPage) + } else { + this.pages.push(newVal) + } + } + }) + this.$watch('activeKey', pathKey => { + this.$router.push({ path: pathKey }) + }) + }, + methods: { + activeLastTab () { + this.activeKey = this.fullPathList[this.fullPathList.length - 1] + }, + remove (targetKey) { + this.closeThat(targetKey) + }, + closeThat (targetKey) { + if (this.fullPathList.length > 1) { + this.pages = this.pages.filter(page => page.fullPath !== targetKey) + this.fullPathList = this.fullPathList.filter(path => path !== targetKey) + this.activeLastTab() + } + } + } +} + +export default MultiTab diff --git a/src/components/MultiTab/MultiTab.vue b/src/components/MultiTab/MultiTab.vue deleted file mode 100644 index bfb6e57a45..0000000000 --- a/src/components/MultiTab/MultiTab.vue +++ /dev/null @@ -1,162 +0,0 @@ - diff --git a/src/components/MultiTab/RouteAPI.js b/src/components/MultiTab/RouteAPI.js new file mode 100644 index 0000000000..e56b4604d2 --- /dev/null +++ b/src/components/MultiTab/RouteAPI.js @@ -0,0 +1,69 @@ +import { ROUTE_BINDING } from './APIEnums' +import AppEvent from './events' + +const RouteAPI = { + /** + * Open a new tab(route) + * 打开一个新标签(路由) + * + * @param config: { + * routeName, + * title + * } + */ + open (config) { + AppEvent.$emit(ROUTE_BINDING.R_OPEN, config) + }, + /** + * Close a tab + * 关闭一个打开的标签 + * 如果标签没打开或找不到标签则不做任何事情 + * + * @param config + * @param cache: bool true 则关闭后会缓存页面,false 反之 + */ + close (config, cache) { + AppEvent.$emit(ROUTE_BINDING.R_CLOSE, { config, isCache: cache }) + }, + /** + * Active a opened tab + * 激活一个已经打开的 tab + * @param config + */ + activeTab (config) { + AppEvent.$emit(ROUTE_BINDING.R_ACTIVE, config) + }, + /** + * Replace current page to new Route + * 替换当前 tab 页面为一个新的页面 + * 注意:这个替换会更新路由地址,但是 tab 的位置不会发生变化 + * 被替换的路由还会被缓存住,下次打开还是缓存时的状态 + * + * @param {*} config + */ + replace (config) { + AppEvent.$emit('hook:replace', config) + }, + /** + * Refresh current tab (clear page cache) + */ + refresh (keyPath) { + AppEvent.$emit(ROUTE_BINDING.R_REFRESH, keyPath) + }, + closeAll () { + AppEvent.$emit('hook:closeAll') + }, + closeOthers () { + AppEvent.$emit('hook:closeOthers') + }, + /** + * Get all cached page + * + * @param callback + */ + caches (callback) { + AppEvent.$emit(ROUTE_BINDING.R_GET_CACHES, callback) + } +} + +export default RouteAPI diff --git a/src/components/MultiTab/RouteContent.jsx b/src/components/MultiTab/RouteContent.jsx new file mode 100644 index 0000000000..29806e7eab --- /dev/null +++ b/src/components/MultiTab/RouteContent.jsx @@ -0,0 +1,89 @@ +/* eslint-disable */ +import { ROUTE_BINDING, TAB_BINDING } from './APIEnums' +import AppEvent from './events' +import RouteAPI from './RouteAPI' +import MultiTab from './MultiTab' +import RouteKeepAlive from './RouteKeepAlive' + +const addAndGet = val => { + return val >= Number.MAX_SAFE_INTEGER ? 0 : ++val +} + +const RouteContent = { + name: 'RouteContent', + data () { + return { + includes: [], + excludes: [], + /* + * Cache: { fullPath : String, snapshot : Number } + * cached: Map + */ + cached: {} + } + }, + render () { + const { + $route: { meta, fullPath }, + includes, + excludes, + cached + } = this + + const handleRef = (ref) => { + this.keepRef = ref + } + console.log('meta', this.$route); + if (meta.keepAlive) { + if (includes.findIndex(item => item === fullPath) === -1) { + includes.push(fullPath) + cached[fullPath] = { + fullPath, + snapshot: 0 + } + } + } + const genKey = cached[fullPath].fullPath + cached[fullPath].snapshot + const props = { + on: { + ref: handleRef + } + } + return ( + + + + ) + }, + created () { + AppEvent.$on(ROUTE_BINDING.R_OPEN, ({ routeName, title, ...rest }) => { + this.$router.push({ name: routeName, params: { '_tabName': title, ...rest } }) + }).$on(ROUTE_BINDING.R_REFRESH, keyPath => { + const { $route: { fullPath } } = this + let key = keyPath || fullPath + const cache = this.cached[key] + this.keepRef.clearCache(key) + cache.snapshot = addAndGet(cache.snapshot) + // how with + this.$forceUpdate() + }).$on(ROUTE_BINDING.R_GET_CACHES, (callback) => { + callback(this.keepRef.allCache()) + }).$on(ROUTE_BINDING.R_CLOSE, val => { + const { config: keyPath, isCache } = val + AppEvent.$emit(TAB_BINDING.TAB_CLOSE, keyPath) + // this.keepRef.clearCache(keyPath) + }) + } +} + +RouteContent.install = function (Vue) { + if (Vue.prototype.$tab) { + return + } + Vue.prototype.$tab = RouteAPI + Vue.component(RouteContent.name, RouteContent) + Vue.component(RouteKeepAlive.name, RouteKeepAlive) + Vue.component(MultiTab.name, MultiTab) +} + +export default RouteContent diff --git a/src/components/MultiTab/RouteKeepAlive.js b/src/components/MultiTab/RouteKeepAlive.js new file mode 100644 index 0000000000..ac8ceef075 --- /dev/null +++ b/src/components/MultiTab/RouteKeepAlive.js @@ -0,0 +1,183 @@ +const _toString = Object.prototype.toString + +const isRegExp = (v) => { + return _toString.call(v) === '[object RegExp]' +} + +const isDef = (v) => { + return v !== undefined && v !== null +} + +const isAsyncPlaceholder = (node) => { + return node.isComment && node.asyncFactory +} + +const remove = (arr, item) => { + if (arr.length) { + const index = arr.indexOf(item) + if (index > -1) { + return arr.splice(index, 1) + } + } +} + +export const getFirstComponentChild = (children) => { + if (Array.isArray(children)) { + for (let i = 0; i < children.length; i++) { + const c = children[i] + if (isDef(c) && (isDef(c.componentOptions) || isAsyncPlaceholder(c))) { + return c + } + } + } +} + +export const getComponentName = (opts) => { + return opts && (opts.Ctor.options.name || opts.tag) +} + +export const getComponentKey = (opts) => { + return opts && (opts.Ctor.cid + (opts.tag ? `::${opts.tag}` : '')) +} + +export const getCurrentRouteKey = ($route) => { + return $route.fullPath +} + +const matches = (pattern, name) => { + if (Array.isArray(pattern)) { + return pattern.indexOf(name) > -1 + } else if (typeof pattern === 'string') { + return pattern.split(',').indexOf(name) > -1 + } else if (isRegExp(pattern)) { + return pattern.test(name) + } + /* istanbul ignore next */ + return false +} + +const pruneCache = (keepAliveInstance, cacheKey) => { + const { cache, keys, _vnode } = keepAliveInstance + for (const key in cache) { + const cachedNode = cache[key] + if (cachedNode) { + if (cacheKey === key) { + pruneCacheEntry(cache, key, keys, _vnode) + } + } + } +} + +const pruneCacheEntry = ( + cache, + key, + keys, + current +) => { + const cached = cache[key] + if (cached && (!current || cached.tag !== current.tag)) { + cached.componentInstance.$destroy() + } + cache[key] = null + remove(keys, key) +} + +/* const findCached = (cached, vnode) => { + +} */ + +const RouteKeepAlive = { + name: 'RouteKeepAlive', + abstract: true, + props: { + include: { + type: [String, Array], + default: '' + }, + exclude: { + type: [String, Array], + default: '' + }, + max: { + type: [String, Number], + default: null + } + }, + + created () { + this.cache = Object.create(null) + this.keys = [] + }, + + destroyed () { + for (const key in this.cache) { + pruneCacheEntry(this.cache, key, this.keys) + } + }, + + mounted () { + this.$watch('include', val => { + pruneCache(this, val) + }) + this.$watch('exclude', val => { + pruneCache(this, val) + }) + }, + + methods: { + allCache () { + return this.cache + }, + clearCache (key) { + pruneCache(this, key) + this.$router.replace(this.$router.currentRoute) + } + }, + + render () { + this.$emit('ref', this) + const slot = this.$slots.default + const vnode = getFirstComponentChild(slot) + // const vnode = cloneVNode(defVNode, true) + const componentOptions = vnode && vnode.componentOptions + + const key = getCurrentRouteKey(this.$route) + if (componentOptions) { + // check pattern + const { include, exclude } = this + if ( + // not included + (include && (!key || !matches(include, key))) || + // excluded + (exclude && key && matches(exclude, key)) + ) { + return vnode + } + + const { cache, keys } = this + if (cache[key]) { + vnode.componentInstance = cache[key].componentInstance + // make current key freshest + remove(keys, key) + keys.push(key) + } else { + // vnode = cloneVNode(vnode, true) + + cache[key] = vnode + keys.push(key) + // prune oldest entry + if (this.max && keys.length > parseInt(this.max)) { + pruneCacheEntry(cache, keys[0], keys, this._vnode) + } + } + vnode.data.keepAlive = true + } + return vnode || (slot && slot[0]) + } +} + +RouteKeepAlive.install = function (Vue) { + Vue.component(RouteKeepAlive.name, RouteKeepAlive) +} + +export default RouteKeepAlive diff --git a/src/components/MultiTab/index.js b/src/components/MultiTab/index.js index 02a1c77d64..2d9402703a 100644 --- a/src/components/MultiTab/index.js +++ b/src/components/MultiTab/index.js @@ -1,40 +1,15 @@ -import events from './events' +import AppEvent from './events' +import RouteAPI from './RouteAPI' import MultiTab from './MultiTab' -import './index.less' +import * as APIEnums from './APIEnums' +import RouteKeepAlive from './RouteKeepAlive' +import RouteContent from './RouteContent' -const api = { - /** - * open new tab on route fullPath - * @param config - */ - open: function (config) { - events.$emit('open', config) - }, - rename: function (key, name) { - events.$emit('rename', { key: key, name: name }) - }, - /** - * close current page - */ - closeCurrentPage: function () { - this.close() - }, - /** - * close route fullPath tab - * @param config - */ - close: function (config) { - events.$emit('close', config) - } +export { + AppEvent, + RouteAPI, + MultiTab, + RouteKeepAlive, + APIEnums } - -MultiTab.install = function (Vue) { - if (Vue.prototype.$multiTab) { - return - } - api.instance = events - Vue.prototype.$multiTab = api - Vue.component('multi-tab', MultiTab) -} - -export default MultiTab +export default RouteContent diff --git a/src/components/MultiTab/index.less b/src/components/MultiTab/index.less index 773e3af319..45cbeeb23f 100644 --- a/src/components/MultiTab/index.less +++ b/src/components/MultiTab/index.less @@ -1,25 +1,60 @@ -@import '../index'; +@import "~ant-design-vue/es/style/themes/default"; -@multi-tab-prefix-cls: ~"@{ant-pro-prefix}-multi-tab"; -@multi-tab-wrapper-prefix-cls: ~"@{ant-pro-prefix}-multi-tab-wrapper"; +@multi-tab-prefix-cls: ~"ant-pro-multi-tab"; +@multi-tab-wrapper-prefix-cls: ~"ant-pro-multi-tab-wrapper"; -/* -.topmenu .@{multi-tab-prefix-cls} { - max-width: 1200px; - margin: -23px auto 24px auto; -} -*/ .@{multi-tab-prefix-cls} { - margin: -23px -24px 24px -24px; background: #fff; -} + padding-top: 10px; + margin: -24px; + margin-bottom: 24px; -.topmenu .@{multi-tab-wrapper-prefix-cls} { - max-width: 1200px; - margin: 0 auto; + .ant-tabs.ant-tabs-card .ant-tabs-card-bar .ant-tabs-tab-active { + padding-bottom: 0; + } + .ant-tabs-nav .ant-tabs-tab .anticon { + margin-right: 4px; + &:last-child { + margin-right: 0; + } + } + .ant-pro-multi-tab-title { + user-select: none; + margin-right: 6px; + font-weight: 500; + } + .ant-pro-multi-tab-icon { + height: @font-size-base; + overflow: hidden; + color: @text-color-secondary; + font-size: @font-size-sm; + vertical-align: middle; + transition: all 0.3s; + &:hover { + color: @heading-color; + } + } } -.topmenu.content-width-Fluid .@{multi-tab-wrapper-prefix-cls} { - max-width: 100%; - margin: 0 auto; +.topmenu { + .@{multi-tab-prefix-cls} { + border-bottom: 1px solid #e8e8e8; + + .@{multi-tab-wrapper-prefix-cls} { + max-width: 100%; + margin: 0 auto; + } + + &.wide .@{multi-tab-wrapper-prefix-cls} { + max-width: 1200px; + margin: 0 auto; + } + + .ant-tabs { + height: 39px; + } + .ant-tabs-bar { + border-bottom: 0; + } + } } diff --git a/src/config/router.config.js b/src/config/router.config.js index 0054b22418..52d9b7ed80 100644 --- a/src/config/router.config.js +++ b/src/config/router.config.js @@ -1,6 +1,7 @@ // eslint-disable-next-line import { UserLayout, BasicLayout, BlankLayout } from '@/layouts' import { bxAnaalyse } from '@/core/icons' +// import RouteContent from '@/components/MultiTab' const RouteView = { name: 'RouteView', diff --git a/src/layouts/BasicLayout.vue b/src/layouts/BasicLayout.vue index 0784393760..64e84df4aa 100644 --- a/src/layouts/BasicLayout.vue +++ b/src/layouts/BasicLayout.vue @@ -48,7 +48,9 @@ - + + + @@ -57,7 +59,6 @@ import { SettingDrawer, updateTheme } from '@ant-design-vue/pro-layout' import { i18nRender } from '@/locales' import { mapState } from 'vuex' import { CONTENT_WIDTH_TYPE, SIDEBAR_TYPE, TOGGLE_MOBILE_TYPE } from '@/store/mutation-types' - import defaultSettings from '@/config/defaultSettings' import RightContent from '@/components/GlobalHeader/RightContent' import GlobalFooter from '@/components/GlobalFooter' @@ -72,7 +73,7 @@ export default { GlobalFooter, LogoSvg, Ads - }, +}, data () { return { // preview.pro.antdv.com only use.