Files
JoyD/AutoRobot/Windows/Robot/Web/src/DockLayout/Area.vue
2026-01-20 13:21:09 +08:00

1279 lines
37 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
ref="areaRef"
class="vs-area select-none"
:class="{ 'is-maximized': isMaximized, 'is-normal': !isMaximized }"
:style="areaStyle"
:data-area-id="id"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@click.capture="onAreaClick"
>
<!-- 调整大小的边框 -->
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-nw"
@mousedown="onResizeStart('nw', $event)"
></div>
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-ne"
@mousedown="onResizeStart('ne', $event)"
></div>
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-sw"
@mousedown="onResizeStart('sw', $event)"
></div>
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-se"
@mousedown="onResizeStart('se', $event)"
></div>
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-n"
@mousedown="onResizeStart('n', $event)"
></div>
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-e"
@mousedown="onResizeStart('e', $event)"
></div>
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-s"
@mousedown="onResizeStart('s', $event)"
></div>
<div
v-if="resizable && !isMaximized && !isSinglePanel"
class="resize-handle resize-handle-w"
@mousedown="onResizeStart('w', $event)"
></div>
<!-- 顶部标题栏 -->
<div v-if="shouldShowTitleBar" class="vs-title-bar" :class="{ 'cursor-move': !isMaximized }" @mousedown="onDragStart">
<div class="vs-title-left">
<div class="vs-app-icon" aria-label="AppIcon">
<svg class="vs-icon" viewBox="0 0 22.4 22.4" aria-hidden="true">
<path
fill="#68217A"
fill-rule="evenodd"
clip-rule="evenodd"
style="shape-rendering: crispEdges;"
d="M0 4.2 L1.8 3.4 L5.8 6.6 L12.6 0 L16.6 1.8 L16.6 15 L12.4 16.6 L6 10.2 L1.8 13.4 L0 12.6 Z
M1.8 5.8 L4.2 8.4 L1.8 10.8 Z
M8.2 8.4 L12.6 5 L12.4 11.6 Z" />
</svg>
</div>
<span class="vs-title-text">{{ title || '面板区' }}</span>
</div>
<div class="vs-title-right title-bar-buttons flex items-center gap-0.5">
<button class="button-icon p-[2px] rounded hover:opacity-100 opacity-80"
:aria-label="isMaximized ? '还原' : '最大化'"
@click.stop="onToggleMaximize"
@mousedown.stop>
<svg v-if="!isMaximized" class="icon-square-svg" width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<rect x="0.5" y="0.5" width="10" height="10" fill="#cbd6ff" stroke="#8ea3d8" stroke-width="1" />
<rect x="3" y="3" width="5" height="1" fill="#b8c6ff" />
<rect x="1" y="3" width="8.5" height="6.5" fill="#435d9c" />
</svg>
<svg v-else class="icon-square-svg" width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<path
fill="#CED4DD"
fill-rule="evenodd"
clip-rule="evenodd"
d="M1 4 L4 4 L4 1 L11 1 L11 8 L8 8 L8 11 L1 11 Z
M5 4 L5 3 L10 3 L10 7 L8 7 L8 4 Z
M2 6 L12.6 5 L7 6 L7 10 L2 10 Z" />
</svg>
</button>
<button class="button-icon p-[2px] rounded hover:opacity-100 opacity-80"
aria-label="关闭"
@click.stop="onClose"
@mousedown.stop>
<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>
<line x1="2" y1="9" x2="9" y2="2" stroke="#e6efff" stroke-width="1"></line>
</svg>
</button>
</div>
</div>
<!-- 内容区域 -->
<div class="vs-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>
</div>
</template>
<script setup>
import { defineProps, computed, ref, onMounted, onUnmounted, watch, defineExpose } from 'vue'
import { emitEvent, onEvent, EVENT_TYPES } from './eventBus'
import TabPage from './TabPage.vue'
import Panel from './Panel.vue'
import Render from './Render.vue'
import { zIndexManager, Z_INDEX_LAYERS } from './dockLayers'
const props = defineProps({
id: { type: String, required: true },
title: { type: String, default: '面板区' },
resizable: { type: Boolean, default: true },
// 初始状态(支持中文值)
windowState: { type: String, default: '正常' },
// 默认尺寸
width: { type: Number, default: 300 },
height: { type: Number, default: 250 },
// 控制标题栏显示
showTitleBar: { type: Boolean, default: true },
// 位置属性,可选
left: { type: Number, default: undefined },
top: { type: Number, default: undefined },
draggable: { type: Boolean, default: true },
// 子组件配置
children: {
type: [Array, Object],
default: () => []
},
// 外部样式
style: {
type: Object,
default: () => ({})
}
})
// 组件卸载标记
const isUnmounted = ref(false)
// 使用全局事件总线和拖拽管理器
// 本地状态
const localState = ref(props.windowState)
// 保存最大化前的位置和大小,用于还原
const maximizedFromPosition = ref(null)
// 组件引用
const areaRef = ref(null)
// 原始位置和大小(用于拖拽和调整大小)
const originalPosition = ref({
width: props.width,
height: props.height,
left: props.left || 0,
top: props.top || 0
})
// 调整大小相关状态
const isResizing = ref(false)
const resizeDirection = ref(null)
const resizeStartPos = ref({ x: 0, y: 0 })
const resizeStartSize = ref({ width: 0, height: 0 })
const resizeStartAreaPos = ref({ left: 0, top: 0 })
// 计算属性:是否是单面板场景
const isSinglePanel = computed(() => {
// 检查children配置
if (props.children) {
const childrenArray = Array.isArray(props.children) ? props.children : [props.children];
// 如果没有children不是单面板
if (childrenArray.length === 0) return false;
// 如果children不是TabPage不是单面板
const firstChild = childrenArray[0];
if (firstChild.type !== 'TabPage') return false;
// 检查TabPage的children
const tabPageChildren = firstChild.children;
if (!tabPageChildren) return false;
const tabPageChildrenArray = Array.isArray(tabPageChildren) ? tabPageChildren : [tabPageChildren];
// 如果TabPage只包含一个Panel是单面板
return tabPageChildrenArray.length === 1;
}
// 默认不是单面板
return false;
});
// 计算属性是否是只有一个TabPage且有多个Panel的情况
const isSingleTabPageWithMultiplePanels = computed(() => {
// 检查children配置
if (props.children) {
const childrenArray = Array.isArray(props.children) ? props.children : [props.children];
// 只有一个child且是TabPage
if (childrenArray.length === 1 && childrenArray[0].type === 'TabPage') {
const tabPage = childrenArray[0];
const tabPageChildren = tabPage.children;
if (tabPageChildren) {
const tabPageChildrenArray = Array.isArray(tabPageChildren) ? tabPageChildren : [tabPageChildren];
// TabPage包含多个Panel
return tabPageChildrenArray.length > 1;
}
}
}
// 默认不是
return false;
});
// 计算属性TabPage的tabPosition
const tabPagePosition = computed(() => {
// 检查children配置
if (props.children) {
const childrenArray = Array.isArray(props.children) ? props.children : [props.children];
// 只有一个child且是TabPage
if (childrenArray.length === 1 && childrenArray[0].type === 'TabPage') {
return childrenArray[0].tabPosition || 'top';
}
}
// 默认top
return 'top';
});
// 计算属性:是否显示标题栏
const shouldShowTitleBar = computed(() => {
// 基础条件props.showTitleBar为true
if (!props.showTitleBar) return false;
// 单面板场景不显示标题栏
if (isSinglePanel.value) return false;
// 只有一个TabPage且有多个Panel的情况
if (isSingleTabPageWithMultiplePanels.value) {
// 当tabPosition为top时显示标题栏其他位置不显示
return tabPagePosition.value === 'top';
}
// 检查children配置
if (props.children) {
const childrenArray = Array.isArray(props.children) ? props.children : [props.children];
// 如果没有children显示标题栏
if (childrenArray.length === 0) return true;
// 如果children不是TabPage显示标题栏
const firstChild = childrenArray[0];
if (firstChild.type !== 'TabPage') return true;
// 检查TabPage的children
const tabPageChildren = firstChild.children;
if (!tabPageChildren) return true;
const tabPageChildrenArray = Array.isArray(tabPageChildren) ? tabPageChildren : [tabPageChildren];
// 如果TabPage包含多个Panel显示标题栏
if (tabPageChildrenArray.length !== 1) return true;
// 如果TabPage只包含一个Panel不显示标题栏
return false;
}
// 默认显示标题栏
return true;
});
// 拖拽相关状态
const isDragging = ref(false)
const dragStartPos = ref({ x: 0, y: 0 })
const areaStartPos = ref({ x: 0, y: 0 })
const currentDragId = ref(null)
// 父容器引用
const parentContainer = ref(null)
// 根据本地状态计算是否最大化
const isMaximized = computed(() => localState.value === '最大化' || localState.value === 'maximized')
// 监听windowState变化同步更新localState
watch(() => props.windowState, (newState) => {
if (newState !== localState.value) {
localState.value = newState
// 如果是从外部设置为最大化,保存当前位置以便还原
if (newState === '最大化' || newState === 'maximized') {
maximizedFromPosition.value = {
width: props.width,
height: props.height,
left: props.left,
top: props.top
}
}
}
}, { immediate: true })
// 用于跟踪当前z-index的响应式ref确保点击时能触发style更新
const currentZIndex = ref(zIndexManager.getFloatingAreaZIndex(props.id));
// 监听props.id变化重新初始化z-index
watch(() => props.id, (newId) => {
currentZIndex.value = zIndexManager.getFloatingAreaZIndex(newId);
}, { immediate: true });
// 监听Z_INDEX_UPDATE事件当其他Area的z-index变化时更新当前Area的z-index
// 因为zIndexManager重新排序了所有Area的z-index所以当前Area的z-index也可能变化
const unsubscribeZIndexUpdate = onEvent(EVENT_TYPES.Z_INDEX_UPDATE, () => {
currentZIndex.value = zIndexManager.getFloatingAreaZIndex(props.id);
});
// 组件卸载时取消订阅
onUnmounted(() => {
unsubscribeZIndexUpdate();
});
// 根据状态计算尺寸和位置样式
const areaStyle = computed(() => {
let internalStyle;
if (isMaximized.value) {
// 最大化时填充满父容器使用更高的z-index确保在最顶层
internalStyle = {
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
zIndex: Z_INDEX_LAYERS.CONTENT_ACTIVE, // 使用统一的z-index层级
margin: 0,
padding: 0
}
} else {
// 非最大化状态优先使用originalPosition的值实时响应拖拽变化
internalStyle = {
width: `${originalPosition.value.width}px`,
height: `${originalPosition.value.height}px`,
left: `${originalPosition.value.left}px`,
top: `${originalPosition.value.top}px`,
zIndex: currentZIndex.value // 使用响应式的z-index
}
}
return internalStyle;
})
// 使用事件总线替代直接emit
// 处理Panel的最大化事件
const onPanelMaximize = (panelId) => {
// // console.log('🔸 Area接收最大化事件 - Panel ID:', panelId)
// 检查内容区是否只有一个Panel
let isSinglePanel = false
// 检查children配置
if (props.children) {
const childrenArray = Array.isArray(props.children) ? props.children : [props.children]
// 查找TabPage组件
const tabPages = childrenArray.filter(child => child.type === 'TabPage')
if (tabPages.length === 1) {
// 检查TabPage的children
const tabPageChildren = tabPages[0].children
if (tabPageChildren) {
const tabPageChildrenArray = Array.isArray(tabPageChildren) ? tabPageChildren : [tabPageChildren]
// 如果TabPage只有一个Panel认为是单Panel模式
isSinglePanel = tabPageChildrenArray.length === 1
}
}
}
// // console.log('🔸 检查是否单Panel模式:', { tabPages: tabPages.length, isSinglePanel })
if (isSinglePanel) {
// // console.log('🔸 单Panel模式切换Area最大化状态')
onToggleMaximize()
} else {
// // console.log('🔸 非单Panel模式转发到父组件')
// 如果不是单Panel转发给父组件处理
emitEvent(EVENT_TYPES.PANEL_MAXIMIZE, { panelId, areaId: props.id }, {
source: { component: 'Area', areaId: props.id }
})
}
}
// 处理拖拽悬停事件
const handleDragOver = (event) => {
emitEvent(EVENT_TYPES.AREA_DRAG_OVER, { event, areaId: props.id }, {
source: { component: 'Area', areaId: props.id }
})
}
// 处理拖拽离开事件
const handleDragLeave = (event) => {
emitEvent(EVENT_TYPES.AREA_DRAG_LEAVE, { event, areaId: props.id }, {
source: { component: 'Area', areaId: props.id }
})
}
// 拖拽开始
const onDragStart = (e) => {
// 最大化状态下不允许拖拽
if (isMaximized.value) return
isDragging.value = true
dragStartPos.value = {
x: e.clientX,
y: e.clientY
}
areaStartPos.value = {
x: originalPosition.value.left || 0,
y: originalPosition.value.top || 0
}
// 生成统一的 dragId
currentDragId.value = `area_${props.id}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
// 使用事件总线通知拖拽开始,包含 dragId 和标准化数据格式
emitEvent(EVENT_TYPES.AREA_DRAG_START, {
dragId: currentDragId.value,
areaId: props.id,
position: { x: e.clientX, y: e.clientY },
startLeft: originalPosition.value.left || 0,
startTop: originalPosition.value.top || 0,
timestamp: Date.now()
}, {
source: { component: 'Area', areaId: props.id, dragId: currentDragId.value }
})
// 添加鼠标移动和释放事件监听器
const handleMouseMove = (moveEvent) => {
// 检查组件是否已卸载
if (isUnmounted.value) {
// 组件已卸载,清理事件监听器
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
return
}
// 计算移动距离
const deltaX = moveEvent.clientX - dragStartPos.value.x
const deltaY = moveEvent.clientY - dragStartPos.value.y
// 更新位置
const newLeft = areaStartPos.value.x + deltaX
const newTop = areaStartPos.value.y + deltaY
// 更新原始位置
originalPosition.value.left = newLeft
originalPosition.value.top = newTop
// 发送拖拽移动事件
emitEvent(EVENT_TYPES.AREA_DRAG_MOVE, {
dragId: currentDragId.value,
areaId: props.id,
position: { x: moveEvent.clientX, y: moveEvent.clientY },
left: newLeft,
top: newTop,
timestamp: Date.now()
}, {
source: { component: 'Area', areaId: props.id, dragId: currentDragId.value }
})
}
const handleMouseUp = () => {
// 调用拖拽结束方法
onDragEnd({
dragId: currentDragId.value,
finalPosition: {
x: originalPosition.value.left || 0,
y: originalPosition.value.top || 0
},
areaId: props.id
})
// 清理事件监听器
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
// 防止文本选择
e.preventDefault()
e.stopPropagation()
}
// 拖拽移动 - 处理事件总线的area.drag.move事件
const onDragMove = (eventData) => {
// 从事件数据中获取位置信息和areaId
const { left, top, dragId, areaId } = eventData
// 只有当事件的areaId与当前组件的id匹配时才更新位置
if (areaId !== props.id) {
return;
}
// 只使用明确提供的left和top值不直接使用position.x和position.y
if (left !== undefined) {
originalPosition.value.left = left
}
if (top !== undefined) {
originalPosition.value.top = top
}
// 发送位置更新事件
emitEvent(EVENT_TYPES.AREA_POSITION_UPDATE, {
dragId: dragId || currentDragId.value,
areaId: props.id,
left: originalPosition.value.left,
top: originalPosition.value.top
}, {
source: { component: 'Area', areaId: props.id, dragId: dragId || currentDragId.value }
})
}
// 拖拽结束 - 处理事件总线的area.drag.end事件
const onDragEnd = (eventData) => {
const { dragId, finalPosition, areaId } = eventData
// 只有当事件的areaId与当前组件的id匹配时才处理拖拽结束
if (areaId !== props.id) {
return;
}
// 如果提供了finalPosition更新位置
if (finalPosition) {
originalPosition.value.left = finalPosition.x
originalPosition.value.top = finalPosition.y
}
// 清理拖拽状态
isDragging.value = false
currentDragId.value = null
// 发送位置更新事件
emitEvent(EVENT_TYPES.AREA_POSITION_UPDATE, {
dragId: dragId || currentDragId.value,
areaId: props.id,
left: originalPosition.value.left,
top: originalPosition.value.top
}, {
source: { component: 'Area', areaId: props.id, dragId: dragId || currentDragId.value }
})
}
// 处理事件总线的area.resize.move事件
const onAreaResizeMove = (eventData) => {
const { areaId, size, position, direction, timestamp } = eventData
if (areaId !== props.id) {
return
}
// 防御性检查,确保事件数据完整
if (!size || !position) {
console.error(`[Area:${props.id}] 无效的事件数据缺少size或position`)
return
}
// 应用最小尺寸限制与CSS保持一致
const minWidth = 200
const minHeight = 30
// 对输入值进行验证和过滤,确保数值有效
const inputWidth = Number(size.width)
const inputHeight = Number(size.height)
const inputLeft = Number(position.left)
const inputTop = Number(position.top)
if (isNaN(inputWidth) || isNaN(inputHeight) || isNaN(inputLeft) || isNaN(inputTop)) {
console.error(`[Area:${props.id}] 收到无效的数值,跳过更新`)
return
}
// 直接使用Panel计算好的尺寸和位置不再重新计算
// Panel已经处理了对角线方向的特殊逻辑Area只需要验证和应用即可
let newWidth = Math.max(minWidth, inputWidth)
let newHeight = Math.max(minHeight, inputHeight)
let newLeft = inputLeft
let newTop = inputTop
// 计算边界位置
const oldBottom = originalPosition.value.top + originalPosition.value.height
const newBottom = newTop + newHeight
const oldRight = originalPosition.value.left + originalPosition.value.width
const newRight = newLeft + newWidth
// 获取父容器尺寸,用于边界检查
let parentWidth = window.innerWidth
let parentHeight = window.innerHeight
if (parentContainer.value && parentContainer.value !== window) {
const parentRect = parentContainer.value.getBoundingClientRect()
parentWidth = parentRect.width
parentHeight = parentRect.height
}
// 确保整个Area在父容器可视范围内
// 如果右侧超出,调整宽度
if (newRight > parentWidth) {
newWidth = parentWidth - newLeft
}
// 如果底部超出,调整高度
if (newBottom > parentHeight) {
newHeight = parentHeight - newTop
}
// 确保不小于最小尺寸
newWidth = Math.max(minWidth, newWidth)
newHeight = Math.max(minHeight, newHeight)
// 原子性更新originalPosition避免中间状态被访问
originalPosition.value = {
width: newWidth,
height: newHeight,
left: newLeft,
top: newTop
}
// 发送区域位置和尺寸更新事件
emitEvent(EVENT_TYPES.AREA_POSITION_UPDATE, {
areaId: props.id,
left: originalPosition.value.left,
top: originalPosition.value.top,
width: originalPosition.value.width,
height: originalPosition.value.height,
timestamp: timestamp || Date.now()
}, {
source: { component: 'Area', areaId: props.id }
})
}
// 调整大小开始
const onResizeStart = (direction, e) => {
if (isMaximized.value) return
isResizing.value = true
resizeDirection.value = direction
resizeStartPos.value = {
x: e.clientX,
y: e.clientY
}
// 应用最小值限制确保初始尺寸符合渲染要求和CSS限制
resizeStartSize.value = {
width: Math.max(200, originalPosition.value.width),
height: Math.max(30, originalPosition.value.height)
}
resizeStartAreaPos.value = {
left: originalPosition.value.left,
top: originalPosition.value.top
}
// 添加鼠标移动和释放事件监听器
const handleMouseMove = (moveEvent) => onResizeMove(moveEvent)
const handleMouseUp = () => {
onResizeEnd()
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
// 防止文本选择
e.preventDefault()
e.stopPropagation()
}
// 调整大小移动
const onResizeMove = (e) => {
if (!isResizing.value) return
const deltaX = e.clientX - resizeStartPos.value.x
const deltaY = e.clientY - resizeStartPos.value.y
let newWidth = resizeStartSize.value.width
let newHeight = resizeStartSize.value.height
let newLeft = resizeStartAreaPos.value.left
let newTop = resizeStartAreaPos.value.top
// 根据方向调整大小
switch (resizeDirection.value) {
case 'nw':
// 左上方向:向左上拖动,宽度增加,高度增加,位置左上移
newWidth = Math.max(200, resizeStartSize.value.width - deltaX)
newHeight = Math.max(30, resizeStartSize.value.height - deltaY)
newLeft = resizeStartAreaPos.value.left + deltaX
newTop = resizeStartAreaPos.value.top + deltaY
break
case 'ne':
// 右上方向:向右上拖动,宽度增加,高度增加,位置右上移
newWidth = Math.max(200, resizeStartSize.value.width + deltaX)
newHeight = Math.max(30, resizeStartSize.value.height - deltaY)
newTop = resizeStartAreaPos.value.top + deltaY
break
case 'sw':
// 左下方向:向左下拖动,宽度增加,高度增加,位置左下移
newWidth = Math.max(200, resizeStartSize.value.width - deltaX)
newHeight = Math.max(30, resizeStartSize.value.height + deltaY)
newLeft = resizeStartAreaPos.value.left + deltaX
break
case 'se':
// 右下方向:向右下拖动,宽度增加,高度增加
newWidth = Math.max(200, resizeStartSize.value.width + deltaX)
newHeight = Math.max(30, resizeStartSize.value.height + deltaY)
break
case 'n':
// 上方向:向上拖动,高度增加,位置上移
newHeight = Math.max(30, resizeStartSize.value.height - deltaY)
newTop = resizeStartAreaPos.value.top + deltaY
break
case 'e':
// 右方向:向右拖动,宽度增加
newWidth = Math.max(200, resizeStartSize.value.width + deltaX)
break
case 's':
// 下方向:向下拖动,高度增加
newHeight = Math.max(30, resizeStartSize.value.height + deltaY)
break
case 'w':
// 左方向:向左拖动,宽度增加,位置左移
newWidth = Math.max(200, resizeStartSize.value.width - deltaX)
newLeft = resizeStartAreaPos.value.left + deltaX
break
}
// 确保不超出父容器边界
if (parentContainer.value) {
const parentRect = parentContainer.value.getBoundingClientRect()
// 右边界检查
if (newLeft + newWidth > parentRect.width) {
newWidth = parentRect.width - newLeft
}
// 下边界检查
if (newTop + newHeight > parentRect.height) {
newHeight = parentRect.height - newTop
}
// 左边界检查
if (newLeft < 0) {
newWidth += newLeft
newLeft = 0
}
// 上边界检查
if (newTop < 0) {
newHeight += newTop
newTop = 0
}
}
// 更新位置和大小
originalPosition.value.width = newWidth
originalPosition.value.height = newHeight
originalPosition.value.left = newLeft
originalPosition.value.top = newTop
// 使用事件总线通知位置和尺寸变化
emitEvent(EVENT_TYPES.AREA_POSITION_UPDATE, {
areaId: props.id,
left: newLeft,
top: newTop,
width: newWidth,
height: newHeight
}, {
source: { component: 'Area', areaId: props.id }
})
// 防止文本选择
e.preventDefault()
}
// 调整大小结束
const onResizeEnd = () => {
isResizing.value = false
resizeDirection.value = null
}
const onToggleMaximize = () => {
const next = isMaximized.value ? '正常' : '最大化'
if (!isMaximized.value) {
// 切换到最大化状态前,保存当前位置和大小
maximizedFromPosition.value = {
width: props.width,
height: props.height,
left: props.left,
top: props.top
}
}
localState.value = next
emitEvent(EVENT_TYPES.WINDOW_STATE_CHANGE, {
areaId: props.id,
state: next,
// 从最大化状态还原时,传递原始位置信息
position: isMaximized.value && maximizedFromPosition.value ? maximizedFromPosition.value : undefined
}, {
source: { component: 'Area', areaId: props.id }
})
}
const onClose = () => emitEvent(EVENT_TYPES.AREA_CLOSE_REQUEST, {
areaId: props.id
}, {
source: { component: 'Area', areaId: props.id }
})
// 区域点击事件 - 将当前区域置于最顶层
const onAreaClick = () => {
console.log(`[Area:${props.id}] onAreaClick called`);
zIndexManager.activateFloatingArea(props.id)
const newZIndex = zIndexManager.getFloatingAreaZIndex(props.id);
console.log(`[Area:${props.id}] New z-index: ${newZIndex}`);
// 更新响应式z-index触发areaStyle重新计算
currentZIndex.value = newZIndex;
emitEvent(EVENT_TYPES.Z_INDEX_UPDATE, {
areaId: props.id,
zIndex: newZIndex
}, {
source: { component: 'Area', areaId: props.id }
})
// 直接更新DOM元素的z-index确保界面上能看到变化
if (areaRef.value) {
areaRef.value.style.zIndex = newZIndex;
}
}
// 组件挂载后获取父容器引用并初始化位置
onMounted(() => {
parentContainer.value = document.querySelector('.dock-layout') || window
// 如果没有指定left或top自动居中定位
if (props.left === undefined || props.top === undefined) {
let parentWidth, parentHeight
if (parentContainer.value === window) {
parentWidth = window.innerWidth
parentHeight = window.innerHeight
} else if (parentContainer.value) {
const parentRect = parentContainer.value.getBoundingClientRect()
parentWidth = parentRect.width
parentHeight = parentRect.height
} else {
// 默认值,防止出错
parentWidth = 800
parentHeight = 600
}
const areaWidth = props.width || 300
const areaHeight = props.height || 250
// 计算居中位置
const centerLeft = Math.floor((parentWidth - areaWidth) / 2)
const centerTop = Math.floor((parentHeight - areaHeight) / 2)
// 更新originalPosition
originalPosition.value.left = centerLeft
originalPosition.value.top = centerTop
// 通知父组件位置和尺寸变化
emitEvent(EVENT_TYPES.AREA_POSITION_UPDATE, {
areaId: props.id,
left: centerLeft,
top: centerTop,
width: originalPosition.value.width,
height: originalPosition.value.height
}, {
source: { component: 'Area', areaId: props.id }
})
} else {
// 使用props初始化originalPosition
originalPosition.value.left = props.left
originalPosition.value.top = props.top
originalPosition.value.width = props.width
originalPosition.value.height = props.height
}
// 监听区域拖拽事件
onEvent(EVENT_TYPES.AREA_DRAG_MOVE, onDragMove, { componentId: `area-${props.id}` })
onEvent(EVENT_TYPES.AREA_DRAG_END, onDragEnd, { componentId: `area-${props.id}` })
// 监听区域resize事件 - 同时监听两种事件类型,确保兼容性
onEvent(EVENT_TYPES.AREA_RESIZE_MOVE, onAreaResizeMove, { componentId: `area-${props.id}` })
onEvent(EVENT_TYPES.AREA_RESIZE, onAreaResizeMove, { componentId: `area-${props.id}` })
})
// 组件卸载时清理状态
onUnmounted(() => {
// 设置组件卸载标记
isUnmounted.value = true
// 清理拖拽和调整大小状态
isDragging.value = false
currentDragId.value = null
isResizing.value = false
resizeDirection.value = null
})
// 处理Area合并内容
const mergeAreaContent = (sourceArea) => {
// console.log(`[Area] ${props.id} 接收到Area合并请求:`, sourceArea)
if (!sourceArea) {
// console.warn('[Area] 源Area为空无法合并内容')
return false
}
try {
// 发送合并请求事件,让父组件处理配置修改
emitEvent(EVENT_TYPES.AREA_MERGE_REQUEST, {
sourceArea: sourceArea,
targetAreaId: props.id
}, {
source: { component: 'Area', areaId: props.id }
})
// 触发事件通知父组件将源Area保存到隐藏列表
emitEvent(EVENT_TYPES.AREA_MERGED, {
sourceArea: sourceArea,
targetAreaId: props.id,
targetAreaHasContent: false, // 简化处理,由父组件判断
operation: 'merge-children',
sourceTabPages: sourceArea.children ? [sourceArea.children] : []
}, {
source: { component: 'Area', areaId: props.id }
})
return true
} catch (error) {
// console.error('[Area] 合并Area内容时出错:', error)
return false
}
}
// 暴露方法给父组件调用
defineExpose({
mergeAreaContent, // 合并Area内容的方法
id: props.id,
title: props.title,
isMaximized: isMaximized.value
})
</script>
<style scoped>
:root { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
/* 颜色(贴近 VS 蓝色主题) */
.vs-area {
--vs-blue-top: #4f72b3;
--vs-blue-bottom: #3c5a99;
--vs-blue-deep: #2c3e7a;
--vs-tab-blue: #4869a8;
--vs-border: #c7d2ea;
--vs-bg: #f5f7fb;
--vs-panel: #ffffff;
--vs-muted: #6b7aa9;
--vs-accent: #f0a000;
}
/* 容器 */
.vs-area-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.vs-area {
display: flex;
flex-direction: column;
background: var(--vs-bg);
border: 1px solid var(--vs-border);
min-width: 300px;
min-height: 30px;
}
/* 正常状态样式 */
.vs-area.is-normal {
position: absolute;
z-index: 10;
}
/* 最大化状态样式 */
.vs-area.is-maximized {
width: 100% !important;
height: 100% !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
z-index: 100 !important;
margin: 0;
padding: 0;
}
/* 标题栏 */
.vs-title-bar {
height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
color: #ffffff;
background: linear-gradient(to bottom, var(--vs-blue-top), var(--vs-blue-bottom));
border-bottom: 1px solid var(--vs-blue-deep);
z-index: 15; /* 确保标题栏在调整手柄之上 */
position: relative; /* 为z-index生效 */
}
.vs-title-left { display: flex; align-items: center; gap: 6px; }
.vs-app-icon { font-size: 12px; opacity: 0.9; }
.vs-title-text { font-size: 13px; }
.vs-title-right { display: flex; align-items: center; gap: 6px; }
.vs-btn {
width: 22px; height: 18px; line-height: 18px;
color: #ffffff; background: transparent; border: none; padding: 0; cursor: default;
position: relative; /* 确保按钮层级生效 */
z-index: 16; /* 确保按钮在最上层 */
}
.vs-btn:hover { background: rgba(255,255,255,0.12); }
.vs-close:hover { background: #e81123; }
/* 面板标题行(左右) */
.vs-pane-headers {
display: flex; align-items: center;
height: 26px; background: var(--vs-tab-blue);
border-bottom: 1px solid var(--vs-blue-deep);
color: #eaf1ff;
padding: 0 6px;
}
.vs-pane-header {
display: flex; align-items: center; gap: 8px;
height: 100%; padding: 0 10px;
}
.vs-pane-sep {
width: 1px; height: 18px; background: rgba(255,255,255,0.3);
margin: 0 8px;
}
.hdr-text { font-size: 12px; }
.hdr-icon { font-size: 10px; opacity: 0.9; }
.hdr-close { font-size: 12px; opacity: 0.9; }
.hdr-close:hover { opacity: 1; }
/* 内容区域 */
.vs-content {
display: flex;
flex: 1;
overflow: visible;
background-color: #C7D3FF;
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
/* 调整大小的手柄样式 */
.resize-handle {
position: absolute;
z-index: 14; /* 调整手柄应该在标题栏之下,但在正常区域内 */
background: transparent;
pointer-events: auto;
}
/* 四个角 */
.resize-handle-nw {
width: 12px;
height: 12px;
top: -6px;
left: -6px;
cursor: nwse-resize;
}
.resize-handle-ne {
width: 12px;
height: 12px;
top: -6px;
right: -6px;
cursor: nesw-resize;
}
.resize-handle-sw {
width: 12px;
height: 12px;
bottom: -6px;
left: -6px;
cursor: nesw-resize;
}
.resize-handle-se {
width: 12px;
height: 12px;
bottom: -6px;
right: -6px;
cursor: nwse-resize;
}
/* 四条边 */
.resize-handle-n {
height: 12px;
top: -6px;
left: 12px;
right: 12px;
cursor: ns-resize;
}
.resize-handle-e {
width: 3px;
right: -1.5px;
top: 12px;
bottom: 12px;
cursor: ew-resize;
}
.resize-handle-s {
height: 3px;
bottom: -1.5px;
left: 12px;
right: 12px;
cursor: ns-resize;
}
.resize-handle-w {
width: 3px;
left: -1.5px;
top: 12px;
bottom: 12px;
cursor: ew-resize;
}
/* 鼠标悬停在边框上时的样式提示 */
.vs-area.is-normal:not(:hover) .resize-handle {
opacity: 0;
}
.vs-area.is-normal:hover .resize-handle {
opacity: 0.5;
}
/* 左侧输出 */
.vs-left { flex: 1; background: var(--vs-panel); display: flex; }
.left-blank { flex: 1; background: #eef1f9; border-right: 1px solid var(--vs-border); }
/* 中间分割线 */
.vs-divider { width: 1px; background: var(--vs-border); }
/* 右侧 Git 更改 */
.vs-right { flex: 1; background: #f5f7fb; padding: 0; }
.sec-text { margin-bottom: 8px; }
.vs-card {
display: inline-flex; align-items: center; gap: 8px;
background: #fff; border: 1px solid var(--vs-border);
padding: 6px 8px; border-radius: 2px; margin-bottom: 10px;
box-shadow: 0 1px 0 rgba(0,0,0,0.04);
}
.card-icon { color: var(--vs-accent); }
.card-text { color: #000; }
.hint-text { color: #666; }
/* 滚动条(接近 VS */
:deep(::-webkit-scrollbar) { width: 12px; height: 12px; }
:deep(::-webkit-scrollbar-track) { background: var(--vs-bg); border-left: 1px solid var(--vs-border); }
:deep(::-webkit-scrollbar-thumb) {
background: linear-gradient(to bottom, #d0d6ea, #c0c7e0);
border: 1px solid #b0b6d6; border-radius: 6px;
}
:deep(::-webkit-scrollbar-thumb:hover) { background: linear-gradient(to bottom, #c1c7e2, #b2b8d9); }
:deep(*) { box-sizing: border-box; }
.vs-area.is-maximized {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 100;
}
.vs-icon-stage { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: transparent; overflow: auto; }
.vs-app-icon--x200 { width: 2800px; height: 2800px; }
.vs-app-icon { width: 14px; height: 14px; display: inline-block; background: transparent; opacity: 0.95; }
.vs-icon { width: 100%; height: 100%; shape-rendering: crispEdges; }
.vs-app-icon svg { display: block; }
/* 外层包裹,确保最大化时填充父容器,非最大化时居中 */
.vs-area-wrapper {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* 最大化状态时wrapper不居中 */
.vs-area-wrapper.is-maximized {
align-items: stretch;
justify-content: stretch;
}
/* 接收到的外部内容样式 */
.received-content {
width: 100%;
height: 100%;
overflow: auto;
background: #f8f9ff;
border: 1px solid #e0e6f0;
border-radius: 4px;
}
.received-item {
background: white;
border: 1px solid #d0d7e2;
border-radius: 6px;
margin: 8px;
padding: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.2s ease;
}
.received-item:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.received-title {
font-size: 13px;
font-weight: 600;
color: #2c3e7a;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #e8edf7;
}
.received-body {
min-height: 60px;
}
/* TabPage和Panel容器样式 */
.tab-page-container,
.area-container {
width: 100%;
height: 100%;
border: 1px dashed #c7d2ea;
border-radius: 4px;
padding: 12px;
background: #fafbff;
color: #6b7aa9;
text-align: center;
font-style: italic;
}
</style>