FrontendRefactoring:标签栏可以展示多个菜单
This commit is contained in:
parent
58632033b5
commit
3288829a01
@ -107,6 +107,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顶部浏览器式标签栏存在时,侧边栏让出顶部高度,避免被标签栏遮住
|
||||||
|
&.has-top-tabs {
|
||||||
|
.sidebar-container {
|
||||||
|
top: $top-tabs-height;
|
||||||
|
height: calc(100% - #{$top-tabs-height});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hideSidebar {
|
.hideSidebar {
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
width: 54px !important;
|
width: 54px !important;
|
||||||
|
|||||||
@ -38,6 +38,10 @@ $base-sub-menu-hover:#001528;
|
|||||||
|
|
||||||
$base-sidebar-width: 200px;
|
$base-sidebar-width: 200px;
|
||||||
|
|
||||||
|
// 顶部浏览器式标签栏高度(固定在页面最顶部,横跨侧边栏)
|
||||||
|
// 取 50px 与 navbar 同高,才能放得下侧边栏原有的 42px logo + 24px 标题
|
||||||
|
$top-tabs-height: 50px;
|
||||||
|
|
||||||
// the :export directive is the magic sauce for webpack
|
// the :export directive is the magic sauce for webpack
|
||||||
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
|
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
|
||||||
:export {
|
:export {
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '~@/assets/styles/variables.scss';
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
/* 50= navbar 50 */
|
/* 50= navbar 50 */
|
||||||
min-height: calc(100vh - 50px);
|
min-height: calc(100vh - 50px);
|
||||||
@ -39,14 +41,10 @@ export default {
|
|||||||
padding-top: 50px;
|
padding-top: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hasTagsView {
|
.has-top-tabs {
|
||||||
.app-main {
|
.app-main {
|
||||||
/* 84 = navbar + tags-view = 50 + 34 */
|
/* 90 = 顶部标签栏 + navbar = 40 + 50 */
|
||||||
min-height: calc(100vh - 84px);
|
min-height: calc(100vh - 50px - #{$top-tabs-height});
|
||||||
}
|
|
||||||
|
|
||||||
.fixed-header + .app-main {
|
|
||||||
padding-top: 84px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sidebar-logo-container" :class="{ collapse: collapse }" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
|
<div class="sidebar-logo-container" :class="{ collapse: collapse, compact: compact }" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
|
||||||
<transition name="sidebarLogoFade">
|
<transition name="sidebarLogoFade">
|
||||||
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
|
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
|
||||||
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
||||||
@ -24,6 +24,11 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
// 内联在顶部标签栏左侧展示(矮行,去掉侧边栏那套 50px 高 + 上边距的排布)
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
variables() {
|
variables() {
|
||||||
@ -47,6 +52,8 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '~@/assets/styles/variables.scss';
|
||||||
|
|
||||||
.sidebarLogoFade-enter-active {
|
.sidebarLogoFade-enter-active {
|
||||||
transition: opacity 1.5s;
|
transition: opacity 1.5s;
|
||||||
}
|
}
|
||||||
@ -93,5 +100,40 @@ export default {
|
|||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顶部标签栏左侧的 logo 区:整行高度、背景色与标签栏/侧边栏一致(由 inline style 提供)
|
||||||
|
// 尺寸与侧边栏内的 logo 保持一致(42px 图标 + 24px 标题),只是去掉侧边栏那 15px 上边距
|
||||||
|
&.compact {
|
||||||
|
height: $top-tabs-height;
|
||||||
|
line-height: $top-tabs-height;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
& .sidebar-logo-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
& .sidebar-logo {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .sidebar-title {
|
||||||
|
margin-left: 8px;
|
||||||
|
line-height: $top-tabs-height;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧边栏收起时只留图标,居中显示
|
||||||
|
&.collapse .sidebar-logo-link {
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -39,7 +39,15 @@ export default {
|
|||||||
return path;
|
return path;
|
||||||
},
|
},
|
||||||
showLogo() {
|
showLogo() {
|
||||||
return this.$store.state.settings.sidebarLogo;
|
// 顶部标签栏开启时,logo 由标签栏左侧承载,侧边栏不再重复渲染(否则会被标签栏遮住);
|
||||||
|
// 移动端标签栏不展示 logo,因此侧边栏保留
|
||||||
|
if (!this.settings.sidebarLogo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.settings.tagsView) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.$store.state.app.device === 'mobile';
|
||||||
},
|
},
|
||||||
variables() {
|
variables() {
|
||||||
return variables;
|
return variables;
|
||||||
|
|||||||
@ -77,6 +77,8 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '~@/assets/styles/variables.scss';
|
||||||
|
|
||||||
.scroll-container {
|
.scroll-container {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -87,7 +89,29 @@ export default {
|
|||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
}
|
}
|
||||||
.el-scrollbar__wrap {
|
.el-scrollbar__wrap {
|
||||||
height: 49px;
|
height: $top-tabs-height;
|
||||||
|
// 关键:应用全局定义了 ::-webkit-scrollbar{width/height:6px}(index.scss),
|
||||||
|
// element-ui 因此探测到"经典滚动条",会给 wrap 保留 overflow:scroll 并塞进
|
||||||
|
// margin-bottom/right:-6px,于是底部出现占位 6px 的原生横向滚动条,
|
||||||
|
// 把满高的标签盖掉 6px、露出条底色(表现为标签下方一条缝)。
|
||||||
|
// 改 hidden 后既不留位也不画原生滚动条;横向滚动仍由 handleScroll/moveToTarget
|
||||||
|
// 直接改 scrollLeft 驱动,功能不受影响。
|
||||||
|
// margin 是 element-ui 写在内联 style 上的,必须 !important 才能覆盖。
|
||||||
|
// 同源处理可参考 assets/styles/sidebar.scss 里的 .scrollbar-wrapper{overflow-x:hidden!important}
|
||||||
|
overflow: hidden !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
// wrap 未带 --hidden-default 时 element-ui 会补 15px 下内边距,
|
||||||
|
// 会让内容盒只剩 35px,标签溢出让 element-ui 误算出竖向滚动条;这里一并清零
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
// 深色标签栏下默认的深色滚动条几乎看不见,跟随标签栏配色
|
||||||
|
.el-scrollbar__thumb {
|
||||||
|
background-color: var(--top-tabs-line, rgba(0, 0, 0, 0.3));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--top-tabs-fg-dim, rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,28 +2,70 @@
|
|||||||
<div id="tags-view-container" class="tags-view-container">
|
<div id="tags-view-container" class="tags-view-container">
|
||||||
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
|
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="tag in visitedViews"
|
v-for="(tag, index) in visibleTags"
|
||||||
ref="tag"
|
ref="tag"
|
||||||
:key="tag.path"
|
:key="tag.path"
|
||||||
:class="isActive(tag)?'active':''"
|
:class="tagClass(tag, index)"
|
||||||
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
|
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
|
||||||
tag="span"
|
tag="span"
|
||||||
class="tags-view-item"
|
class="tags-view-item"
|
||||||
:style="activeStyle(tag)"
|
|
||||||
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
|
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
|
||||||
@contextmenu.prevent.native="openMenu(tag,$event)"
|
@contextmenu.prevent.native="openMenu(tag,$event)"
|
||||||
>
|
>
|
||||||
{{ tag.title }}
|
<svg v-if="isAffix(tag)" class="tab-pin" viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M4 1h4v3.2l1.6 1.6v1.2H6.7v3.4l-.7.6-.7-.6V7H2.4V5.8L4 4.2V1z" />
|
||||||
|
</svg>
|
||||||
|
<span class="tab-title">{{ tag.title }}</span>
|
||||||
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
|
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<!-- 被挤出队列的老标签收进「更多」,按钮紧跟在最后一个可见标签右侧,
|
||||||
|
随标签一起横向滚动。判据用 overflowTags 而不是「标签总数超上限」,
|
||||||
|
保证按钮存在 ⇔ 面板里确实有内容 -->
|
||||||
|
<div
|
||||||
|
v-if="overflowTags.length"
|
||||||
|
ref="moreBtn"
|
||||||
|
class="tags-view-more"
|
||||||
|
:class="{ 'is-open': moreVisible, 'is-active': activeInOverflow }"
|
||||||
|
@click.stop="toggleMore"
|
||||||
|
>
|
||||||
|
更多
|
||||||
|
<i class="el-icon-caret-bottom more-caret" />
|
||||||
|
</div>
|
||||||
</scroll-pane>
|
</scroll-pane>
|
||||||
|
<!-- 面板必须挂在滚动容器之外:scroll-pane 的 wrap 是 overflow:hidden,绝对定位的
|
||||||
|
面板放在里面会被裁掉;left 由 toggleMore 按按钮实际位置算出来 -->
|
||||||
|
<ul v-show="moreVisible" class="more-panel" :style="{ left: moreLeft + 'px' }">
|
||||||
|
<li
|
||||||
|
v-for="tag in overflowTags"
|
||||||
|
:key="tag.path"
|
||||||
|
:class="{ 'is-active': isActive(tag) }"
|
||||||
|
:title="tag.title"
|
||||||
|
@click="goTag(tag)"
|
||||||
|
>
|
||||||
|
<span class="more-title">{{ tag.title }}</span>
|
||||||
|
<i v-if="!isAffix(tag)" class="el-icon-close" @click.stop="closeSelectedTag(tag)" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
|
<ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
|
||||||
<li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
|
<li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
|
||||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前</li>
|
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前标签</li>
|
||||||
<li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他</li>
|
<li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他标签</li>
|
||||||
<li v-if="!isFirstView()" @click="closeLeftTags"><i class="el-icon-back"></i> 关闭左侧</li>
|
<li v-if="!isFirstView()" @click="closeLeftTags"><i class="el-icon-back"></i> 关闭左侧</li>
|
||||||
<li v-if="!isLastView()" @click="closeRightTags"><i class="el-icon-right"></i> 关闭右侧</li>
|
<li v-if="!isLastView()" @click="closeRightTags"><i class="el-icon-right"></i> 关闭右侧</li>
|
||||||
<li @click="closeAllTags(selectedTag)"><i class="el-icon-circle-close"></i> 全部关闭</li>
|
<li @click="closeAllTags(selectedTag)"><i class="el-icon-circle-close"></i> 全部关闭</li>
|
||||||
|
<li v-if="!isAffix(selectedTag) || isPinned(selectedTag)" class="menu-divider" />
|
||||||
|
<li v-if="!isAffix(selectedTag)" @click="pinSelectedTag">
|
||||||
|
<svg class="menu-pin" viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M4 1h4v3.2l1.6 1.6v1.2H6.7v3.4l-.7.6-.7-.6V7H2.4V5.8L4 4.2V1z" />
|
||||||
|
</svg>
|
||||||
|
固定标签到任务栏
|
||||||
|
</li>
|
||||||
|
<li v-else-if="isPinned(selectedTag)" @click="unpinSelectedTag">
|
||||||
|
<svg class="menu-pin is-off" viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M4 1h4v3.2l1.6 1.6v1.2H6.7v3.4l-.7.6-.7-.6V7H2.4V5.8L4 4.2V1z" />
|
||||||
|
</svg>
|
||||||
|
取消固定
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -32,6 +74,12 @@
|
|||||||
import ScrollPane from './ScrollPane'
|
import ScrollPane from './ScrollPane'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
|
// 标签栏最多平铺多少个标签(含固定区)。超出的按队列规则挤进「更多」下拉:
|
||||||
|
// 最近打开的留在栏上,最早打开的移入「更多」——固定区不参与淘汰
|
||||||
|
const MAX_VISIBLE_TAGS = 14
|
||||||
|
// 「更多」下拉面板宽度,用于把面板夹在标签栏可视范围内(与样式里的 width 保持一致)
|
||||||
|
const MORE_PANEL_WIDTH = 200
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { ScrollPane },
|
components: { ScrollPane },
|
||||||
data() {
|
data() {
|
||||||
@ -40,7 +88,11 @@ export default {
|
|||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
selectedTag: {},
|
selectedTag: {},
|
||||||
affixTags: []
|
affixTags: [],
|
||||||
|
// 「更多」下拉是否展开
|
||||||
|
moreVisible: false,
|
||||||
|
// 「更多」下拉的横向位置(相对 #tags-view-container),展开时按按钮实际位置计算
|
||||||
|
moreLeft: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -50,8 +102,39 @@ export default {
|
|||||||
routes() {
|
routes() {
|
||||||
return this.$store.state.permission.routes
|
return this.$store.state.permission.routes
|
||||||
},
|
},
|
||||||
theme() {
|
// 固定区标签数(路由声明的 affix + 用户右键固定的)
|
||||||
return this.$store.state.settings.theme;
|
affixCount() {
|
||||||
|
return this.visitedViews.filter(tag => this.isAffix(tag)).length
|
||||||
|
},
|
||||||
|
// 平铺在标签栏上的标签:固定区优先占位,剩余名额给「最近打开的标签」,
|
||||||
|
// 名额之外的老标签按队列规则挤出到「更多」——新开的进栏、最老的移出,
|
||||||
|
// 这样刚点开的页面永远看得见,符合标签栏的使用直觉
|
||||||
|
visibleTags() {
|
||||||
|
const all = this.visitedViews
|
||||||
|
if (all.length <= MAX_VISIBLE_TAGS) {
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
const slots = MAX_VISIBLE_TAGS - this.affixCount
|
||||||
|
// 名额为 0(固定标签自己就占满了)时不放任何普通标签;
|
||||||
|
// 注意不能写成 Math.max(0, slots) + slice(-slots),slice(-0) 等价于 slice(0) 会返回全部
|
||||||
|
const kept = new Set(slots > 0 ? all.filter(tag => !this.isAffix(tag)).slice(-slots) : [])
|
||||||
|
// 用 filter 保序:栏上始终是「固定区 + 打开顺序」,不会因淘汰而重排
|
||||||
|
return all.filter(tag => this.isAffix(tag) || kept.has(tag))
|
||||||
|
},
|
||||||
|
// 收进「更多」的标签:被挤出队列的那些,仍按打开顺序排列(最早的排最前)。
|
||||||
|
// 空数组 = 没有东西被收起,它同时也是「更多」按钮的显隐判据
|
||||||
|
overflowTags() {
|
||||||
|
const visible = new Set(this.visibleTags)
|
||||||
|
return this.visitedViews.filter(tag => !visible.has(tag))
|
||||||
|
},
|
||||||
|
// 标签数量:增删路径很多(关闭当前/其他/左侧/右侧/全部、固定、恢复),
|
||||||
|
// 统一监听这个数字来收回「更多」面板,比在每个关闭方法里各写一次可靠
|
||||||
|
visitedCount() {
|
||||||
|
return this.visitedViews.length
|
||||||
|
},
|
||||||
|
// 当前页落到了「更多」里(点开一个老标签时会发生),按钮要给出提示
|
||||||
|
activeInOverflow() {
|
||||||
|
return this.overflowTags.some(tag => this.isActive(tag))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -59,38 +142,65 @@ export default {
|
|||||||
this.addTags()
|
this.addTags()
|
||||||
this.moveToCurrentTag()
|
this.moveToCurrentTag()
|
||||||
},
|
},
|
||||||
|
visitedCount() {
|
||||||
|
this.syncMoreVisibility()
|
||||||
|
},
|
||||||
visible(value) {
|
visible(value) {
|
||||||
if (value) {
|
if (value) {
|
||||||
document.body.addEventListener('click', this.closeMenu)
|
document.body.addEventListener('click', this.closeMenu)
|
||||||
} else {
|
} else {
|
||||||
document.body.removeEventListener('click', this.closeMenu)
|
document.body.removeEventListener('click', this.closeMenu)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
moreVisible(value) {
|
||||||
|
if (value) {
|
||||||
|
document.body.addEventListener('click', this.closeMore)
|
||||||
|
} else {
|
||||||
|
document.body.removeEventListener('click', this.closeMore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initTags()
|
this.initTags()
|
||||||
this.addTags()
|
this.addTags()
|
||||||
},
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
document.body.removeEventListener('click', this.closeMenu)
|
||||||
|
document.body.removeEventListener('click', this.closeMore)
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
isActive(route) {
|
isActive(route) {
|
||||||
return route.path === this.$route.path
|
return route.path === this.$route.path
|
||||||
},
|
},
|
||||||
activeStyle(tag) {
|
tagClass(tag, index) {
|
||||||
if (!this.isActive(tag)) return {};
|
return [
|
||||||
return {
|
this.isActive(tag) ? 'active' : '',
|
||||||
"background-color": this.theme,
|
this.isAffix(tag) ? 'is-fixed' : '',
|
||||||
"border-color": this.theme
|
this.isFixedBlockEnd(index) ? 'fixed-block-end' : ''
|
||||||
};
|
]
|
||||||
},
|
},
|
||||||
isAffix(tag) {
|
isAffix(tag) {
|
||||||
return tag.meta && tag.meta.affix
|
return tag.meta && tag.meta.affix
|
||||||
},
|
},
|
||||||
|
// 用户右键固定到任务栏的标签
|
||||||
|
isPinned(tag) {
|
||||||
|
return !!(tag.meta && tag.meta.pinned)
|
||||||
|
},
|
||||||
|
// 固定区最后一个标签,右侧画分隔线,与普通标签区分
|
||||||
|
// 注意:index 来自 visibleTags,这里必须用同一份列表取值,否则会错位
|
||||||
|
isFixedBlockEnd(index) {
|
||||||
|
const tags = this.visibleTags
|
||||||
|
const next = tags[index + 1]
|
||||||
|
return !!this.isAffix(tags[index]) && (!next || !this.isAffix(next))
|
||||||
|
},
|
||||||
isFirstView() {
|
isFirstView() {
|
||||||
try {
|
// 可视区里第一个非固定标签,它左侧只剩固定区,所以「关闭左侧」对它无意义
|
||||||
return this.selectedTag.fullPath === this.visitedViews[1].fullPath || this.selectedTag.fullPath === '/index'
|
const tags = this.visibleTags
|
||||||
} catch (err) {
|
const index = tags.findIndex(tag => !this.isAffix(tag))
|
||||||
return false
|
if (index === -1) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
return this.selectedTag.fullPath === tags[index].fullPath || this.selectedTag.fullPath === '/index'
|
||||||
},
|
},
|
||||||
isLastView() {
|
isLastView() {
|
||||||
try {
|
try {
|
||||||
@ -122,12 +232,22 @@ export default {
|
|||||||
},
|
},
|
||||||
initTags() {
|
initTags() {
|
||||||
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
|
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
|
||||||
|
const affixPaths = affixTags.map(tag => tag.path)
|
||||||
|
// 1. 路由自身声明的 affix 标签(如首页),永远排在最左侧固定区
|
||||||
for (const tag of affixTags) {
|
for (const tag of affixTags) {
|
||||||
// Must have tag name
|
// Must have tag name
|
||||||
if (tag.name) {
|
if (tag.name) {
|
||||||
this.$store.dispatch('tagsView/addVisitedView', tag)
|
this.$store.dispatch('tagsView/addVisitedView', tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 2. 恢复上次会话「固定到任务栏」的标签(localStorage),保证刷新/重新登录后仍在固定区
|
||||||
|
this.$store.state.tagsView.pinnedViews
|
||||||
|
.filter(tag => tag.name && !affixPaths.includes(tag.path))
|
||||||
|
.forEach(tag => {
|
||||||
|
this.$store.dispatch('tagsView/addView', Object.assign({}, tag, {
|
||||||
|
meta: Object.assign({}, tag.meta, { affix: true, pinned: true })
|
||||||
|
}))
|
||||||
|
})
|
||||||
},
|
},
|
||||||
addTags() {
|
addTags() {
|
||||||
const { name } = this.$route
|
const { name } = this.$route
|
||||||
@ -195,6 +315,15 @@ export default {
|
|||||||
this.toLastView(visitedViews, view)
|
this.toLastView(visitedViews, view)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
// 固定到标签栏最左侧的固定区,且不可关闭、刷新后仍存在
|
||||||
|
pinSelectedTag() {
|
||||||
|
this.$store.dispatch('tagsView/pinView', this.selectedTag)
|
||||||
|
this.closeMenu()
|
||||||
|
},
|
||||||
|
unpinSelectedTag() {
|
||||||
|
this.$store.dispatch('tagsView/unpinView', this.selectedTag)
|
||||||
|
this.closeMenu()
|
||||||
|
},
|
||||||
toLastView(visitedViews, view) {
|
toLastView(visitedViews, view) {
|
||||||
const latestView = visitedViews.slice(-1)[0]
|
const latestView = visitedViews.slice(-1)[0]
|
||||||
if (latestView) {
|
if (latestView) {
|
||||||
@ -212,6 +341,7 @@ export default {
|
|||||||
},
|
},
|
||||||
openMenu(tag, e) {
|
openMenu(tag, e) {
|
||||||
const menuMinWidth = 105
|
const menuMinWidth = 105
|
||||||
|
const menuHeight = 260
|
||||||
const offsetLeft = this.$el.getBoundingClientRect().left // container margin left
|
const offsetLeft = this.$el.getBoundingClientRect().left // container margin left
|
||||||
const offsetWidth = this.$el.offsetWidth // container width
|
const offsetWidth = this.$el.offsetWidth // container width
|
||||||
const maxLeft = offsetWidth - menuMinWidth // left boundary
|
const maxLeft = offsetWidth - menuMinWidth // left boundary
|
||||||
@ -223,64 +353,270 @@ export default {
|
|||||||
this.left = left
|
this.left = left
|
||||||
}
|
}
|
||||||
|
|
||||||
this.top = e.clientY
|
// 标签栏固定在页面顶部,clientY 即为容器内相对纵坐标;超屏时向上收
|
||||||
|
this.top = Math.min(e.clientY, window.innerHeight - menuHeight)
|
||||||
this.visible = true
|
this.visible = true
|
||||||
this.selectedTag = tag
|
this.selectedTag = tag
|
||||||
},
|
},
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
this.visible = false
|
this.visible = false
|
||||||
},
|
},
|
||||||
|
closeMore() {
|
||||||
|
this.moreVisible = false
|
||||||
|
},
|
||||||
|
// 溢出消失(标签被关到上限以内)时收回「更多」面板,避免留一个指向空列表的下拉
|
||||||
|
syncMoreVisibility() {
|
||||||
|
if (!this.overflowTags.length) {
|
||||||
|
this.moreVisible = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleMore() {
|
||||||
|
if (this.moreVisible) {
|
||||||
|
this.moreVisible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 与右键菜单互斥,避免两个浮层叠在一起
|
||||||
|
this.closeMenu()
|
||||||
|
this.updateMorePosition()
|
||||||
|
this.moreVisible = true
|
||||||
|
},
|
||||||
|
// 面板挂在滚动容器外,位置要按按钮当前所在位置实时算(按钮会随标签横向滚动)
|
||||||
|
updateMorePosition() {
|
||||||
|
const btn = this.$refs.moreBtn
|
||||||
|
if (!btn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const containerRect = this.$el.getBoundingClientRect()
|
||||||
|
const btnRect = btn.getBoundingClientRect()
|
||||||
|
const left = btnRect.left - containerRect.left
|
||||||
|
// 按钮贴到右端时面板会顶出可视区,向左收回来
|
||||||
|
this.moreLeft = Math.max(0, Math.min(left, containerRect.width - MORE_PANEL_WIDTH))
|
||||||
|
},
|
||||||
|
goTag(tag) {
|
||||||
|
this.moreVisible = false
|
||||||
|
if (this.isActive(tag)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$router.push({ path: tag.path, query: tag.query, fullPath: tag.fullPath }).catch(() => {})
|
||||||
|
},
|
||||||
handleScroll() {
|
handleScroll() {
|
||||||
this.closeMenu()
|
this.closeMenu()
|
||||||
|
this.closeMore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '~@/assets/styles/variables.scss';
|
||||||
|
|
||||||
.tags-view-container {
|
.tags-view-container {
|
||||||
height: 34px;
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #d8dce5;
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
|
|
||||||
.tags-view-wrapper {
|
.tags-view-wrapper {
|
||||||
.tags-view-item {
|
height: 100%;
|
||||||
display: inline-block;
|
// 行高归零,让标签以整行高度排布,不被行框撑出多余空白
|
||||||
position: relative;
|
line-height: 0;
|
||||||
cursor: pointer;
|
}
|
||||||
|
|
||||||
|
.tags-view-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: $top-tabs-height;
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--top-tabs-fg-dim, rgba(255, 255, 255, 0.7));
|
||||||
|
font-size: 13px;
|
||||||
|
// 外层 line-height 归零压制行框,标签自身要恢复行高,否则标题文字被压成 0 高
|
||||||
|
line-height: 1.4;
|
||||||
|
max-width: 180px;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--top-tabs-hover, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--top-tabs-fg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pin {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
fill: currentColor;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定到任务栏的标签:不铺底色,只用主题色图钉 + 白字与普通标签区分
|
||||||
|
&.is-fixed {
|
||||||
|
color: var(--top-tabs-fg, #fff);
|
||||||
|
max-width: 150px;
|
||||||
|
|
||||||
|
.tab-pin {
|
||||||
|
fill: var(--current-color, #409eff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前标签:白底深字,与下方白色 navbar 连成一体
|
||||||
|
&.active {
|
||||||
|
background-color: var(--top-tabs-active-bg, #fff);
|
||||||
|
color: var(--top-tabs-active-fg, #303133);
|
||||||
|
|
||||||
|
.tab-pin {
|
||||||
|
fill: var(--current-color, #409eff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定区与普通标签之间的分隔线
|
||||||
|
&.fixed-block-end::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 0;
|
||||||
|
width: 1px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
line-height: 26px;
|
background: var(--top-tabs-line, rgba(255, 255, 255, 0.12));
|
||||||
border: 1px solid #d8dce5;
|
}
|
||||||
color: #495060;
|
}
|
||||||
background: #fff;
|
|
||||||
padding: 0 8px;
|
// 「更多」入口:超出上限的标签收在这里,按钮紧跟在最后一个可见标签右侧
|
||||||
|
.tags-view-more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: $top-tabs-height;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
// 外层 .tags-view-wrapper 的 line-height 归零,这里要恢复,否则文字被压成 0 高
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--top-tabs-fg-dim, rgba(255, 255, 255, 0.7));
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-open {
|
||||||
|
background: var(--top-tabs-hover, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--top-tabs-fg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-caret {
|
||||||
|
margin-left: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 5px;
|
transition: transform 0.2s;
|
||||||
margin-top: 4px;
|
}
|
||||||
&:first-of-type {
|
|
||||||
margin-left: 15px;
|
&.is-open .more-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前页正好被收进面板:用主题色圆点提示,否则栏上完全没有"当前页"的痕迹
|
||||||
|
&.is-active {
|
||||||
|
color: var(--top-tabs-fg, #fff);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--current-color, #409eff);
|
||||||
}
|
}
|
||||||
&:last-of-type {
|
}
|
||||||
margin-right: 15px;
|
}
|
||||||
|
|
||||||
|
// 「更多」下拉面板。挂在滚动容器之外(位置由内联 style 给 left),
|
||||||
|
// 因为 scroll-pane 的 wrap 是 overflow:hidden,放里面会被裁掉
|
||||||
|
.more-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3001;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 200px; // 与 script 里的 MORE_PANEL_WIDTH 保持一致
|
||||||
|
max-height: 320px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style-type: none;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.16);
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px 12px 7px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eee;
|
||||||
}
|
}
|
||||||
&.active {
|
|
||||||
background-color: #42b983;
|
.more-title {
|
||||||
color: #fff;
|
flex: 1 1 auto;
|
||||||
border-color: #42b983;
|
min-width: 0;
|
||||||
&::before {
|
overflow: hidden;
|
||||||
content: '';
|
text-overflow: ellipsis;
|
||||||
background: #fff;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
transform: scale(0.6);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 8px;
|
vertical-align: -3px;
|
||||||
height: 8px;
|
}
|
||||||
border-radius: 50%;
|
|
||||||
position: relative;
|
&:hover {
|
||||||
margin-right: 2px;
|
background-color: #dcdfe6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前项在面板里高亮,便于在长列表里一眼找到
|
||||||
|
&.is-active {
|
||||||
|
background: var(--current-color, #409eff);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.el-icon-close {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contextmenu {
|
.contextmenu {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@ -291,16 +627,40 @@ export default {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
color: #333;
|
color: #333;
|
||||||
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
|
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
|
||||||
li {
|
li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 7px 16px;
|
padding: 7px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.menu-divider {
|
||||||
|
padding: 0;
|
||||||
|
margin: 5px 0;
|
||||||
|
height: 1px;
|
||||||
|
background: #ebeef5;
|
||||||
|
cursor: default;
|
||||||
|
&:hover {
|
||||||
|
background: #ebeef5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.menu-pin {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-right: 6px;
|
||||||
|
fill: currentColor;
|
||||||
|
vertical-align: -1px;
|
||||||
|
|
||||||
|
&.is-off {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -310,11 +670,13 @@ export default {
|
|||||||
.tags-view-wrapper {
|
.tags-view-wrapper {
|
||||||
.tags-view-item {
|
.tags-view-item {
|
||||||
.el-icon-close {
|
.el-icon-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
vertical-align: 2px;
|
margin-left: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: var(--top-tabs-close-fg, #909399);
|
||||||
transition: all .3s cubic-bezier(.645, .045, .355, 1);
|
transition: all .3s cubic-bezier(.645, .045, .355, 1);
|
||||||
transform-origin: 100% 50%;
|
transform-origin: 100% 50%;
|
||||||
&:before {
|
&:before {
|
||||||
@ -323,8 +685,16 @@ export default {
|
|||||||
vertical-align: -3px;
|
vertical-align: -3px;
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #b4bccc;
|
background-color: var(--top-tabs-close-hover, #dcdfe6);
|
||||||
color: #fff;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中标签(白底深字)上的关闭按钮
|
||||||
|
&.active .el-icon-close {
|
||||||
|
color: var(--top-tabs-active-close-fg, #909399);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--top-tabs-active-close-hover, #dcdfe6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
|
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
|
||||||
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
|
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
|
||||||
|
<!-- 顶部浏览器式标签栏:固定在页面最上方,左侧是 logo 区(与侧边栏同宽同色) -->
|
||||||
|
<div
|
||||||
|
v-if="needTagsView"
|
||||||
|
class="top-tabs-bar"
|
||||||
|
:class="sideTheme === 'theme-dark' ? 'is-dark' : 'is-light'"
|
||||||
|
:style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }"
|
||||||
|
>
|
||||||
|
<logo v-if="showLogo && !sidebar.hide" class="top-tabs-logo" :collapse="!sidebar.opened" compact />
|
||||||
|
<tags-view class="top-tabs-view" />
|
||||||
|
</div>
|
||||||
<sidebar v-if="!sidebar.hide" class="sidebar-container" />
|
<sidebar v-if="!sidebar.hide" class="sidebar-container" />
|
||||||
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
|
<div :class="{ sidebarHide: sidebar.hide }" class="main-container">
|
||||||
<div :class="{ 'fixed-header': fixedHeader }">
|
<div :class="{ 'fixed-header': fixedHeader }">
|
||||||
<navbar />
|
<navbar />
|
||||||
<tags-view v-if="needTagsView" />
|
|
||||||
</div>
|
</div>
|
||||||
<app-main />
|
<app-main />
|
||||||
<right-panel>
|
<right-panel>
|
||||||
@ -18,6 +27,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import RightPanel from '@/components/RightPanel';
|
import RightPanel from '@/components/RightPanel';
|
||||||
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components';
|
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components';
|
||||||
|
import Logo from './components/Sidebar/Logo';
|
||||||
import ResizeMixin from './mixin/ResizeHandler';
|
import ResizeMixin from './mixin/ResizeHandler';
|
||||||
import { mapState } from 'vuex';
|
import { mapState } from 'vuex';
|
||||||
import variables from '@/assets/styles/variables.scss';
|
import variables from '@/assets/styles/variables.scss';
|
||||||
@ -26,6 +36,7 @@ export default {
|
|||||||
name: 'Layout',
|
name: 'Layout',
|
||||||
components: {
|
components: {
|
||||||
AppMain,
|
AppMain,
|
||||||
|
Logo,
|
||||||
Navbar,
|
Navbar,
|
||||||
RightPanel,
|
RightPanel,
|
||||||
Settings,
|
Settings,
|
||||||
@ -41,6 +52,7 @@ export default {
|
|||||||
device: (state) => state.app.device,
|
device: (state) => state.app.device,
|
||||||
needTagsView: (state) => state.settings.tagsView,
|
needTagsView: (state) => state.settings.tagsView,
|
||||||
fixedHeader: (state) => state.settings.fixedHeader,
|
fixedHeader: (state) => state.settings.fixedHeader,
|
||||||
|
showLogo: (state) => state.settings.sidebarLogo,
|
||||||
}),
|
}),
|
||||||
classObj() {
|
classObj() {
|
||||||
return {
|
return {
|
||||||
@ -48,6 +60,7 @@ export default {
|
|||||||
openSidebar: this.sidebar.opened,
|
openSidebar: this.sidebar.opened,
|
||||||
withoutAnimation: this.sidebar.withoutAnimation,
|
withoutAnimation: this.sidebar.withoutAnimation,
|
||||||
mobile: this.device === 'mobile',
|
mobile: this.device === 'mobile',
|
||||||
|
'has-top-tabs': this.needTagsView,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
variables() {
|
variables() {
|
||||||
@ -88,6 +101,74 @@ export default {
|
|||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顶部浏览器式标签栏:整行固定,左侧 logo 区与侧边栏同宽
|
||||||
|
.top-tabs-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: $top-tabs-height;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
z-index: 1002;
|
||||||
|
// 不加投影:选中标签是白底,要与下方白色 navbar 无缝连成一体,投影会在接缝处压出一条暗线
|
||||||
|
|
||||||
|
// 深色侧边栏主题:深蓝底白字,选中标签白底 —— 正好与下方白色 navbar 连成一体
|
||||||
|
&.is-dark {
|
||||||
|
--top-tabs-fg: #ffffff;
|
||||||
|
--top-tabs-fg-dim: rgba(255, 255, 255, 0.7);
|
||||||
|
--top-tabs-hover: rgba(0, 0, 0, 0.06);
|
||||||
|
--top-tabs-line: rgba(255, 255, 255, 0.12);
|
||||||
|
--top-tabs-close-fg: rgba(255, 255, 255, 0.75);
|
||||||
|
--top-tabs-close-hover: rgba(255, 255, 255, 0.28);
|
||||||
|
--top-tabs-active-bg: #ffffff;
|
||||||
|
--top-tabs-active-fg: #303133;
|
||||||
|
--top-tabs-active-close-fg: #909399;
|
||||||
|
--top-tabs-active-close-hover: #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浅色侧边栏主题:白底深字,选中标签用主题色实心块保证对比
|
||||||
|
&.is-light {
|
||||||
|
--top-tabs-fg: #303133;
|
||||||
|
--top-tabs-fg-dim: rgba(0, 0, 0, 0.65);
|
||||||
|
--top-tabs-hover: rgba(0, 0, 0, 0.05);
|
||||||
|
--top-tabs-line: rgba(0, 0, 0, 0.08);
|
||||||
|
--top-tabs-close-fg: #909399;
|
||||||
|
--top-tabs-close-hover: rgba(0, 0, 0, 0.12);
|
||||||
|
--top-tabs-active-bg: var(--current-color, #409eff);
|
||||||
|
--top-tabs-active-fg: #ffffff;
|
||||||
|
--top-tabs-active-close-fg: rgba(255, 255, 255, 0.85);
|
||||||
|
--top-tabs-active-close-hover: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tabs-logo {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: $base-sidebar-width;
|
||||||
|
border-right: 1px solid var(--top-tabs-line);
|
||||||
|
transition: width 0.28s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tabs-view {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧边栏收起时 logo 区同步收窄(与 sidebar.scss 的 54px 保持一致)
|
||||||
|
.hideSidebar .top-tabs-logo {
|
||||||
|
width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端屏宽有限,logo 区让位给标签(此时侧边栏内会重新显示 logo)
|
||||||
|
.mobile .top-tabs-logo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.withoutAnimation .top-tabs-logo {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.fixed-header {
|
.fixed-header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -105,6 +186,17 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顶部标签栏占位:主内容整体下移,吸顶头部同步下移到标签栏下方
|
||||||
|
.has-top-tabs {
|
||||||
|
.main-container {
|
||||||
|
padding-top: $top-tabs-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-header {
|
||||||
|
top: $top-tabs-height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mobile .fixed-header {
|
.mobile .fixed-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,9 +17,9 @@ export default {
|
|||||||
topNav: false,
|
topNav: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否显示 tagsView
|
* 是否显示 tagsView(顶部浏览器式标签栏,默认开启)
|
||||||
*/
|
*/
|
||||||
tagsView: false,
|
tagsView: true,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否固定头部
|
* 是否固定头部
|
||||||
|
|||||||
@ -3,7 +3,19 @@ import Cookies from 'js-cookie';
|
|||||||
|
|
||||||
const { sideTheme, showSettings, topNav, tagsView, fixedHeader, sidebarLogo, dynamicTitle, language } = defaultSettings;
|
const { sideTheme, showSettings, topNav, tagsView, fixedHeader, sidebarLogo, dynamicTitle, language } = defaultSettings;
|
||||||
|
|
||||||
|
// 顶部浏览器式标签栏改为默认布局:存量用户 localStorage 里缓存的 tagsView=false 只强制覆盖一次,
|
||||||
|
// 之后用户仍可在「布局设置」抽屉里自由开关(版本号变化才会再迁移一次)
|
||||||
|
const LAYOUT_SETTING_VERSION = '2';
|
||||||
const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || '';
|
const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || '';
|
||||||
|
|
||||||
|
if (localStorage.getItem('layout-setting-version') !== LAYOUT_SETTING_VERSION) {
|
||||||
|
if (storageSetting && typeof storageSetting === 'object') {
|
||||||
|
storageSetting.tagsView = true;
|
||||||
|
localStorage.setItem('layout-setting', JSON.stringify(storageSetting));
|
||||||
|
}
|
||||||
|
localStorage.setItem('layout-setting-version', LAYOUT_SETTING_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
title: '',
|
title: '',
|
||||||
theme: storageSetting.theme || '#409EFF',
|
theme: storageSetting.theme || '#409EFF',
|
||||||
|
|||||||
@ -1,7 +1,47 @@
|
|||||||
|
const PINNED_STORAGE_KEY = 'pinned-tags'
|
||||||
|
|
||||||
|
// 只持久化「重新打开标签」所需的最小字段,避免把 meta 里的临时/函数数据写进 localStorage
|
||||||
|
function toStorableView(view) {
|
||||||
|
return {
|
||||||
|
path: view.path,
|
||||||
|
fullPath: view.fullPath,
|
||||||
|
name: view.name,
|
||||||
|
query: view.query || {},
|
||||||
|
params: view.params || {},
|
||||||
|
title: view.title || (view.meta && view.meta.title) || 'no-name',
|
||||||
|
meta: Object.assign({}, view.meta, { affix: true, pinned: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPinnedViews() {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(localStorage.getItem(PINNED_STORAGE_KEY))
|
||||||
|
return Array.isArray(list) ? list : []
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePinnedViews(list) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(list))
|
||||||
|
} catch (e) {
|
||||||
|
// localStorage 不可用(隐私模式/超配额)时降级为仅当前会话有效
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定区 = 排在普通标签之前的 affix 标签;新固定/取消固定的标签插到固定区末尾
|
||||||
|
function moveToAffixBlockEnd(list, view) {
|
||||||
|
const affixCount = list.filter(v => v.meta && v.meta.affix).length
|
||||||
|
list.splice(affixCount, 0, view)
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
visitedViews: [],
|
visitedViews: [],
|
||||||
cachedViews: [],
|
cachedViews: [],
|
||||||
iframeViews: []
|
iframeViews: [],
|
||||||
|
// 用户右键「固定标签到任务栏」的标签,刷新后仍然存在
|
||||||
|
pinnedViews: loadPinnedViews()
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutations = {
|
const mutations = {
|
||||||
@ -28,11 +68,10 @@ const mutations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
DEL_VISITED_VIEW: (state, view) => {
|
DEL_VISITED_VIEW: (state, view) => {
|
||||||
for (const [i, v] of state.visitedViews.entries()) {
|
// 用户固定的标签不允许被其它逻辑(如 $tab.closeOpenPage)误删
|
||||||
if (v.path === view.path) {
|
const index = state.visitedViews.findIndex(v => v.path === view.path && !(v.meta && v.meta.pinned))
|
||||||
state.visitedViews.splice(i, 1)
|
if (index > -1) {
|
||||||
break
|
state.visitedViews.splice(index, 1)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
state.iframeViews = state.iframeViews.filter(item => item.path !== view.path)
|
state.iframeViews = state.iframeViews.filter(item => item.path !== view.path)
|
||||||
},
|
},
|
||||||
@ -75,6 +114,30 @@ const mutations = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
PIN_VIEW: (state, view) => {
|
||||||
|
const index = state.visitedViews.findIndex(v => v.path === view.path)
|
||||||
|
if (index === -1) return
|
||||||
|
const target = state.visitedViews[index]
|
||||||
|
// affix=true 复用现有关闭逻辑:不显示关闭按钮、关闭其他/全部关闭时都保留
|
||||||
|
target.meta = Object.assign({}, target.meta, { affix: true, pinned: true })
|
||||||
|
state.visitedViews.splice(index, 1)
|
||||||
|
moveToAffixBlockEnd(state.visitedViews, target)
|
||||||
|
if (!state.pinnedViews.some(v => v.path === view.path)) {
|
||||||
|
state.pinnedViews.push(toStorableView(target))
|
||||||
|
}
|
||||||
|
savePinnedViews(state.pinnedViews)
|
||||||
|
},
|
||||||
|
UNPIN_VIEW: (state, view) => {
|
||||||
|
state.pinnedViews = state.pinnedViews.filter(v => v.path !== view.path)
|
||||||
|
savePinnedViews(state.pinnedViews)
|
||||||
|
const index = state.visitedViews.findIndex(v => v.path === view.path)
|
||||||
|
if (index === -1) return
|
||||||
|
const target = state.visitedViews[index]
|
||||||
|
// 取消固定后标签仍留在标签栏,只是恢复可关闭并移出固定区
|
||||||
|
target.meta = Object.assign({}, target.meta, { affix: false, pinned: false })
|
||||||
|
state.visitedViews.splice(index, 1)
|
||||||
|
moveToAffixBlockEnd(state.visitedViews, target)
|
||||||
|
},
|
||||||
DEL_RIGHT_VIEWS: (state, view) => {
|
DEL_RIGHT_VIEWS: (state, view) => {
|
||||||
const index = state.visitedViews.findIndex(v => v.path === view.path)
|
const index = state.visitedViews.findIndex(v => v.path === view.path)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@ -206,6 +269,14 @@ const actions = {
|
|||||||
updateVisitedView({ commit }, view) {
|
updateVisitedView({ commit }, view) {
|
||||||
commit('UPDATE_VISITED_VIEW', view)
|
commit('UPDATE_VISITED_VIEW', view)
|
||||||
},
|
},
|
||||||
|
// 固定标签到任务栏(标签栏最左侧固定区,刷新后仍存在)
|
||||||
|
pinView({ commit }, view) {
|
||||||
|
commit('PIN_VIEW', view)
|
||||||
|
},
|
||||||
|
// 取消固定
|
||||||
|
unpinView({ commit }, view) {
|
||||||
|
commit('UNPIN_VIEW', view)
|
||||||
|
},
|
||||||
delRightTags({ commit }, view) {
|
delRightTags({ commit }, view) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
commit('DEL_RIGHT_VIEWS', view)
|
commit('DEL_RIGHT_VIEWS', view)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user