FrontendRefactoring:标签栏可以展示多个菜单

This commit is contained in:
zhugongping 2026-09-23 17:10:29 +08:00
parent 58632033b5
commit 3288829a01
11 changed files with 703 additions and 74 deletions

View File

@ -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;

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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));
}
} }
} }
} }

View File

@ -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 {
height: 100%;
// 行高归零,让标签以整行高度排布,不被行框撑出多余空白
line-height: 0;
}
.tags-view-item { .tags-view-item {
display: inline-block; display: inline-flex;
align-items: center;
vertical-align: top;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
height: 26px; box-sizing: border-box;
line-height: 26px; height: $top-tabs-height;
border: 1px solid #d8dce5; padding: 0 14px;
color: #495060; color: var(--top-tabs-fg-dim, rgba(255, 255, 255, 0.7));
background: #fff; font-size: 13px;
padding: 0 8px; // 外层 line-height 归零压制行框,标签自身要恢复行高,否则标题文字被压成 0 高
font-size: 12px; line-height: 1.4;
margin-left: 5px; max-width: 180px;
margin-top: 4px; user-select: none;
&:first-of-type { transition: background 0.2s, color 0.2s;
margin-left: 15px;
&:hover {
background: var(--top-tabs-hover, rgba(0, 0, 0, 0.06));
color: var(--top-tabs-fg, #fff);
} }
&:last-of-type {
margin-right: 15px; .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 { &.active {
background-color: #42b983; background-color: var(--top-tabs-active-bg, #fff);
color: #fff; color: var(--top-tabs-active-fg, #303133);
border-color: #42b983;
.tab-pin {
fill: var(--current-color, #409eff);
}
}
// 固定区与普通标签之间的分隔线
&.fixed-block-end::after {
content: '';
position: absolute;
top: 12px;
right: 0;
width: 1px;
height: 26px;
background: var(--top-tabs-line, rgba(255, 255, 255, 0.12));
}
}
// 「更多」入口:超出上限的标签收在这里,按钮紧跟在最后一个可见标签右侧
.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;
transition: transform 0.2s;
}
&.is-open .more-caret {
transform: rotate(180deg);
}
// 当前页正好被收进面板:用主题色圆点提示,否则栏上完全没有"当前页"的痕迹
&.is-active {
color: var(--top-tabs-fg, #fff);
&::before { &::before {
content: ''; content: '';
background: #fff; flex: 0 0 auto;
display: inline-block; width: 6px;
width: 8px; height: 6px;
height: 8px; margin-right: 6px;
border-radius: 50%; border-radius: 50%;
position: relative; background: var(--current-color, #409eff);
margin-right: 2px; }
}
}
// 「更多」下拉面板。挂在滚动容器之外(位置由内联 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;
}
.more-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
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;
vertical-align: -3px;
}
&:hover {
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);
} }
} }
} }

View File

@ -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%;
} }

View File

@ -17,9 +17,9 @@ export default {
topNav: false, topNav: false,
/** /**
* 是否显示 tagsView * 是否显示 tagsView(顶部浏览器式标签栏,默认开启)
*/ */
tagsView: false, tagsView: true,
/** /**
* 是否固定头部 * 是否固定头部

View File

@ -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',

View File

@ -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)