Files
JoyD/AutoRobot/Windows/Robot/Web/src/DockLayout/TabPage.vue
2025-12-29 10:40:33 +08:00

667 lines
17 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="tab-page" :class="[`tab-page-${tabPosition}`]" style="width: 100%; height: 100%;">
<!-- 顶部标签栏 -->
<div v-if="tabPosition === 'top' && shouldShowTabs" class="tab-header tab-header-horizontal">
<div
v-for="(item, index) in slotItems"
:key="index"
:class="['tab-item', { 'active': activeTabIndex === index }]"
@click="setActiveTab(index)"
@mousedown="onTabDragStart(index, $event)"
>
<div class="flex items-center justify-between h-full px-3">
<span class="tab-title">{{ item?.title || '未命名' }}</span>
<!-- 当标签页未被激活时显示关闭按钮 -->
<button
v-if="activeTabIndex !== index"
class="button-icon p-[2px] rounded hover:opacity-100 opacity-80"
@click.stop="closeTab(item?.id)"
aria-label="关闭"
>
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<line x1="2" y1="2" x2="9" y2="9" stroke="#e6efff" stroke-width="1" />
<line x1="2" y1="9" x2="9" y2="2" stroke="#e6efff" stroke-width="1" />
</svg>
</button>
</div>
</div>
<div class="tab-placeholder"></div>
</div>
<!-- 左侧标签栏 -->
<div v-if="tabPosition === 'left' && shouldShowTabs" class="tab-header tab-header-vertical tab-header-left">
<div
v-for="(item, index) in slotItems"
:key="index"
:class="['tab-item-vertical', { 'active': activeTabIndex === index }]"
@click="setActiveTab(index)"
@mousedown="onTabDragStart(index, $event)"
>
<div class="flex flex-col items-center justify-center w-full h-full py-2">
<span class="tab-title-vertical">{{ item?.title || '未命名' }}</span>
<!-- 当标签页未被激活时显示关闭按钮 -->
<button
v-if="activeTabIndex !== index"
class="button-icon p-[2px] rounded hover:opacity-100 opacity-80 mt-1"
@click.stop="closeTab(item?.id)"
aria-label="关闭"
>
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<line x1="2" y1="2" x2="9" y2="9" stroke="#e6efff" stroke-width="1" />
<line x1="2" y1="9" x2="9" y2="2" stroke="#e6efff" stroke-width="1" />
</svg>
</button>
</div>
</div>
<div class="tab-placeholder"></div>
</div>
<!-- 右侧标签栏 -->
<div v-if="tabPosition === 'right' && shouldShowTabs" class="tab-header tab-header-vertical tab-header-right">
<div
v-for="(item, index) in slotItems"
:key="index"
:class="['tab-item-vertical', { 'active': activeTabIndex === index }]"
@click="setActiveTab(index)"
@mousedown="onTabDragStart(index, $event)"
>
<div class="flex flex-col items-center justify-center w-full h-full py-2">
<span class="tab-title-vertical">{{ item?.title || '未命名' }}</span>
<!-- 当标签页未被激活时显示关闭按钮 -->
<button
v-if="activeTabIndex !== index"
class="button-icon p-[2px] rounded hover:opacity-100 opacity-80 mt-1"
@click.stop="closeTab(item?.id)"
aria-label="关闭"
>
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<line x1="2" y1="2" x2="9" y2="9" stroke="#e6efff" stroke-width="1" />
<line x1="2" y1="9" x2="9" y2="2" stroke="#e6efff" stroke-width="1" />
</svg>
</button>
</div>
</div>
<div class="tab-placeholder"></div>
</div>
<!-- Tab页内容区域 -->
<div class="tab-content">
<!-- 使用Render组件渲染children配置 -->
<div v-for="child in Array.isArray(children) ? children : [children]" :key="child.id" style="width: 100%; height: 100%;">
<Render
:type="child.type"
:config="child"
/>
</div>
<!-- 空状态提示 -->
<div v-if="!children || (Array.isArray(children) && children.length === 0)" class="tab-empty">
<span>没有可显示的内容</span>
</div>
</div>
<!-- 底部标签栏 - 移动到最后确保在底部显示 -->
<div v-if="tabPosition === 'bottom' && shouldShowTabs" class="tab-header tab-header-horizontal tab-header-bottom">
<div
v-for="(item, index) in slotItems"
:key="index"
:class="['tab-item', { 'active': activeTabIndex === index }]"
@click="setActiveTab(index)"
@mousedown="onTabDragStart(index, $event)"
>
<div class="flex items-center justify-between h-full px-3">
<span class="tab-title">{{ item?.title || '未命名' }}</span>
<!-- 当标签页未被激活时显示关闭按钮 -->
<button
v-if="activeTabIndex !== index"
class="button-icon p-[2px] rounded hover:opacity-100 opacity-80"
@click.stop="closeTab(item?.id)"
aria-label="关闭"
>
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<line x1="2" y1="2" x2="9" y2="9" stroke="#e6efff" stroke-width="1" />
<line x1="2" y1="9" x2="9" y2="2" stroke="#e6efff" stroke-width="1" />
</svg>
</button>
</div>
</div>
<div class="tab-placeholder"></div>
</div>
</div>
</template>
<script setup>
import { defineProps, ref, onMounted, onUnmounted, computed } from 'vue'
import { emitEvent, EVENT_TYPES } from './eventBus'
import Render from './Render.vue'
const props = defineProps({
id: { type: String, required: true },
title: { type: String, default: '标签页' },
// 是否显示页标签栏
showTabs: {
type: Boolean,
default: true
},
// 标签页位置top(顶部), right(右侧), bottom(底部), left(左侧)
tabPosition: {
type: String,
default: 'top',
validator: (value) => ['top', 'right', 'bottom', 'left'].includes(value)
},
// 子组件配置,支持数组或单个对象
children: {
type: [Array, Object],
default: null
}
})
// 使用事件总线替代直接emit
// 当前激活的标签页索引
const activeTabIndex = ref(-1)
// 拖拽相关状态
let isDragging = false
let dragIndex = -1
let currentDragId = null
// 计算属性获取子组件项的props
const slotItems = computed(() => {
if (!props.children) return []
const childrenArray = Array.isArray(props.children) ? props.children : [props.children]
return childrenArray.map(child => child || {})
})
// 计算属性:控制标签栏的显示
const shouldShowTabs = computed(() => {
// 显示标签栏的条件showTabs为true且有多个子组件
if (!props.children) return false
const childrenCount = Array.isArray(props.children) ? props.children.length : 1
return props.showTabs && childrenCount > 1
})
// 设置激活的标签页
const setActiveTab = (index) => {
if (!props.children) return
const childrenArray = Array.isArray(props.children) ? props.children : [props.children]
if (index >= 0 && index < childrenArray.length) {
activeTabIndex.value = index
emitEvent(EVENT_TYPES.TAB_CHANGE, { index, tab: childrenArray[index] })
}
}
// 组件挂载后,如果有子组件且没有激活的标签,默认激活第一个
onMounted(() => {
if (props.children) {
const childrenCount = Array.isArray(props.children) ? props.children.length : 1
if (childrenCount > 0 && activeTabIndex.value === -1) {
setActiveTab(0)
}
}
})
// 关闭标签页
const closeTab = (tabId) => {
emitEvent(EVENT_TYPES.TAB_CLOSE, { id: tabId })
}
// 标签拖拽开始
const onTabDragStart = (index, event) => {
// 只有当点击的是标题文本区域(不是关闭按钮)时才触发拖拽
if (!event.target.closest('.button-icon') && !event.target.closest('button')) {
isDragging = true
dragIndex = index
// 生成统一的 dragId
currentDragId = `tabpage_${props.id}_${index}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
// 获取tabId
let tabId = null
if (props.children) {
const childrenArray = Array.isArray(props.children) ? props.children : [props.children]
tabId = childrenArray[index]?.id || `tab-${index}`
}
// 传递标签页索引和鼠标位置,包含 dragId
emitEvent(EVENT_TYPES.TAB_DRAG_START, {
dragId: currentDragId,
clientX: event.clientX,
clientY: event.clientY,
tabIndex: index,
tabId: tabId
}, {
source: { component: 'TabPage', tabPageId: props.id, tabIndex: index, dragId: currentDragId }
})
// 防止文本选择和默认行为
event.preventDefault()
event.stopPropagation()
// 将鼠标移动和释放事件绑定到document确保拖拽的连续性
document.addEventListener('mousemove', onTabDragMove)
document.addEventListener('mouseup', onTabDragEnd)
document.addEventListener('mouseleave', onTabDragEnd)
}
}
// 标签拖拽移动
const onTabDragMove = (event) => {
if (isDragging && currentDragId) {
// 防止文本选择和默认行为
event.preventDefault()
event.stopPropagation()
emitEvent(EVENT_TYPES.TAB_DRAG_MOVE, {
dragId: currentDragId,
clientX: event.clientX,
clientY: event.clientY,
tabIndex: dragIndex
})
}
}
// 标签拖拽结束
const onTabDragEnd = () => {
if (isDragging && currentDragId) {
isDragging = false
emitEvent(EVENT_TYPES.TAB_DRAG_END, {
dragId: currentDragId,
tabIndex: dragIndex
})
dragIndex = -1
currentDragId = null
// 拖拽结束后移除事件监听器
document.removeEventListener('mousemove', onTabDragMove)
document.removeEventListener('mouseup', onTabDragEnd)
document.removeEventListener('mouseleave', onTabDragEnd)
}
}
// 组件卸载时清理全局事件监听器
onUnmounted(() => {
// 确保清理所有可能存在的全局事件监听器
document.removeEventListener('mousemove', onTabDragMove)
document.removeEventListener('mouseup', onTabDragEnd)
document.removeEventListener('mouseleave', onTabDragEnd)
})
</script>
<style scoped>
/* 主容器样式 */
.tab-page {
display: flex;
width: 100%;
height: 100%;
background: #5D6B99;
border: 0px solid #c7d2ea;
border-radius: 0;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* 顶部位置:标签栏 -> 工具栏 -> 内容区 */
.tab-page-top {
flex-direction: column;
}
.tab-page-top .tab-content {
flex: 1;
overflow: auto;
min-height: 0;
}
/* 底部位置:内容区 -> 工具栏 -> 标签栏 */
.tab-page-bottom {
flex-direction: column-reverse;
}
/* 左侧位置:标签栏 -> 工具栏 -> 内容区(垂直排列) */
.tab-page-left {
flex-direction: row;
}
.tab-page-left .tab-content {
flex: 1;
overflow: auto;
min-width: 0;
}
/* 右侧位置:标签栏 -> 工具栏 -> 内容区(垂直排列) */
.tab-page-right {
flex-direction: row-reverse;
}
.tab-page-right .tab-content {
flex: 1;
overflow: auto;
min-width: 0;
}
:root {
--vs-blue-top: #4f72b3;
--vs-blue-bottom: #3c5a99;
}
/* 水平标签栏样式 */
.tab-header-horizontal {
display: flex;
flex-wrap: nowrap;
height: 28px;
background: transparent;
overflow-x: auto;
overflow-y: hidden;
padding-top: 2px;
}
.tab-header-bottom {
border-top: none;
border-bottom: none;
padding-top: 0;
padding-bottom: 2px;
}
/* 垂直标签栏样式 */
.tab-header-vertical {
display: flex;
flex-wrap: nowrap;
width: 28px;
background: transparent;
overflow-x: hidden;
overflow-y: auto;
padding-left: 2px;
}
.tab-header-right {
flex-direction: column;
padding-left: 0;
padding-right: 2px;
}
/* 水平标签项样式 */
.tab-item {
height: 26px;
margin-left: 1px;
background: linear-gradient(to bottom, var(--vs-blue-top), var(--vs-blue-bottom));
border-radius: 3px 3px 0 0;
cursor: pointer;
white-space: nowrap;
color: white;
font-size: 12px;
user-select: none;
min-width: 60px;
display: flex;
align-items: center;
}
/* 底部位置的特殊样式 */
.tab-page-bottom {
flex-direction: column;
display: flex;
height: 100%;
}
.tab-page-bottom .tab-item {
border-radius: 0 0 3px 3px;
}
/* Tab内容区域 - 占用剩余空间 */
.tab-page-bottom .tab-content {
flex: 1;
overflow: auto;
min-height: 0; /* 重要允许flex子项收缩 */
}
/* 底部标签栏 - 固定高度,始终在底部 */
.tab-page-bottom .tab-header-bottom {
flex-shrink: 0;
height: 28px;
margin-top: auto;
border-top: none;
border-bottom: none;
padding-top: 0;
padding-bottom: 2px;
}
.tab-title {
flex: 1;
text-align: left;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
/* 垂直标签项样式 */
.tab-item-vertical {
width: 26px;
height: 60px;
margin-top: 1px;
background: linear-gradient(to right, var(--vs-blue-top), var(--vs-blue-bottom));
border-radius: 3px 0 0 3px;
cursor: pointer;
white-space: nowrap;
color: white;
font-size: 12px;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
}
/* 右侧位置的特殊样式 */
.tab-page-right .tab-item-vertical {
border-radius: 0 3px 3px 0;
background: linear-gradient(to left, var(--vs-blue-top), var(--vs-blue-bottom));
}
.tab-title-vertical {
text-align: center;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 4px;
writing-mode: vertical-rl;
text-orientation: mixed;
}
/* 活动标签样式 */
.tab-item.active,
.tab-item-vertical.active {
background: #F5CC84;
border: 1px solid #c7d2ea;
color: #000000;
font-weight: 500;
cursor: move;
}
/* 顶部位置活动标签 */
.tab-page-top .tab-item.active {
border-bottom-color: #F5CC84;
border-top-width: 2px;
border-top-color: #0078d7;
}
/* 底部位置活动标签 */
.tab-page-bottom .tab-item.active {
border-top-color: #F5CC84;
border-bottom-width: 2px;
border-bottom-color: #0078d7;
}
/* 左侧位置活动标签 */
.tab-page-left .tab-item-vertical.active {
border-right-color: #F5CC84;
border-left-width: 2px;
border-left-color: #0078d7;
}
/* 右侧位置活动标签 */
.tab-page-right .tab-item-vertical.active {
border-left-color: #F5CC84;
border-right-width: 2px;
border-right-color: #0078d7;
}
/* 悬停样式 */
.tab-item:hover,
.tab-item-vertical:hover {
background: linear-gradient(to bottom, #5a7db8, #4667a4);
}
.tab-page-right .tab-item-vertical:hover {
background: linear-gradient(to left, #5a7db8, #4667a4);
}
.tab-item.active:hover,
.tab-item-vertical.active:hover {
background: #F5CC84;
color: #000000;
}
/* 按钮样式 */
.button-icon {
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* 确保在活动标签页中的按钮样式正确 */
.tab-item.active .button-icon,
.tab-item-vertical.active .button-icon {
opacity: 0.6;
}
.tab-item.active .button-icon:hover,
.tab-item-vertical.active .button-icon:hover {
opacity: 1;
background: #f3f4f6;
}
/* 确保在非活动标签页中的按钮样式正确 */
.tab-item:not(.active) .button-icon svg line,
.tab-item-vertical:not(.active) .button-icon svg line {
stroke: #e6efff;
}
.tab-item:not(.active) .button-icon:hover svg line,
.tab-item-vertical:not(.active) .button-icon:hover svg line {
stroke: white;
}
.tab-placeholder {
background: transparent;
border: transparent;
}
/* 内容区域样式 */
.tab-content {
flex: 1;
position: relative;
overflow: hidden;
min-height: 0; /* 重要允许flex子项收缩 */
width: 100%;
height: 100%;
}
/* 顶部位置:标签栏 -> 内容区 */
.tab-page-top .tab-content {
flex: 1;
overflow: auto;
min-height: 0; /* 重要允许flex子项收缩 */
}
/* 左侧位置:标签栏 -> 内容区 */
.tab-page-left .tab-content {
flex: 1;
overflow: auto;
min-height: 0; /* 重要允许flex子项收缩 */
}
/* 右侧位置:标签栏 -> 内容区 */
.tab-page-right .tab-content {
flex: 1;
overflow: auto;
min-height: 0; /* 重要允许flex子项收缩 */
}
.tab-panel {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
background: #ffffff;
display: none;
}
.tab-panel.active {
display: block;
}
.tab-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #999;
font-size: 14px;
}
/* 滚动条样式 - 水平标签栏 */
.tab-header-horizontal::-webkit-scrollbar {
height: 6px;
}
.tab-header-horizontal::-webkit-scrollbar-track {
background: #f0f0f0;
}
.tab-header-horizontal::-webkit-scrollbar-thumb {
background: #c7d2ea;
border-radius: 3px;
}
/* 滚动条样式 - 垂直标签栏 */
.tab-header-vertical::-webkit-scrollbar {
width: 6px;
}
.tab-header-vertical::-webkit-scrollbar-track {
background: #f0f0f0;
}
.tab-header-vertical::-webkit-scrollbar-thumb {
background: #c7d2ea;
border-radius: 3px;
}
/* 内容区域滚动条 */
.tab-panel::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.tab-panel::-webkit-scrollbar-track {
background: #f5f7fb;
}
.tab-panel::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #d0d6ea, #c0c7e0);
border: 1px solid #b0b6d6;
border-radius: 6px;
}
.tab-panel::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #c1c7e2, #b2b8d9);
}
</style>