Files
JoyD/AutoRobot/Windows/Robot/Web/src/DockLayout/Area.vue

752 lines
22 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="vs-area select-none"
:class="{ 'is-maximized': isMaximized, 'is-normal': !isMaximized }"
:style="areaStyle"
:data-area-id="id"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
>
<!-- 调整大小的边框 -->
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-nw"
@mousedown="onResizeStart('nw', $event)"
></div>
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-ne"
@mousedown="onResizeStart('ne', $event)"
></div>
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-sw"
@mousedown="onResizeStart('sw', $event)"
></div>
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-se"
@mousedown="onResizeStart('se', $event)"
></div>
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-n"
@mousedown="onResizeStart('n', $event)"
></div>
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-e"
@mousedown="onResizeStart('e', $event)"
></div>
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-s"
@mousedown="onResizeStart('s', $event)"
></div>
<div
v-if="resizable && !isMaximized"
class="resize-handle resize-handle-w"
@mousedown="onResizeStart('w', $event)"
></div>
<!-- 顶部标题栏 -->
<div v-if="showTitleBar" 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="onToggleMaximize">
<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="onClose">
<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">
<!-- 这里是内容区域使用slot接收子组件并监听Panel的maximize事件 -->
<slot @maximize="onPanelMaximize"></slot>
</div>
</div>
</template>
<script setup>
import { defineProps, computed, defineEmits, ref, onMounted, watch } from 'vue'
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 }
})
// 本地状态
const localState = ref(props.WindowState)
// 保存原始位置和大小信息
const originalPosition = ref({
width: props.width,
height: props.height,
left: props.left,
top: props.top
})
// 保存最大化前的位置和大小,用于还原
const maximizedFromPosition = ref(null)
// 拖拽相关状态
const isDragging = ref(false)
const dragStartPos = ref({ x: 0, y: 0 })
const areaStartPos = ref({ x: 0, y: 0 })
// 调整大小相关状态
const isResizing = ref(false)
const resizeStartPos = ref({ x: 0, y: 0 })
const resizeDirection = ref(null)
const resizeStartSize = ref({ width: 0, height: 0 })
const resizeStartAreaPos = ref({ left: 0, top: 0 })
// 父容器引用
const parentContainer = ref(null)
// 根据本地状态计算是否最大化
const isMaximized = computed(() => localState.value === '最大化' || localState.value === 'maximized')
// 监听props位置变化更新原始位置
watch(() => props.left, (newLeft) => {
if (newLeft !== undefined && newLeft !== originalPosition.value.left) {
originalPosition.value.left = newLeft
}
}, { immediate: true })
watch(() => props.top, (newTop) => {
if (newTop !== undefined && newTop !== originalPosition.value.top) {
originalPosition.value.top = newTop
}
}, { immediate: true })
// 监听WindowState变化同步更新localState
watch(() => props.WindowState, (newState) => {
if (newState !== localState.value) {
localState.value = newState
// 如果是从外部设置为最大化,保存当前位置以便还原
if (newState === '最大化' || newState === 'maximized') {
maximizedFromPosition.value = {
width: originalPosition.value.width,
height: originalPosition.value.height,
left: originalPosition.value.left,
top: originalPosition.value.top
}
} else if (maximizedFromPosition.value) {
// 如果是从外部设置为正常状态,恢复保存的位置
originalPosition.value = { ...maximizedFromPosition.value }
}
}
}, { immediate: true })
// 根据状态计算尺寸和位置样式
const areaStyle = computed(() => {
if (isMaximized.value) {
// 最大化时填充满父容器
return {
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
zIndex: 100,
margin: 0,
padding: 0
}
}
// 非最大化状态:使用原始位置或默认居中
const style = {
width: `${originalPosition.value.width}px`,
height: `${originalPosition.value.height}px`
}
// 如果有明确的位置,则使用指定位置
if (originalPosition.value.left !== undefined) {
style.left = `${originalPosition.value.left}px`
}
if (originalPosition.value.top !== undefined) {
style.top = `${originalPosition.value.top}px`
}
return style
})
const emit = defineEmits(['close', 'update:WindowState', 'update:position', 'dragover', 'dragleave'])
// 处理Panel的最大化事件
const onPanelMaximize = (panelId) => {
// 检查内容区是否只有一个Panel
const panelChildren = $slots.default ? $slots.default() : []
const isSinglePanel = panelChildren.length === 1
if (isSinglePanel) {
// 如果只有一个Panel则切换Area的最大化状态
onToggleMaximize()
}
}
// 处理拖拽悬停事件
const handleDragOver = (event) => {
emit('dragover', event, props.id)
}
// 处理拖拽离开事件
const handleDragLeave = (event) => {
emit('dragleave', event, 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
}
// 添加全局事件监听
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
// 防止文本选择
e.preventDefault()
}
// 拖拽移动
const onDragMove = (e) => {
if (!isDragging.value) return
// 计算移动距离
const deltaX = e.clientX - dragStartPos.value.x
const deltaY = e.clientY - dragStartPos.value.y
// 计算新位置
let newLeft = areaStartPos.value.x + deltaX
let newTop = areaStartPos.value.y + deltaY
// 确保不超出父容器边界
if (parentContainer.value) {
const parentRect = parentContainer.value.getBoundingClientRect()
const areaWidth = originalPosition.value.width
const areaHeight = originalPosition.value.height
// 严格边界检查,确保元素完全在父容器内
newLeft = Math.max(0, Math.min(newLeft, parentRect.width - areaWidth))
newTop = Math.max(0, Math.min(newTop, parentRect.height - areaHeight))
}
// 更新位置
originalPosition.value.left = newLeft
originalPosition.value.top = newTop
// 通知父组件位置变化
emit('update:position', { left: newLeft, top: newTop })
}
// 拖拽结束
const onDragEnd = () => {
isDragging.value = false
// 移除全局事件监听
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
}
// 调整大小开始
const onResizeStart = (direction, e) => {
if (isMaximized.value) return
isResizing.value = true
resizeDirection.value = direction
resizeStartPos.value = {
x: e.clientX,
y: e.clientY
}
resizeStartSize.value = {
width: originalPosition.value.width,
height: originalPosition.value.height
}
resizeStartAreaPos.value = {
left: originalPosition.value.left,
top: originalPosition.value.top
}
// 添加全局事件监听
document.addEventListener('mousemove', onResizeMove)
document.addEventListener('mouseup', onResizeEnd)
document.addEventListener('mouseleave', onResizeEnd)
// 防止文本选择
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(150, 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(150, resizeStartSize.value.height - deltaY)
newTop = resizeStartAreaPos.value.top + deltaY
break
case 'sw':
newWidth = Math.max(200, resizeStartSize.value.width - deltaX)
newHeight = Math.max(150, resizeStartSize.value.height + deltaY)
newLeft = resizeStartAreaPos.value.left + deltaX
break
case 'se':
newWidth = Math.max(200, resizeStartSize.value.width + deltaX)
newHeight = Math.max(150, resizeStartSize.value.height + deltaY)
break
case 'n':
// 拖动上边框时Area向上边扩展
newHeight = Math.max(150, resizeStartSize.value.height - deltaY)
// 当deltaY为负时鼠标向上移动增加高度并向上移动位置
newTop = resizeStartAreaPos.value.top + deltaY
break
case 'e':
newWidth = Math.max(200, resizeStartSize.value.width + deltaX)
break
case 's':
newHeight = Math.max(150, resizeStartSize.value.height + deltaY)
break
case 'w':
// 拖动左边框时Area向左边扩展
newWidth = Math.max(200, resizeStartSize.value.width - deltaX)
// 当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
// 通知父组件位置变化
emit('update:position', {
left: newLeft,
top: newTop
})
// 防止文本选择
e.preventDefault()
}
// 调整大小结束
const onResizeEnd = () => {
isResizing.value = false
resizeDirection.value = null
// 移除全局事件监听
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
document.removeEventListener('mouseleave', onResizeEnd)
}
const onToggleMaximize = () => {
const next = isMaximized.value ? '正常' : '最大化'
if (!isMaximized.value) {
// 切换到最大化状态前,保存当前位置和大小
maximizedFromPosition.value = {
width: originalPosition.value.width,
height: originalPosition.value.height,
left: originalPosition.value.left,
top: originalPosition.value.top
}
} else if (maximizedFromPosition.value) {
// 从最大化状态还原时,恢复到保存的位置和大小
originalPosition.value = { ...maximizedFromPosition.value }
// 通知父组件位置变化
emit('update:position', {
left: originalPosition.value.left,
top: originalPosition.value.top
})
}
localState.value = next
emit('update:WindowState', next)
}
const onClose = () => emit('close')
// 组件挂载后获取父容器引用并初始化位置
onMounted(() => {
parentContainer.value = document.querySelector('.dock-layout') || window
// 如果没有指定left或top自动居中定位
if (originalPosition.value.left === undefined || originalPosition.value.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 = originalPosition.value.width || 300
const areaHeight = originalPosition.value.height || 250
// 计算居中位置
originalPosition.value.left = Math.floor((parentWidth - areaWidth) / 2)
originalPosition.value.top = Math.floor((parentHeight - areaHeight) / 2)
// 通知父组件位置变化
emit('update:position', {
left: originalPosition.value.left,
top: originalPosition.value.top
})
}
})
</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: 250px;
}
/* 正常状态样式 */
.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);
}
.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;
}
.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: #ffffff;
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
/* 调整大小的手柄样式 */
.resize-handle {
position: absolute;
z-index: 20;
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: 12px;
right: -6px;
top: 12px;
bottom: 12px;
cursor: ew-resize;
}
.resize-handle-s {
height: 12px;
bottom: -6px;
left: 12px;
right: 12px;
cursor: ns-resize;
}
.resize-handle-w {
width: 12px;
left: -6px;
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;
}
</style>