foreign-admin-ui/src/components/layout/Tabs.vue

829 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="tabs-container">
<!-- 左侧滚动按钮 -->
<button class="scroll-btn scroll-btn-left" @click="scrollLeft" :class="{ 'is-disabled': !canScrollLeft }">
<el-icon><ArrowLeft /></el-icon>
</button>
<!-- 标签列表区域 -->
<div class="tabs-scroll-area" ref="tabsContainerRef">
<draggable
v-model="tabsList"
item-key="path"
class="tabs-list"
:animation="300"
ghost-class="ghost"
chosen-class="chosen"
drag-class="drag"
handle=".tab-label"
:move="checkDragMove"
@start="handleDragStart"
@end="handleDragEnd"
>
<template #item="{ element: tab }">
<div
class="tab-item"
:class="{ 'is-active': tabsStore.activeTab === tab.path }"
@click="handleTabClick(tab)"
@contextmenu.prevent="handleContextMenu($event, tab.path)"
>
<div class="tab-label">
<span class="tab-text">{{ tab.title }}</span>
</div>
<span
v-if="tab.path !== '/dashboard'"
class="tab-close"
@click.stop="handleTabRemove(tab.path)"
>
<el-icon><Close /></el-icon>
</span>
</div>
</template>
</draggable>
</div>
<!-- 右侧滚动按钮 -->
<button class="scroll-btn scroll-btn-right" @click="scrollRight" :class="{ 'is-disabled': !canScrollRight }">
<el-icon><ArrowRight /></el-icon>
</button>
<!-- 当前页面下拉菜单 -->
<!-- <el-dropdown trigger="click" @command="handleDropdownCommand" placement="bottom-end" ref="pageDropdownRef">
<button class="current-page-btn" @click.stop="togglePageDropdown">
<el-icon><MoreFilled /></el-icon>
</button>
<template #dropdown>
<div class="dropdown-header">已打开的页面</div>
<el-dropdown-menu class="page-dropdown-menu">
<el-dropdown-item
v-for="tab in tabsStore.tabs"
:key="tab.path"
:command="tab.path"
:class="{ 'is-active-item': tabsStore.activeTab === tab.path }"
>
<span class="dropdown-item-text">{{ tab.title }}</span>
<el-icon
v-if="tab.path !== '/dashboard'"
class="dropdown-item-close"
@click.stop="handleTabRemove(tab.path)"
>
<Close />
</el-icon>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown> -->
<!-- 右键菜单 -->
<teleport to="body">
<div
v-if="contextMenuVisible"
class="context-menu-dropdown"
:style="contextMenuStyle"
@click.stop
>
<div
class="context-menu-item"
:class="{ 'is-disabled': !canRefresh }"
@click="canRefresh && handleContextMenuCommand('refresh')"
>
<el-icon><Refresh /></el-icon>
<span>刷新当前页</span>
</div>
<div
class="context-menu-item"
:class="{ 'is-disabled': !canCloseCurrent }"
@click="canCloseCurrent && handleContextMenuCommand('close')"
>
<el-icon><Close /></el-icon>
<span>关闭当前</span>
</div>
<div
class="context-menu-item"
:class="{ 'is-disabled': !canCloseOther }"
@click="canCloseOther && handleContextMenuCommand('closeOther')"
>
<el-icon><Switch /></el-icon>
<span>关闭其他</span>
</div>
<div
class="context-menu-item"
:class="{ 'is-disabled': !canCloseLeft }"
@click="canCloseLeft && handleContextMenuCommand('closeLeft')"
>
<el-icon><ArrowLeft /></el-icon>
<span>关闭左侧</span>
</div>
<div
class="context-menu-item"
:class="{ 'is-disabled': !canCloseRight }"
@click="canCloseRight && handleContextMenuCommand('closeRight')"
>
<el-icon><ArrowRight /></el-icon>
<span>关闭右侧</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleContextMenuCommand('closeAll')">
<el-icon><FolderRemove /></el-icon>
<span>关闭全部</span>
</div>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { useTabsStore, fixedTabs, isFixedTab } from '@/stores/modules/tabs'
import type { TabItem } from '@/stores/modules/tabs'
import draggable from 'vuedraggable'
import { Close, Refresh, Switch, ArrowLeft, ArrowRight, FolderRemove, MoreFilled } from '@element-plus/icons-vue'
// 能否关闭当前(右键点击的不是固定标签页)
const canCloseCurrent = computed(() => {
const path = contextMenuPath.value || tabsStore.activeTab || ''
return !isFixedTab(path)
})
// 能否刷新(右键点击的 tab 是当前打开的页面)
const canRefresh = computed(() => {
const path = contextMenuPath.value || ''
return path === route.path
})
// 能否关闭其他(除了固定标签和右键点击的标签外还有其他标签)
const canCloseOther = computed(() => {
const path = contextMenuPath.value || tabsStore.activeTab || ''
// 标签总数 - 固定标签数 - 右键点击的标签(如果是固定标签则不减)> 0
const fixedCount = tabsStore.tabs.filter(t => isFixedTab(t.path)).length
const otherCount = tabsStore.tabs.length - fixedCount - (isFixedTab(path) ? 0 : 1)
return otherCount > 0
})
// 能否关闭左侧(左侧有非固定标签页)
const canCloseLeft = computed(() => {
const path = contextMenuPath.value || tabsStore.activeTab || ''
const rightClickIndex = tabsStore.tabs.findIndex(t => t.path === path)
if (rightClickIndex <= 0) return false
// 检查左侧是否有非固定标签
for (let i = 0; i < rightClickIndex; i++) {
const tab = tabsStore.tabs[i]
if (tab && !isFixedTab(tab.path)) {
return true
}
}
return false
})
// 能否关闭右侧(右侧有非固定标签页)
const canCloseRight = computed(() => {
const path = contextMenuPath.value || tabsStore.activeTab || ''
const rightClickIndex = tabsStore.tabs.findIndex(t => t.path === path)
if (rightClickIndex === -1 || rightClickIndex >= tabsStore.tabs.length - 1) return false
// 检查右侧是否有非固定标签
for (let i = rightClickIndex + 1; i < tabsStore.tabs.length; i++) {
const tab = tabsStore.tabs[i]
if (tab && !isFixedTab(tab.path)) {
return true
}
}
return false
})
const router = useRouter()
const route = useRoute()
const tabsStore = useTabsStore()
const tabsContainerRef = ref<HTMLElement>()
const pageDropdownRef = ref()
// 右键菜单状态
const contextMenuVisible = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const contextMenuPath = ref('')
// 滚动状态
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
// 计算右键菜单样式
const contextMenuStyle = computed(() => ({
left: contextMenuX.value + 'px',
top: contextMenuY.value + 'px'
}))
// 双向绑定tabs列表用于拖拽
const tabsList = computed({
get: () => tabsStore.tabs,
set: (val) => tabsStore.setTabs(val)
})
// 检查滚动状态
const checkScroll = () => {
if (tabsContainerRef.value) {
const { scrollLeft, scrollWidth, clientWidth } = tabsContainerRef.value
canScrollLeft.value = scrollLeft > 0
canScrollRight.value = scrollLeft + clientWidth < scrollWidth - 1
}
}
// 向左滚动
const scrollLeft = () => {
if (tabsContainerRef.value && canScrollLeft.value) {
tabsContainerRef.value.scrollBy({ left: -200, behavior: 'smooth' })
setTimeout(checkScroll, 300)
}
}
// 向右滚动
const scrollRight = () => {
if (tabsContainerRef.value && canScrollRight.value) {
tabsContainerRef.value.scrollBy({ left: 200, behavior: 'smooth' })
setTimeout(checkScroll, 300)
}
}
// 切换当前页面下拉菜单
const togglePageDropdown = () => {
if (pageDropdownRef.value) {
pageDropdownRef.value.handleOpen()
}
}
// 组件挂载后,检查是否需要跳转到保存的激活页面
onMounted(() => {
// 如果当前路由不是保存的激活页面,则跳转
if (tabsStore.activeTab && tabsStore.activeTab !== route.path) {
const targetTab = tabsStore.tabs.find(item => item.path === tabsStore.activeTab)
if (targetTab) {
router.replace({ path: targetTab.path, query: targetTab.query })
}
}
// 初始检查滚动状态
nextTick(checkScroll)
// 添加滚动监听
tabsContainerRef.value?.addEventListener('scroll', checkScroll)
// 全局左键点击关闭右键菜单
document.addEventListener('click', handleGlobalClick)
// 全局右键监听如果右键在其他tab上关闭当前右键菜单
document.addEventListener('contextmenu', handleGlobalContextMenu)
})
onUnmounted(() => {
tabsContainerRef.value?.removeEventListener('scroll', checkScroll)
document.removeEventListener('click', handleGlobalClick)
document.removeEventListener('contextmenu', handleGlobalContextMenu)
})
// 全局左键点击处理 - 关闭右键菜单
const handleGlobalClick = (e: MouseEvent) => {
closeContextMenu()
closePageDropdown()
}
// 全局右键菜单处理 - 如果右键在其他位置,关闭当前右键菜单
const handleGlobalContextMenu = (e: MouseEvent) => {
// 检查右键点击是否在tabs区域内
const target = e.target as HTMLElement
const tabItem = target.closest('.tab-item')
// 如果右键不在任何一个tab上关闭右键菜单
if (!tabItem) {
closeContextMenu()
}
}
// 监听路由变化,自动添加页签
watch(
() => route.fullPath,
() => {
if (route.meta?.title && !route.meta?.isAuthPage) {
tabsStore.addTab({
title: route.meta.title as string,
path: route.path,
query: route.query
})
// 路由变化后检查滚动
nextTick(checkScroll)
}
},
{ immediate: true }
)
// 页签点击
const handleTabClick = (tab: TabItem) => {
// 点击时关闭右键菜单
closeContextMenu()
tabsStore.setActiveTab(tab.path)
if (tab.path !== route.path) {
router.push({ path: tab.path, query: tab.query })
}
}
// 页签关闭
const handleTabRemove = (path: any) => {
tabsStore.removeTab(path)
// 如果关闭的是当前页签,跳转到新的激活页签
if (path === route.path && tabsStore.activeTab) {
const targetTab = tabsStore.tabs.find(item => item.path === tabsStore.activeTab)
if (targetTab) {
router.push({ path: targetTab.path, query: targetTab.query })
}
}
// 关闭后检查滚动状态
nextTick(checkScroll)
}
// 下拉菜单命令处理
const handleDropdownCommand = (path: string) => {
handleTabClick({ path, title: '', query: {} })
}
// 关闭当前页面下拉菜单
const closePageDropdown = () => {
if (pageDropdownRef.value) {
pageDropdownRef.value.handleClose()
}
}
// 拖拽开始时关闭右键菜单
const handleDragStart = () => {
closeContextMenu()
}
// 检查拖拽移动是否允许(固定标签页不能被拖动到左侧)
const checkDragMove = (evt: any) => {
const draggedPath = evt.draggedContext.element.path
// 固定标签页本身不能移动
if (isFixedTab(draggedPath)) {
return false
}
// 获取目标位置的索引
const toIndex = evt.draggedContext.toIndex
// 检查目标位置左侧是否有固定标签页
for (let i = 0; i < toIndex; i++) {
const tab = tabsStore.tabs[i]
if (tab && isFixedTab(tab.path)) {
// 如果目标位置左边有固定标签页,不允许移动到这里
return false
}
}
return true
}
// 拖拽结束
const handleDragEnd = () => {
// 确保固定标签页始终在最左边
const fixedTabsList: TabItem[] = []
const otherTabsList: TabItem[] = []
for (const tab of tabsStore.tabs) {
if (isFixedTab(tab.path)) {
fixedTabsList.push(tab)
} else {
otherTabsList.push(tab)
}
}
// 只有当固定标签页不在最前面时才需要重新排序
const firstTab = tabsStore.tabs[0]
if (firstTab && !isFixedTab(firstTab.path)) {
tabsStore.setTabs([...fixedTabsList, ...otherTabsList])
}
}
// 右键菜单命令处理
const handleContextMenuCommand = (command: string) => {
// 使用右键选中的路径,如果没有则用当前激活的标签
const currentPath = contextMenuPath.value || tabsStore.activeTab
// 保存右键点击的路径(在关闭菜单前保存)
const rightClickedPath = contextMenuPath.value
closeContextMenu()
switch (command) {
case 'refresh':
// 获取当前右键点击的 tab 或当前激活的 tab
const refreshPath = rightClickedPath || tabsStore.activeTab
if (refreshPath) {
const targetTab = tabsStore.tabs.find(item => item.path === refreshPath)
// 如果不是当前页面,先跳转过去并更新 activeTab
if (refreshPath !== route.path) {
tabsStore.setActiveTab(refreshPath)
router.push({ path: refreshPath, query: targetTab?.query }).then(() => {
// 等待路由切换完成后再刷新
nextTick(() => {
tabsStore.refreshTab(refreshPath)
})
})
} else {
// 当前页面直接刷新
tabsStore.refreshTab(refreshPath)
}
}
break
case 'close':
if (currentPath) {
// 检查关闭后当前浏览的页面是否还存在
const willCloseCurrentPage = currentPath === route.path
handleTabRemove(currentPath)
// 如果当前页面被关闭了,跳转到右键选中的页面
if (willCloseCurrentPage && tabsStore.activeTab) {
const newTab = tabsStore.tabs.find(item => item.path === tabsStore.activeTab)
if (newTab) {
router.push({ path: newTab.path, query: newTab.query })
}
}
}
break
case 'closeOther':
if (currentPath) {
// 检查关闭后当前浏览的页面是否还存在(关闭其他时只有右键点击的页面保留)
const willCloseCurrentPage = route.path !== currentPath
tabsStore.closeOtherTabs(currentPath)
// 如果当前页面被关闭了,跳转到右键选中的页面
if (willCloseCurrentPage) {
const targetTab = tabsStore.tabs.find(item => item.path === currentPath)
if (targetTab) {
router.push({ path: targetTab.path, query: targetTab.query })
}
}
}
break
case 'closeLeft':
if (currentPath) {
// 获取要关闭的标签列表(左侧所有标签,排除固定标签页)
const rightClickIndex = tabsStore.tabs.findIndex(item => item.path === currentPath)
const tabsToClose = tabsStore.tabs.slice(0, rightClickIndex).filter(item => item.path !== '/dashboard')
// 检查当前页面是否在要关闭的列表中
const currentTab = tabsToClose.find(item => item.path === route.path)
tabsStore.closeLeftTabs(currentPath)
// 如果当前页面在要关闭的列表中,跳转到右键选中的页面
if (currentTab) {
const targetTab = tabsStore.tabs.find(item => item.path === currentPath)
if (targetTab) {
router.push({ path: targetTab.path, query: targetTab.query })
}
}
}
break
case 'closeRight':
if (currentPath) {
// 获取要关闭的标签列表(右侧所有标签,排除固定标签页)
const rightClickIndex = tabsStore.tabs.findIndex(item => item.path === currentPath)
const tabsToClose = tabsStore.tabs.slice(rightClickIndex + 1).filter(item => item.path !== '/dashboard')
// 检查当前页面是否在要关闭的列表中
const currentTab = tabsToClose.find(item => item.path === route.path)
tabsStore.closeRightTabs(currentPath)
// 如果当前页面在要关闭的列表中,跳转到右键选中的页面
if (currentTab) {
const targetTab = tabsStore.tabs.find(item => item.path === currentPath)
if (targetTab) {
router.push({ path: targetTab.path, query: targetTab.query })
}
}
}
break
case 'closeAll':
tabsStore.closeAllTabs()
router.push('/dashboard')
break
}
}
// 处理右键菜单显示 - 不再激活标签,只记录右键点击的路径
const handleContextMenu = (e: MouseEvent, path: string) => {
e.preventDefault()
e.stopPropagation()
// 先关闭可能打开的当前页面下拉菜单
closePageDropdown()
// 记录右键点击的路径用于后续操作
contextMenuPath.value = path
// 计算菜单位置,确保不超出屏幕
const menuWidth = 160
const menuHeight = 240
let x = e.clientX
let y = e.clientY
// 如果右边空间不够,往左显示
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 10
}
// 如果下面空间不够,往上显示
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 10
}
// 确保最小值
x = Math.max(10, x)
y = Math.max(10, y)
contextMenuX.value = x
contextMenuY.value = y
contextMenuVisible.value = true
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenuVisible.value = false
contextMenuPath.value = ''
}
</script>
<style scoped lang="scss">
.tabs-container {
height: 42px;
box-sizing: content-box;
background: var(--el-bg-color);
// border-bottom: 1px solid var(--el-border-color-light);
padding: 0 40px;
position: relative;
z-index: 10;
display: flex;
align-items: center;
.scroll-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
cursor: pointer;
color: var(--el-text-color-secondary);
transition: all 0.2s ease;
z-index: 5;
&:hover:not(.is-disabled) {
background: var(--el-fill-color);
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5);
}
&.is-disabled {
opacity: 0.4;
cursor: not-allowed;
}
&.scroll-btn-left {
left: 6px;
}
&.scroll-btn-right {
right: 6px;
}
}
.tabs-scroll-area {
flex: 1;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabs-list {
display: flex;
align-items: center;
height: 100%;
gap: 2px;
min-width: max-content;
}
.tab-item {
display: flex;
align-items: center;
height: 32px;
padding: 0 12px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-light);
// border-bottom: none;
border-radius: 4px 4px 0 0;
color: var(--el-text-color-regular);
font-size: 13px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
white-space: nowrap;
flex-shrink: 0;
.tab-label {
display: flex;
align-items: center;
cursor: grab;
&:active {
cursor: grabbing;
}
}
.tab-text {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
margin-left: 6px;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 12px;
opacity: 0.6;
transition: all 0.2s ease;
&:hover {
opacity: 1;
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
}
&:hover {
background: var(--el-fill-color);
color: var(--el-text-color-primary);
}
&.is-active {
background: #fff;
color: var(--el-color-primary);
border-color: var(--el-border-color);
font-weight: 500;
position: relative;
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: #fff;
}
}
// 拖拽动画样式
&.ghost {
opacity: 0.5;
background: var(--el-color-primary-light-9);
border: 1px dashed var(--el-color-primary);
}
&.chosen {
opacity: 0.8;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
&.drag {
opacity: 1;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
transform: translateY(-4px) scale(1.02);
}
}
.current-page-btn {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
cursor: pointer;
color: var(--el-text-color-secondary);
transition: all 0.2s ease;
padding: 0;
&:hover {
background: var(--el-fill-color);
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5);
}
}
}
// 右键菜单下拉
.context-menu-dropdown {
position: fixed;
z-index: 999999;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 4px 0;
min-width: 160px;
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(.is-disabled) {
background: var(--el-fill-color-light);
color: var(--el-color-primary);
}
&.is-disabled {
color: var(--el-text-color-placeholder);
cursor: not-allowed;
pointer-events: none;
}
.el-icon {
font-size: 14px;
}
}
.context-menu-divider {
height: 1px;
background: var(--el-border-color-light);
margin: 4px 0;
}
}
// 当前页面下拉菜单
.dropdown-header {
padding: 8px 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
// border-bottom: 1px solid var(--el-border-color-light);
background: var(--el-fill-color-lighter);
}
.page-dropdown-menu {
max-height: 300px;
overflow-y: auto;
:deep(.el-dropdown-menu__item) {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 12px;
&.is-active-item {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.dropdown-item-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-item-close {
margin-left: 8px;
font-size: 12px;
opacity: 0.6;
&:hover {
opacity: 1;
color: var(--el-color-danger);
}
}
}
}
</style>