标签视图(TagsView)系统
简介
标签视图系统是现代后台管理界面的核心交互组件,提供类似浏览器多标签页的管理和导航功能。该系统采用模块化架构设计,由 TagsView.vue(标签管理主组件)、ScrollPane.vue(水平滚动容器)和 tab.ts(标签操作工具函数)三大核心模块构成,配合 useLayout Composable 实现响应式状态管理。
系统支持标签页的增删改查、右键上下文菜单、固定标签(Affix)、自动滚动定位、国际化标题、视图缓存联动、动态路由处理等丰富功能,是构建高效后台管理系统的重要组成部分。
核心特性:
- 多标签管理 - 支持标签的添加、关闭、刷新,自动去重和状态保持
- 固定标签(Affix) - 重要页面设为固定标签,无法被关闭,始终可访问
- 右键菜单 - 提供刷新、关闭当前/其他/左侧/右侧/全部等丰富操作
- 智能滚动 - 鼠标滚轮水平滚动,新标签自动滚动到可视区域
- 缓存联动 - 与 keep-alive 深度集成,标签关闭时自动清理缓存
- 国际化支持 - 支持多语言标签标题,包含动态参数模板
- 深色模式适配 - 完美支持亮色/暗色主题切换
- 响应式布局 - 适配不同屏幕尺寸,移动端自动隐藏
组件架构
TagsView/
├── TagsView.vue # 标签页管理主组件(637行)
│ ├── 标签列表渲染
│ ├── 右键上下文菜单
│ ├── 固定标签初始化
│ ├── 路由监听和标签添加
│ └── 标签操作方法
├── ScrollPane.vue # 水平滚动容器组件(322行)
│ ├── 鼠标滚轮处理
│ ├── 自动滚动定位
│ ├── 相邻标签检测
│ └── 最优滚动位置计算
└── (依赖)
├── useLayout.ts # 布局状态管理(tagsViewMethods)
└── tab.ts # 标签操作工具函数2
3
4
5
6
7
8
9
10
11
12
13
14
15
核心组件详解
TagsView.vue - 标签页管理
标签页管理的核心组件,负责标签的显示、交互和生命周期管理。
组件结构
<template>
<div id="tags-view-container" class="tags-view-container" ref="containerRef">
<!-- 标签页滚动容器 -->
<ScrollPane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
<!-- 遍历所有访问过的视图标签 -->
<router-link
v-for="tag in visitedViews"
:key="tag.path"
:data-path="tag.path"
:class="['tags-view-item', { 'active': isActive(tag) }]"
:to="{ path: tag.path ? tag.path : '', query: tag.query }"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent="openMenu(tag, $event)"
>
<!-- 标签图标 -->
<div class="tag-icon">
<Icon :code="tag.meta?.icon || 'nested'" />
</div>
<!-- 标签文本内容 -->
<div class="tag-text">
{{ getTagTitle(tag) }}
</div>
<!-- 关闭按钮 - 仅非固定标签显示 -->
<span v-if="!isAffix(tag)" class="close-btn" @click.prevent.stop="closeSelectedTag(tag)">
<svg class="close-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</span>
</router-link>
</ScrollPane>
<!-- 右键上下文菜单 -->
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)">
<Icon code="refresh" />
{{ t('tagsView.refresh') }}
</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
<Icon code="close" />
{{ t('tagsView.close') }}
</li>
<li @click="closeOthersTags">
<Icon code="close_circle" />
{{ t('tagsView.closeOthers') }}
</li>
<li v-if="!isFirstView()" @click="closeLeftTags">
<Icon code="back" />
{{ t('tagsView.closeLeft') }}
</li>
<li v-if="!isLastView()" @click="closeRightTags">
<Icon code="right" />
{{ t('tagsView.closeRight') }}
</li>
<li @click="closeAllTags(selectedTag)">
<Icon code="close_circle" />
{{ t('tagsView.closeAll') }}
</li>
</ul>
</div>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
组件配置
<script lang="ts" setup>
import { computed, ref, watch, onMounted, nextTick } from 'vue'
import { useRoute, useRouter, type RouteRecordRaw, type RouteLocationNormalized } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ScrollPane from './ScrollPane.vue'
import { routes } from '@/router/routeModules'
import { nameToTitle } from '@/utils/i18n'
import { normalizePath } from '@/utils/path'
import { refreshPage, closePage, closeOtherPage, closeLeftPage, closeRightPage, closeAllPage } from '@/utils/tab'
defineOptions({
name: 'TagsView'
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
核心功能实现
标签状态管理
// 访问过的视图列表 - 从布局状态管理获取
const visitedViews = computed(() => useLayout().getVisitedViews())
// 检查标签是否为当前激活状态
const isActive = (r: RouteLocationNormalized): boolean => {
return r.path === route.path
}
// 检查标签是否为固定标签(不可关闭)
const isAffix = (tag: RouteLocationNormalized | undefined) => {
return tag?.meta && tag?.meta?.affix
}
// 检查是否是第一个标签(用于禁用"关闭左侧"菜单项)
const isFirstView = () => {
try {
return selectedTag.value?.path === visitedViews.value[0]?.path ||
selectedTag.value?.path === '/index'
} catch {
return false
}
}
// 检查是否是最后一个标签(用于禁用"关闭右侧"菜单项)
const isLastView = () => {
try {
return selectedTag.value?.path === visitedViews.value[visitedViews.value.length - 1]?.path
} catch {
return false
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
国际化标题处理
系统支持多种标题格式,按优先级处理:
/**
* 获取标签标题
* 优先级: i18nKey > 动态参数模板 > name转换 > 原始title
*/
const getTagTitle = (tag: RouteLocationNormalized) => {
const meta = tag?.meta
const name = tag?.name
// 1. 优先使用国际化键进行翻译
if (meta?.i18nKey) {
const i18nTitle = t(meta.i18nKey)
// 如果翻译成功(返回值不等于键名),使用翻译结果
if (i18nTitle !== meta.i18nKey) {
return i18nTitle
}
}
// 2. 处理包含动态参数的标题模板
// 支持格式: "用户详情-{name}" 配合 meta.titleParams
if (meta?.title?.includes('{')) {
try {
return t(meta.title, meta.titleParams || {})
} catch {
// 解析失败时返回原始标题
return meta.title
}
}
// 3. 降级处理:使用名称转换的标题或者原始标题
// nameToTitle 将路由名称转换为标题格式
return t(nameToTitle(name?.toString() || ''), meta?.title)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
固定标签初始化
系统启动时自动扫描路由配置,将设置了 affix: true 的路由添加为固定标签:
/**
* 过滤并提取固定标签
* 递归遍历路由配置,收集所有 affix=true 的路由
*/
const filterAffixTags = (routes: RouteRecordRaw[], basePath = '') => {
let tags: RouteLocationNormalized[] = []
routes.forEach((route) => {
// 检查当前路由是否为固定标签
if (route.meta && route.meta.affix) {
const tagPath = normalizePath(basePath + '/' + route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name as string,
meta: { ...route.meta }
} as RouteLocationNormalized)
}
// 递归处理子路由
if (route.children) {
const tempTags = filterAffixTags(route.children, route.path)
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags]
}
}
})
return tags
}
/**
* 初始化固定标签
* 在组件挂载时调用,将固定标签添加到视图列表
*/
const initTags = () => {
const affixTags = filterAffixTags(routes)
for (const tag of affixTags) {
// 只添加有名称的路由
if (tag.name) {
useLayout().addVisitedView(tag)
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
标签添加和更新
/**
* 添加当前路由为新标签
* 路由变化时自动调用
*/
const addTags = () => {
const { name } = route
// 如果查询参数中包含标题,更新路由元信息
// 支持动态标题: /user/detail?title=张三详情
if (route.query.title) {
route.meta.title = route.query.title as string
}
// 有效路由名称时添加到视图存储
if (name) {
useLayout().addView(route as any)
}
}
/**
* 滚动到当前激活的标签位置
* 确保当前标签在可视区域内
*/
const moveToCurrentTag = async () => {
await nextTick()
for (const r of visitedViews.value) {
if (r.path === route.path) {
// 调用 ScrollPane 的滚动方法
scrollPaneRef.value?.moveToTarget(r)
// 如果完整路径不同则更新视图信息
// 处理带不同查询参数的同一路由
if (r.fullPath !== route.fullPath) {
useLayout().updateVisitedView(route)
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
右键菜单系统
菜单显示控制
// 菜单状态
const visible = ref(false) // 菜单可见性
const top = ref(0) // 菜单Y位置
const left = ref(0) // 菜单X位置
const selectedTag = ref<RouteLocationNormalized>() // 选中的标签
/**
* 打开右键菜单
* 计算菜单位置,防止超出容器边界
*/
const openMenu = (tag: RouteLocationNormalized, e: MouseEvent) => {
const menuMinWidth = 105 // 菜单最小宽度
const container = containerRef.value
if (!container) return
// 计算菜单位置 - 相对于容器
const offsetLeft = container.getBoundingClientRect().left
const offsetWidth = container.offsetWidth
const maxLeft = offsetWidth - menuMinWidth // 最大左偏移,防止超出右边界
// 计算实际左偏移,鼠标位置加15px偏移
const l = e.clientX - offsetLeft + 15
// 边界处理:如果超出右边界则使用最大值
left.value = l > maxLeft ? maxLeft : l
top.value = e.clientY
visible.value = true
selectedTag.value = tag
}
/**
* 关闭右键菜单
*/
const closeMenu = () => {
visible.value = false
}
// 点击其他区域关闭菜单
watch(visible, (value) => {
if (value) {
document.body.addEventListener('click', closeMenu)
} else {
document.body.removeEventListener('click', closeMenu)
}
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
菜单操作实现
/**
* 刷新选中的标签页
* 使用 redirect 路由实现刷新
*/
const refreshSelectedTag = (view: RouteLocationNormalized | undefined) => {
if (!view) return
refreshPage(view)
}
/**
* 关闭选中的标签页
* 如果关闭的是当前激活标签,自动跳转到最后一个标签
*/
const closeSelectedTag = (view: RouteLocationNormalized | undefined) => {
if (!view) return
closePage(view).then(({ visitedViews }: any) => {
if (isActive(view)) {
toLastView(visitedViews, view)
}
})
}
/**
* 关闭其他标签页
* 保留当前选中的标签和固定标签
*/
const closeOthersTags = () => {
if (selectedTag.value) {
// 先导航到选中的标签
router.push({
path: selectedTag.value.path,
query: selectedTag.value.query
}).catch(() => {})
}
closeOtherPage(selectedTag.value).then(() => {
moveToCurrentTag()
})
}
/**
* 关闭左侧标签页
*/
const closeLeftTags = () => {
closeLeftPage(selectedTag.value).then((visitedViews: RouteLocationNormalized[]) => {
// 如果当前路由不在剩余标签中,跳转到选中标签
if (!visitedViews.find((i) => i.path === route.path)) {
toLastView(visitedViews, selectedTag.value)
}
})
}
/**
* 关闭右侧标签页
*/
const closeRightTags = () => {
closeRightPage(selectedTag.value).then((visitedViews: RouteLocationNormalized[]) => {
if (!visitedViews.find((i) => i.path === route.path)) {
toLastView(visitedViews, selectedTag.value)
}
})
}
/**
* 关闭所有标签页
* 保留固定标签,如果当前标签是固定标签则保持,否则跳转
*/
const closeAllTags = (view: RouteLocationNormalized | undefined) => {
closeAllPage().then(({ visitedViews }) => {
// 如果固定标签中包含当前路由,不跳转
if (affixTags.value.some((tag) => tag.path === route.path)) {
return
}
toLastView(visitedViews, view)
})
}
/**
* 跳转到最后一个标签
* 用于关闭当前标签后的导航
*/
const toLastView = (
visitedViews: RouteLocationNormalized[],
view: RouteLocationNormalized | undefined
) => {
// 获取最后一个标签
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
router.push(latestView.fullPath)
} else {
// 没有其他标签时的处理
if (view?.name === 'Dashboard') {
// 首页刷新重定向
router.replace({ path: '/redirect' + view.fullPath })
} else {
// 跳转到首页
router.push('/')
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
ScrollPane.vue - 滚动容器
提供标签页的水平滚动功能,支持鼠标滚轮操作和自动定位。
组件模板
<template>
<el-scrollbar
ref="scrollContainerRef"
:vertical="false"
class="scroll-container"
@wheel.prevent="handleMouseWheel"
>
<slot />
</el-scrollbar>
</template>2
3
4
5
6
7
8
9
10
核心功能实现
鼠标滚轮水平滚动
/**
* 标签间距(px)
* 用于计算滚动位置时的间距调整
*/
const TAG_SPACING = 4
/**
* 获取滚动包装器的 DOM 元素
*/
const scrollWrapper = computed(() => {
const wrapRef = scrollContainerRef.value?.$refs.wrapRef
return wrapRef as HTMLElement | undefined
})
/**
* 处理鼠标滚轮事件
* 实现自定义的水平滚动行为
*
* 滚动方向映射:
* - 往上滚动(deltaY < 0) → scrollLeft减少 → 向左移动
* - 往下滚动(deltaY > 0) → scrollLeft增加 → 向右移动
*/
const handleMouseWheel = (e: WheelEvent): void => {
// 获取滚轮的滚动量,兼容不同浏览器
// wheelDelta 是旧版API,deltaY 是标准API
const eventDelta = (e as any).wheelDelta || -e.deltaY * 40
const wrapper = scrollWrapper.value
if (wrapper) {
// 除以4进行平滑处理,避免滚动过快
wrapper.scrollLeft = wrapper.scrollLeft - eventDelta / 4
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
自动滚动定位
当切换标签或新增标签时,自动将目标标签滚动到可视区域:
/**
* 将视图滚动到指定的目标标签位置
*
* 滚动策略:
* 1. 如果目标是第一个标签,滚动到最左侧
* 2. 如果目标是最后一个标签,滚动到最右侧
* 3. 其他情况,确保目标标签及其前后标签都在视口内
*/
const moveToTarget = (currentTag: RouteLocationNormalized): void => {
// 获取容器和包装器元素
const container = scrollContainerRef.value?.$el as HTMLElement
const wrapper = scrollWrapper.value
// 基础验证
if (!container || !wrapper) {
console.warn('ScrollPane: 容器或包装器元素未找到')
return
}
const containerWidth = container.offsetWidth
const views = visitedViews.value
// 处理空数组情况
if (views.length === 0) {
console.warn('ScrollPane: 没有可访问的视图')
return
}
const firstTag = views[0]
const lastTag = views[views.length - 1]
// 边界情况:第一个标签 - 滚动到最左侧
if (firstTag === currentTag) {
wrapper.scrollLeft = 0
return
}
// 边界情况:最后一个标签 - 滚动到最右侧
if (lastTag === currentTag) {
wrapper.scrollLeft = wrapper.scrollWidth - containerWidth
return
}
// 计算目标标签的索引位置
const currentIndex = views.findIndex((item) => item === currentTag)
if (currentIndex !== -1) {
// 查找相邻标签并计算最优滚动位置
const { prevTagElement, nextTagElement } = findAdjacentTagElements(
views as RouteLocationNormalized[],
currentIndex
)
if (prevTagElement && nextTagElement) {
calculateOptimalScrollPosition(wrapper, containerWidth, prevTagElement, nextTagElement)
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
相邻标签检测
/**
* 查找指定索引标签的前后相邻 DOM 元素
* 通过 data-path 属性匹配标签
*/
const findAdjacentTagElements = (
views: RouteLocationNormalized[],
currentIndex: number
): { prevTagElement: HTMLElement | null; nextTagElement: HTMLElement | null } => {
// 获取所有标签的 DOM 元素
const tagListDom = document.getElementsByClassName('tags-view-item') as HTMLCollectionOf<HTMLElement>
let prevTagElement: HTMLElement | null = null
let nextTagElement: HTMLElement | null = null
// 获取前后标签的路径用于匹配
const prevTagPath = views[currentIndex - 1]?.path
const nextTagPath = views[currentIndex + 1]?.path
// 遍历 DOM 元素查找匹配的前后标签
for (let i = 0; i < tagListDom.length; i++) {
const tagDom = tagListDom[i]
const tagPath = tagDom.dataset.path // 读取 data-path 属性
if (tagPath === prevTagPath) {
prevTagElement = tagDom
}
if (tagPath === nextTagPath) {
nextTagElement = tagDom
}
// 如果都找到了,提前退出循环(性能优化)
if (prevTagElement && nextTagElement) {
break
}
}
return { prevTagElement, nextTagElement }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
最优滚动位置计算
/**
* 计算并设置最佳滚动位置
* 确保目标标签的前后标签都在视口范围内
*/
const calculateOptimalScrollPosition = (
wrapper: HTMLElement,
containerWidth: number,
prevTagElement: HTMLElement,
nextTagElement: HTMLElement
): void => {
// 计算后一个标签右边界位置(包含间距)
const afterNextTagOffsetLeft =
nextTagElement.offsetLeft + nextTagElement.offsetWidth + TAG_SPACING
// 计算前一个标签左边界位置(包含间距)
const beforePrevTagOffsetLeft = prevTagElement.offsetLeft - TAG_SPACING
// 判断是否需要向右滚动(后一个标签超出右边界)
if (afterNextTagOffsetLeft > wrapper.scrollLeft + containerWidth) {
wrapper.scrollLeft = afterNextTagOffsetLeft - containerWidth
}
// 判断是否需要向左滚动(前一个标签超出左边界)
else if (beforePrevTagOffsetLeft < wrapper.scrollLeft) {
wrapper.scrollLeft = beforePrevTagOffsetLeft
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
生命周期管理
/**
* 组件挂载时的初始化操作
* 添加滚动事件监听器
*/
onMounted(() => {
const wrapper = scrollWrapper.value
if (wrapper) {
wrapper.addEventListener('scroll', handleScrollEvent, true)
}
})
/**
* 组件卸载前的清理操作
* 移除滚动事件监听器,防止内存泄漏
*/
onBeforeUnmount(() => {
const wrapper = scrollWrapper.value
if (wrapper) {
wrapper.removeEventListener('scroll', handleScrollEvent)
}
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
标签操作工具函数
tab.ts 提供了一组标签页导航操作的工具函数,封装了常用的标签管理逻辑。
函数列表
import {
refreshPage, // 刷新当前标签页
closeOpenPage, // 关闭当前并打开新标签
closePage, // 关闭指定标签页
closeAllPage, // 关闭所有标签页
closeLeftPage, // 关闭左侧标签页
closeRightPage, // 关闭右侧标签页
closeOtherPage, // 关闭其他标签页
openPage, // 打开新标签页
updatePage // 更新标签页信息
} from '@/utils/tab'2
3
4
5
6
7
8
9
10
11
刷新页面
/**
* 刷新当前标签页
* 使用 redirect 路由实现刷新,保证缓存正确清理
*/
export const refreshPage = async (obj?: RouteLocationNormalized): Promise<void> => {
const { path, query, matched } = router.currentRoute.value
if (obj === undefined) {
// 查找当前路由中的组件
for (const m of matched) {
if (m.components?.default?.name &&
!['Layout', 'ParentView'].includes(m.components.default.name)) {
obj = {
name: m.components.default.name,
path: path,
query: query,
// ... 其他属性设为 undefined
}
break
}
}
}
const targetQuery = obj?.query || {}
const targetPath = obj?.path || ''
// 1. 从缓存中移除视图
await useLayout().delCachedView(obj)
// 2. 使用 redirect 路由刷新页面
// /redirect/xxx 路由会立即重定向回 /xxx
await router.replace({
path: '/redirect' + targetPath,
query: targetQuery
})
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
关闭并打开
/**
* 关闭当前标签页并打开新标签页
* 常用于表单保存后跳转到列表页
*/
export const closeOpenPage = (obj: RouteLocationRaw): void => {
useLayout().delView(router.currentRoute.value)
if (obj !== undefined) {
router.push(obj)
}
}
// 使用示例
const submitAndRedirect = async () => {
await saveData()
// 关闭当前编辑页,打开列表页
closeOpenPage({ path: '/system/user' })
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关闭指定页面
/**
* 关闭指定标签页
* 如果不传参数,关闭当前标签页并自动导航
*/
export const closePage = async (obj?: RouteLocationNormalized): Promise<TagsViewResult | any> => {
if (obj === undefined) {
const { visitedViews } = await useLayout().delView(router.currentRoute.value)
// 获取最后一个标签页
const latestView = visitedViews.slice(-1)[0]
// 导航到最后一个标签页,或首页
return router.push(latestView ? latestView.fullPath : '/')
}
return useLayout().delView(obj)
}2
3
4
5
6
7
8
9
10
11
12
13
14
批量关闭
/**
* 关闭所有标签页(保留固定标签)
*/
export const closeAllPage = (): Promise<TagsViewResult> => {
return useLayout().delAllViews()
}
/**
* 关闭左侧标签页
*/
export const closeLeftPage = (obj?: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
return useLayout().delLeftTags(obj || router.currentRoute.value)
}
/**
* 关闭右侧标签页
*/
export const closeRightPage = (obj?: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
return useLayout().delRightTags(obj || router.currentRoute.value)
}
/**
* 关闭其他标签页(保留当前和固定标签)
*/
export const closeOtherPage = (obj?: RouteLocationNormalized): Promise<TagsViewResult> => {
return useLayout().delOthersViews(obj || router.currentRoute.value)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
打开和更新
/**
* 打开新标签页
* 支持自定义标题和查询参数
*/
export const openPage = (url: string, title?: string, query: Record<string, any> = {}) => {
const obj = {
path: url,
query: title ? { ...query, title } : query
}
return router.push(obj)
}
// 使用示例
openPage('/system/user/detail', '用户详情', { id: '123' })
/**
* 更新标签页信息
* 用于动态修改已打开标签的标题等信息
*/
export const updatePage = (obj: RouteLocationNormalized) => {
return useLayout().updateVisitedView(obj)
}
// 使用示例:更新当前页面标题
const updateTitle = (newTitle: string) => {
const route = {
...router.currentRoute.value,
meta: { ...router.currentRoute.value.meta, title: newTitle }
}
updatePage(route)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
状态管理集成
标签视图系统与 useLayout Composable 深度集成,实现响应式状态管理。
TagsViewState 接口
/**
* 标签视图状态接口
*/
interface TagsViewState {
/** 已访问的视图列表 - 显示在标签栏中 */
visitedViews: RouteLocationNormalized[]
/** 缓存的视图名称列表 - 用于 keep-alive */
cachedViews: string[]
/** iframe 视图列表 - 嵌入式页面特殊处理 */
iframeViews: RouteLocationNormalized[]
}2
3
4
5
6
7
8
9
10
11
12
13
标签管理方法
const tagsViewMethods = {
/**
* 获取视图列表(返回副本,防止直接修改)
*/
getVisitedViews: () => [...state.tagsView.visitedViews],
getIframeViews: () => [...state.tagsView.iframeViews],
getCachedViews: () => [...state.tagsView.cachedViews],
/**
* 添加视图到已访问和缓存列表
* 同时处理显示和缓存
*/
addView(view: RouteLocationNormalized) {
this.addVisitedView(view)
this.addCachedView(view)
},
/**
* 添加视图到已访问列表
* 相同路径的视图不重复添加
*/
addVisitedView(view: RouteLocationNormalized) {
if (state.tagsView.visitedViews.some((v) => v.path === view.path)) return
state.tagsView.visitedViews.push({
...view,
title: view.meta?.title || 'no-name'
})
},
/**
* 添加视图到缓存列表
* 只缓存有名称且未设置 noCache 的视图
*/
addCachedView(view: RouteLocationNormalized) {
const viewName = view.name as string
if (!viewName || state.tagsView.cachedViews.includes(viewName)) return
// 检查路由元信息中的 noCache 设置
if (!view.meta?.noCache) {
state.tagsView.cachedViews.push(viewName)
}
},
/**
* 删除指定视图
* 同时从显示列表和缓存列表中移除
*/
async delView(view: RouteLocationNormalized) {
await this.delVisitedView(view)
// 动态路由不删除缓存(可能还有其他实例)
if (!this.isDynamicRoute(view)) {
await this.delCachedView(view)
}
return {
visitedViews: this.getVisitedViews(),
cachedViews: this.getCachedViews()
}
},
/**
* 判断是否为动态路由
* 动态路由包含 :id 等参数
*/
isDynamicRoute(view: RouteLocationNormalized): boolean {
return view.matched.some((m) => m.path.includes(':'))
},
/**
* 删除指定视图右侧的所有标签
* 保留固定标签
*/
async delRightTags(view: RouteLocationNormalized) {
const index = state.tagsView.visitedViews.findIndex((v) => v.path === view.path)
if (index === -1) return this.getVisitedViews()
state.tagsView.visitedViews = state.tagsView.visitedViews.filter((item, idx) => {
// 保留:索引小于等于当前 或 固定标签
if (idx <= index || item.meta?.affix) return true
// 同时移除缓存
const cacheIndex = state.tagsView.cachedViews.indexOf(item.name as string)
if (cacheIndex > -1) {
state.tagsView.cachedViews.splice(cacheIndex, 1)
}
return false
})
return this.getVisitedViews()
},
// ... 更多方法
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
使用示例
// 在组件中使用
const layout = useLayout()
// 获取已访问视图列表
const visitedViews = computed(() => layout.visitedViews.value)
// 添加视图
layout.addView(route)
// 删除视图
await layout.delView(route)
// 删除其他视图
await layout.delOthersViews(route)
// 更新视图信息
layout.updateVisitedView(route)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
路由配置
固定标签配置
通过路由 meta 中的 affix 属性设置固定标签:
// router/routes.ts
const routes: RouteRecordRaw[] = [
{
path: '/dashboard',
component: Layout,
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '首页',
icon: 'dashboard',
affix: true // 设为固定标签
}
}
]
}
]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
禁用缓存
通过 noCache 属性禁用页面缓存:
{
path: 'add',
name: 'UserAdd',
component: () => import('@/views/user/add.vue'),
meta: {
title: '新增用户',
noCache: true // 禁用缓存,每次进入都重新渲染
}
}2
3
4
5
6
7
8
9
国际化标题
{
path: 'list',
name: 'UserList',
component: () => import('@/views/user/list.vue'),
meta: {
title: '用户管理',
i18nKey: 'menu.system.user' // 国际化键
}
}
// 动态参数标题
{
path: 'detail/:id',
name: 'UserDetail',
component: () => import('@/views/user/detail.vue'),
meta: {
title: '用户详情-{name}',
titleParams: { name: '' } // 运行时动态设置
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
样式系统
标签容器样式
.tags-view-container {
height: 34px;
width: 100%;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
}2
3
4
5
6
7
标签项样式
.tags-view-item {
display: inline-flex;
align-items: center;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
color: var(--el-text-color-primary);
background: var(--el-bg-color);
padding: 0 8px;
font-size: 12px;
margin: 4px 2px;
transition: all 0.3s;
// 图标
.tag-icon {
margin-right: 4px;
font-size: 14px;
}
// 文本
.tag-text {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 关闭按钮
.close-btn {
margin-left: 4px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
&:hover {
background: var(--el-color-danger-light-7);
color: var(--el-color-danger);
}
}
// 激活状态
&.active {
background: var(--el-color-primary);
color: #fff;
border-color: var(--el-color-primary);
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
}
// 悬浮效果
&:hover:not(.active) {
border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
右键菜单样式
.contextmenu {
margin: 0;
padding: 5px 0;
position: fixed;
background: var(--el-bg-color-overlay);
z-index: 3000;
list-style-type: none;
border-radius: 4px;
box-shadow: var(--el-box-shadow-light);
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-primary);
li {
padding: 7px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background: var(--el-fill-color-light);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
滚动容器样式
.scroll-container {
white-space: nowrap; // 防止标签换行
position: relative;
overflow: hidden;
width: 100%;
// 隐藏 Element UI 的滚动条
:deep(.el-scrollbar__bar) {
display: none !important;
}
:deep(.el-scrollbar__wrap) {
height: 49px;
overflow-x: auto;
// 隐藏原生滚动条 - Webkit
&::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
// 隐藏原生滚动条 - Firefox
scrollbar-width: none !important;
// 隐藏原生滚动条 - IE/Edge
-ms-overflow-style: none !important;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
深色模式适配
// 深色模式下的样式调整
html.dark {
.tags-view-container {
background: var(--el-bg-color);
border-bottom-color: var(--el-border-color);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
}
.tags-view-item {
background: var(--el-bg-color);
border-color: var(--el-border-color);
color: var(--el-text-color-regular);
&.active {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
color: #fff;
}
}
.contextmenu {
background: var(--el-bg-color-overlay);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.4);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
功能特性
多标签管理
- 自动添加 - 路由变化时自动添加新标签
- 智能去重 - 相同路径标签自动合并,查询参数不同时更新
- 状态保持 - 标签状态响应式管理,刷新后恢复
- 批量操作 - 支持关闭其他、左侧、右侧、全部标签
- 中键关闭 - 支持鼠标中键快速关闭标签
右键菜单
- 丰富操作 - 刷新、关闭、批量关闭等操作
- 智能显示 - 根据标签位置和类型动态显示菜单项
- 边界处理 - 菜单位置自动调整,防止超出边界
- 权限控制 - 固定标签不显示关闭选项
滚动支持
- 鼠标滚轮 - 支持水平滚动操作
- 自动定位 - 新标签/切换标签自动滚动到可视区域
- 智能计算 - 基于相邻标签的最佳滚动位置算法
- 边界处理 - 首尾标签的特殊定位处理
缓存联动
- 自动缓存 - 标签添加时自动加入 keep-alive 缓存
- 缓存清理 - 标签关闭时自动从缓存中移除
- noCache 支持 - 尊重路由配置的 noCache 设置
- 动态路由处理 - 动态路由保留缓存,防止数据丢失
国际化支持
- i18nKey - 支持国际化键自动翻译
- 参数模板 - 支持动态参数的标题模板
- 降级处理 - 翻译失败时的兜底方案
- 实时更新 - 语言切换后标题自动更新
最佳实践
1. 合理设置固定标签
只对重要且高频访问的页面设置固定标签:
// ✅ 好的做法 - 只有首页和工作台设为固定
{
meta: { affix: true, title: '首页' }
}
{
meta: { affix: true, title: '工作台' }
}
// ❌ 不好的做法 - 过多的固定标签
// 固定标签过多会占用空间,降低用户体验2
3
4
5
6
7
8
9
10
2. 正确使用 noCache
根据页面特性决定是否缓存:
// ✅ 需要缓存的场景
// - 列表页(保持滚动位置和筛选条件)
// - 表单页(保持已填写内容)
{
meta: { title: '用户列表' } // 默认缓存
}
// ✅ 不需要缓存的场景
// - 详情页(每次需要最新数据)
// - 新增页(每次需要空白表单)
{
meta: { title: '新增用户', noCache: true }
}2
3
4
5
6
7
8
9
10
11
12
13
3. 表单提交后关闭页面
// ✅ 使用 closeOpenPage 关闭当前并跳转
const handleSubmit = async () => {
await saveUser(form.value)
ElMessage.success('保存成功')
// 关闭当前编辑页,跳转到列表页
closeOpenPage('/system/user')
}
// ✅ 或者使用 closePage 后手动导航
const handleSubmit = async () => {
await saveUser(form.value)
await closePage() // 关闭当前页
// 自动导航到最后一个标签
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
4. 动态更新标签标题
// 详情页加载数据后更新标题
const loadUserDetail = async () => {
const user = await getUserById(route.params.id)
// 更新标签标题
const newRoute = {
...route,
meta: {
...route.meta,
title: `用户详情 - ${user.name}`
}
}
updatePage(newRoute)
}2
3
4
5
6
7
8
9
10
11
12
13
14
5. 刷新页面保持参数
// ✅ refreshPage 会保持查询参数
const handleRefresh = () => {
refreshPage() // 会保持当前的 query 参数
}
// ❌ 不要直接使用 location.reload
// 会丢失 Vue Router 的状态2
3
4
5
6
7
常见问题
1. 标签不显示
可能原因:
- 路由配置中缺少
name属性 - 路由配置了
hidden: true - 系统设置中关闭了标签视图功能
解决方案:
// 确保路由有 name 属性
{
path: 'list',
name: 'UserList', // ✅ 必须有 name
component: () => import('@/views/user/list.vue'),
meta: {
title: '用户列表',
hidden: false // 确保不是隐藏路由
}
}
// 检查系统设置
const layout = useLayout()
console.log(layout.tagsView.value) // 确保为 true2
3
4
5
6
7
8
9
10
11
12
13
14
2. 缓存不生效
可能原因:
- 路由 name 与组件 name 不一致
- 组件没有定义 name
- 设置了 noCache: true
解决方案:
// 路由配置
{
name: 'UserList', // 路由 name
// ...
}
// 组件中必须定义相同的 name
<script lang="ts" setup>
defineOptions({
name: 'UserList' // ✅ 必须与路由 name 一致
})
</script>2
3
4
5
6
7
8
9
10
11
12
3. 右键菜单位置错误
可能原因:
- 容器有 transform 或其他定位影响
- 页面滚动后位置计算错误
解决方案:
// 确保使用 fixed 定位和正确的坐标计算
const openMenu = (tag: RouteLocationNormalized, e: MouseEvent) => {
const container = containerRef.value
if (!container) return
const offsetLeft = container.getBoundingClientRect().left
const offsetWidth = container.offsetWidth
const maxLeft = offsetWidth - 105 // 菜单最小宽度
left.value = Math.min(e.clientX - offsetLeft + 15, maxLeft)
top.value = e.clientY // 使用 clientY 而非 pageY
visible.value = true
}2
3
4
5
6
7
8
9
10
11
12
13
14
4. 滚动不工作
可能原因:
- 标签内容没有超出容器宽度
- 滚动条样式被隐藏但滚动功能也被禁用
- 事件监听器没有正确绑定
解决方案:
// 确保正确的样式配置
.scroll-container {
overflow: hidden; // 容器隐藏溢出
:deep(.el-scrollbar__wrap) {
overflow-x: auto; // ✅ 允许水平滚动
// 只隐藏滚动条样式,不禁用滚动功能
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
5. 固定标签被关闭
可能原因:
- affix 属性设置错误
- 调用了 delAllViews 等方法
解决方案:
// 确保正确设置 affix
{
meta: {
affix: true // ✅ 布尔值 true
}
}
// delAllViews 会保留固定标签
await layout.delAllViews() // 固定标签不会被删除2
3
4
5
6
7
8
9
类型定义
TagsViewResult
/**
* 标签操作返回类型
*/
type TagsViewResult = {
/** 已访问视图列表 */
visitedViews: RouteLocationNormalized[]
/** 缓存视图名称列表 */
cachedViews: string[]
}2
3
4
5
6
7
8
9
RouteLocationNormalized 扩展
interface RouteLocationNormalized {
// Vue Router 标准属性
path: string
fullPath: string
name: string | symbol | null | undefined
params: RouteParams
query: LocationQuery
hash: string
matched: RouteRecordNormalized[]
redirectedFrom: RouteLocation | undefined
meta: RouteMeta
}
interface RouteMeta {
/** 页面标题 */
title?: string
/** 国际化键 */
i18nKey?: string
/** 动态标题参数 */
titleParams?: Record<string, string>
/** 是否固定标签 */
affix?: boolean
/** 是否禁用缓存 */
noCache?: boolean
/** 是否隐藏标签 */
hidden?: boolean
/** 图标 */
icon?: string
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ScrollPane 暴露方法
/**
* ScrollPane 组件暴露的方法
*/
interface ScrollPaneExpose {
/**
* 滚动到目标标签
* @param currentTag 目标路由对象
*/
moveToTarget: (currentTag: RouteLocationNormalized) => void
}2
3
4
5
6
7
8
9
10
总结
标签视图系统通过 TagsView、ScrollPane 和 tab.ts 三个模块的协作,配合 useLayout 状态管理,为用户提供了功能完整、体验流畅的多标签页管理方案。系统支持丰富的交互功能、智能的滚动定位、完善的右键菜单、与缓存的深度联动等特性,是现代化后台管理系统的重要组成部分。
核心要点:
- 模块化设计 - 职责分离,TagsView 管理标签,ScrollPane 处理滚动,tab.ts 提供工具函数
- 响应式状态 - 通过 useLayout Composable 统一管理标签状态,与 keep-alive 联动
- 用户体验 - 中键关闭、右键菜单、自动滚动等细节优化
- 可配置性 - 支持固定标签、禁用缓存、国际化标题等丰富配置
- 主题适配 - 完美支持亮色/暗色主题切换
