Add AutoRobot directory with Windows line endings
This commit is contained in:
109
AutoRobot/Windows/Robot/Web/src/components/ConnectionInfo.vue
Normal file
109
AutoRobot/Windows/Robot/Web/src/components/ConnectionInfo.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<!-- 手机端地址卡片 -->
|
||||
<div class="bg-white/90 backdrop-blur-sm rounded-2xl shadow-xl p-5 transform transition-all duration-300 hover:shadow-2xl border border-gray-100">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center mr-3">
|
||||
<i class="fa-solid fa-mobile-screen text-blue-500"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold">手机端地址</h3>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-3 rounded-xl border border-gray-100 transition-all duration-300 hover:border-primary/20 hover:bg-primary/5">
|
||||
<div class="text-xs text-gray-500 mb-1">连接地址</div>
|
||||
<div class="font-mono text-blue-600 truncate break-all">{{ phoneAddress || '未连接' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
isConnected: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
wsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 手机端地址状态
|
||||
const phoneAddress = ref('');
|
||||
|
||||
// 定时轮询获取手机端地址
|
||||
let pollInterval = null;
|
||||
|
||||
const fetchPhoneAddress = async () => {
|
||||
// 只有在连接状态时才查询手机端地址
|
||||
if (!props.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/phone-addr');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.phoneAddress) {
|
||||
phoneAddress.value = data.phoneAddress;
|
||||
} else {
|
||||
phoneAddress.value = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
// 监听连接状态变化
|
||||
watch(
|
||||
() => props.isConnected,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
// 连接成功时立即获取一次
|
||||
fetchPhoneAddress();
|
||||
// 开始轮询
|
||||
startPolling();
|
||||
} else {
|
||||
// 断开连接时清除轮询
|
||||
stopPolling();
|
||||
// 清空显示的手机端地址
|
||||
phoneAddress.value = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
pollInterval = setInterval(fetchPhoneAddress, 3000);
|
||||
};
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时的处理
|
||||
onMounted(() => {
|
||||
// 如果已经连接,开始轮询
|
||||
if (props.isConnected) {
|
||||
fetchPhoneAddress();
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时清除轮询
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
// 移除了不需要的computed属性
|
||||
</script>
|
||||
184
AutoRobot/Windows/Robot/Web/src/components/DockPanel.js
Normal file
184
AutoRobot/Windows/Robot/Web/src/components/DockPanel.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ref, computed, h } from 'vue';
|
||||
|
||||
const DockPanel = {
|
||||
name: 'DockPanel',
|
||||
props: {
|
||||
panel: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
position: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close', 'float', 'collapse', 'expand', 'move', 'showContextMenu'],
|
||||
setup(props, { emit }) {
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ x: 0, y: 0 })
|
||||
|
||||
// 简化样式计算逻辑,大部分样式通过CSS类实现
|
||||
const panelStyle = computed(() => {
|
||||
const style = {}
|
||||
|
||||
// 尺寸样式
|
||||
if (props.panel.width) {
|
||||
style.width = `${props.panel.width}px`
|
||||
}
|
||||
if (props.panel.height) {
|
||||
style.height = `${props.panel.height}px`
|
||||
}
|
||||
|
||||
// 折叠状态样式
|
||||
if (props.panel.isCollapsed) {
|
||||
if (props.position === 'left' || props.position === 'right') {
|
||||
style.width = '40px'
|
||||
} else {
|
||||
style.height = '40px'
|
||||
}
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close', props.panel.id)
|
||||
}
|
||||
|
||||
const handleFloat = () => {
|
||||
emit('float', props.panel.id)
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
emit('collapse', props.panel.id)
|
||||
}
|
||||
|
||||
const handleExpand = () => {
|
||||
emit('expand', props.panel.id)
|
||||
}
|
||||
|
||||
const handleDragStart = (e) => {
|
||||
isDragging.value = true
|
||||
dragStart.value = { x: e.clientX, y: e.clientY }
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragMove = (e) => {
|
||||
if (isDragging.value) {
|
||||
const dx = e.clientX - dragStart.value.x
|
||||
const dy = e.clientY - dragStart.value.y
|
||||
emit('move', props.panel.id, { x: dx, y: dy })
|
||||
dragStart.value = { x: e.clientX, y: e.clientY }
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const handleContextMenu = (e) => {
|
||||
e.preventDefault()
|
||||
emit('showContextMenu', props.panel.id, { x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (props.panel.icon) {
|
||||
return h('div', { class: 'dock-panel-icon' }, props.panel.icon)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const renderTitle = () => {
|
||||
if (props.panel.title) {
|
||||
return h('div', { class: 'dock-panel-title' }, props.panel.title)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (props.panel.content) {
|
||||
// 检查内容是否是HTML字符串
|
||||
if (typeof props.panel.content === 'string' && props.panel.content.includes('<')) {
|
||||
// 对于HTML字符串,创建一个div容器并使用innerHTML设置内容
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = props.panel.content;
|
||||
return h('div', {
|
||||
class: 'dock-panel-html-content',
|
||||
innerHTML: props.panel.content
|
||||
});
|
||||
} else {
|
||||
// 对于组件或普通文本,正常处理
|
||||
return h(props.panel.content, {
|
||||
panelId: props.panel.id,
|
||||
...props.panel.props
|
||||
})
|
||||
}
|
||||
}
|
||||
return h('div', { class: 'dock-panel-empty-content' }, 'Empty Panel')
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 面板根元素
|
||||
return h('div', {
|
||||
class: [
|
||||
'dock-panel',
|
||||
`dock-panel-${props.position}`,
|
||||
{ 'dock-panel-collapsed': props.panel.isCollapsed }
|
||||
],
|
||||
style: panelStyle.value,
|
||||
onMousedown: handleDragStart,
|
||||
onMousemove: handleDragMove,
|
||||
onMouseup: handleDragEnd,
|
||||
onMouseleave: handleDragEnd,
|
||||
onContextmenu: handleContextMenu
|
||||
}, [
|
||||
// 折叠状态下的展开按钮
|
||||
props.panel.isCollapsed ? (
|
||||
h('div', {
|
||||
class: 'dock-panel-expand-btn',
|
||||
onClick: handleExpand,
|
||||
title: props.panel.title
|
||||
}, renderIcon())
|
||||
) : (
|
||||
// 非折叠状态
|
||||
[
|
||||
// 标题栏
|
||||
h('div', {
|
||||
class: 'dock-panel-titlebar'
|
||||
}, [
|
||||
renderIcon(),
|
||||
renderTitle(),
|
||||
h('div', { class: 'dock-panel-actions' }, [
|
||||
h('button', {
|
||||
class: 'dock-panel-btn dock-panel-collapse-btn',
|
||||
onClick: handleCollapse,
|
||||
title: 'Collapse'
|
||||
}, '◀'),
|
||||
h('button', {
|
||||
class: 'dock-panel-btn dock-panel-float-btn',
|
||||
onClick: handleFloat,
|
||||
title: 'Float'
|
||||
}, '↗'),
|
||||
h('button', {
|
||||
class: 'dock-panel-btn dock-panel-close-btn',
|
||||
onClick: handleClose,
|
||||
title: 'Close'
|
||||
}, '×')
|
||||
])
|
||||
]),
|
||||
// 内容区
|
||||
h('div', { class: 'dock-panel-content' }, renderContent())
|
||||
]
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DockPanel
|
||||
@@ -0,0 +1,875 @@
|
||||
<template>
|
||||
<div class="dock-panel-container w-full h-full relative overflow-hidden bg-gray-100">
|
||||
<!-- 主内容区域 -->
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 顶部面板区域 -->
|
||||
<PanelArea
|
||||
v-if="topPanels.length > 0"
|
||||
position="top"
|
||||
:panels="topPanels"
|
||||
:size="topPanelHeight"
|
||||
:sizeRatios="topPanelsWidthRatios"
|
||||
:store="store"
|
||||
@closePanel="closePanel"
|
||||
@floatPanel="floatPanel"
|
||||
@dockPanel="dockPanel"
|
||||
@toggleCollapse="toggleCollapse"
|
||||
@titleBarDragStart="handlePanelTitleBarDragStart"
|
||||
@panelResizeStart="startPanelResize"
|
||||
/>
|
||||
|
||||
<!-- 中间主区域和左右面板 -->
|
||||
<div class="flex-1 flex relative overflow-hidden">
|
||||
<!-- 左侧面板区域 -->
|
||||
<PanelArea
|
||||
v-if="leftPanels.length > 0"
|
||||
position="left"
|
||||
:panels="leftPanels"
|
||||
:size="leftPanelWidth"
|
||||
:sizeRatios="leftPanelsHeightRatios"
|
||||
:store="store"
|
||||
@closePanel="closePanel"
|
||||
@floatPanel="floatPanel"
|
||||
@dockPanel="dockPanel"
|
||||
@toggleCollapse="toggleCollapse"
|
||||
@titleBarDragStart="handlePanelTitleBarDragStart"
|
||||
@panelResizeStart="startPanelResize"
|
||||
/>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="flex-1 flex flex-col bg-white relative">
|
||||
<!-- 主内容标签组 -->
|
||||
<div v-if="centerPanels.length > 0" class="flex-1">
|
||||
<TabGroup
|
||||
:panels="centerPanels"
|
||||
:activeTab="activeCenterTab"
|
||||
@switchTab="handleCenterTabSwitch"
|
||||
@closeTab="closePanel"
|
||||
@floatTab="floatPanel"
|
||||
@dockTab="dockPanel"
|
||||
@dragTabStart="handleTabDragStart"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-400">
|
||||
<div class="text-center">
|
||||
<i class="fa-solid fa-layer-group text-4xl mb-3 opacity-30"></i>
|
||||
<p>暂无面板</p>
|
||||
<p class="text-sm mt-1">您可以添加面板或从浮动窗口拖拽面板到此处</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板区域 -->
|
||||
<PanelArea
|
||||
v-if="rightPanels.length > 0"
|
||||
position="right"
|
||||
:panels="rightPanels"
|
||||
:size="rightPanelWidth"
|
||||
:sizeRatios="rightPanelsHeightRatios"
|
||||
:store="store"
|
||||
@closePanel="closePanel"
|
||||
@floatPanel="floatPanel"
|
||||
@dockPanel="dockPanel"
|
||||
@toggleCollapse="toggleCollapse"
|
||||
@titleBarDragStart="handlePanelTitleBarDragStart"
|
||||
@panelResizeStart="startPanelResize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部面板区域 -->
|
||||
<PanelArea
|
||||
v-if="bottomPanels.length > 0"
|
||||
position="bottom"
|
||||
:panels="bottomPanels"
|
||||
:size="bottomPanelHeight"
|
||||
:sizeRatios="bottomPanelsWidthRatios"
|
||||
:store="store"
|
||||
@closePanel="closePanel"
|
||||
@floatPanel="floatPanel"
|
||||
@dockPanel="dockPanel"
|
||||
@toggleCollapse="toggleCollapse"
|
||||
@titleBarDragStart="handlePanelTitleBarDragStart"
|
||||
@panelResizeStart="startPanelResize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 浮动窗口区域 -->
|
||||
<div v-for="window in floatingWindows" :key="window.id" class="absolute z-50">
|
||||
<FloatingWindow
|
||||
:window="window"
|
||||
@close="closeFloatingWindow(window.id)"
|
||||
@dock="dockFloatingWindow"
|
||||
@move="moveFloatingWindow"
|
||||
@minimize="minimizeFloatingWindow"
|
||||
@maximize="maximizeFloatingWindow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 停靠预览阴影 -->
|
||||
<div
|
||||
v-if="dockPreview.visible"
|
||||
class="absolute pointer-events-none opacity-50 z-40"
|
||||
:style="{
|
||||
top: dockPreview.position.top + 'px',
|
||||
left: dockPreview.position.left + 'px',
|
||||
width: dockPreview.position.width + 'px',
|
||||
height: dockPreview.position.height + 'px',
|
||||
backgroundColor: dockPreview.color
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 分割条组件 - 替换原来的div元素 -->
|
||||
<!-- 左侧垂直分隔线 -->
|
||||
<PanelResizer
|
||||
v-if="leftPanels.length > 0"
|
||||
position="left"
|
||||
:should-show="leftPanels.length > 0 && (rightPanels.length > 0 || centerPanels.length > 0)"
|
||||
:left-panel-width="leftPanelWidth"
|
||||
:top-panel-height="topPanelHeight"
|
||||
:bottom-panel-height="bottomPanelHeight"
|
||||
:has-top-panels="topPanels.length > 0"
|
||||
:has-bottom-panels="bottomPanels.length > 0"
|
||||
@resizeStart="startResize"
|
||||
/>
|
||||
|
||||
<!-- 右侧垂直分隔线 -->
|
||||
<PanelResizer
|
||||
v-if="rightPanels.length > 0"
|
||||
position="right"
|
||||
:should-show="rightPanels.length > 0 && (leftPanels.length > 0 || centerPanels.length > 0)"
|
||||
:right-panel-width="rightPanelWidth"
|
||||
:top-panel-height="topPanelHeight"
|
||||
:bottom-panel-height="bottomPanelHeight"
|
||||
:has-top-panels="topPanels.length > 0"
|
||||
:has-bottom-panels="bottomPanels.length > 0"
|
||||
@resizeStart="startResize"
|
||||
/>
|
||||
|
||||
<!-- 上部分水平分隔线 -->
|
||||
<PanelResizer
|
||||
v-if="topPanels.length > 0"
|
||||
position="top"
|
||||
:should-show="topPanels.length > 0 && (bottomPanels.length > 0 || centerPanels.length > 0)"
|
||||
:top-panel-height="topPanelHeight"
|
||||
@resizeStart="startResize"
|
||||
/>
|
||||
|
||||
<!-- 下部分水平分隔线 -->
|
||||
<PanelResizer
|
||||
v-if="bottomPanels.length > 0"
|
||||
position="bottom"
|
||||
:should-show="bottomPanels.length > 0 && (topPanels.length > 0 || centerPanels.length > 0)"
|
||||
:bottom-panel-height="bottomPanelHeight"
|
||||
@resizeStart="startResize"
|
||||
/>
|
||||
|
||||
<!-- 任务栏 - 显示最小化的窗口 -->
|
||||
<Taskbar
|
||||
:minimized-windows="minimizedWindows"
|
||||
@restore-window="restoreMinimizedWindow"
|
||||
/>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="absolute z-50 bg-white shadow-lg rounded border border-gray-300 py-1 min-w-[150px]"
|
||||
:style="{
|
||||
top: contextMenu.position.y + 'px',
|
||||
left: contextMenu.position.x + 'px'
|
||||
}"
|
||||
@mouseleave="hideContextMenu"
|
||||
>
|
||||
<button
|
||||
v-for="item in contextMenu.items"
|
||||
:key="item.id"
|
||||
@click="handleContextMenuAction(item.action, item.data)"
|
||||
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import DockPanel from './DockPanel.js';
|
||||
import FloatingWindow from './FloatingWindow.vue';
|
||||
import { LayoutCoordinator } from './LayoutCoordinator.js';
|
||||
import { TabGroup } from './TabGroup.js';
|
||||
import Taskbar from './Taskbar.vue';
|
||||
import { useDockPanelStore } from '../store/dockPanelStore';
|
||||
import PanelArea from './PanelArea.vue';
|
||||
import PanelResizer from './PanelResizer.vue';
|
||||
import { useLayoutManager } from './LayoutManager.js';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 预定义的面板配置
|
||||
panels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 布局配置
|
||||
layout: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
leftPanelWidth: 250,
|
||||
rightPanelWidth: 300,
|
||||
topPanelHeight: 150,
|
||||
bottomPanelHeight: 200
|
||||
})
|
||||
},
|
||||
// 最小尺寸限制
|
||||
minSizes: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
panelWidth: 150,
|
||||
panelHeight: 100,
|
||||
floatingWindowWidth: 300,
|
||||
floatingWindowHeight: 200
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 从store获取状态
|
||||
const centerPanels = computed(() => store.centerPanels);
|
||||
const floatingWindows = computed(() => store.floatingWindows);
|
||||
const minimizedWindows = computed(() => store.minimizedWindows);
|
||||
const closedPanelHistory = computed(() => store.closedPanelHistory);
|
||||
|
||||
const leftPanelArea = computed(() => store.leftPanelArea);
|
||||
const rightPanelArea = computed(() => store.rightPanelArea);
|
||||
const topPanelArea = computed(() => store.topPanelArea);
|
||||
const bottomPanelArea = computed(() => store.bottomPanelArea);
|
||||
const centerPanelArea = computed(() => store.centerPanelArea);
|
||||
|
||||
const leftPanels = computed(() => store.leftPanelArea.panels);
|
||||
const rightPanels = computed(() => store.rightPanelArea.panels);
|
||||
const topPanels = computed(() => store.topPanelArea.panels);
|
||||
const bottomPanels = computed(() => store.bottomPanelArea.panels);
|
||||
|
||||
const leftPanelWidth = computed({
|
||||
get: () => store.leftPanelArea.width,
|
||||
set: (value) => store.leftPanelArea.width = value
|
||||
});
|
||||
|
||||
const rightPanelWidth = computed({
|
||||
get: () => store.rightPanelArea.width,
|
||||
set: (value) => store.rightPanelArea.width = value
|
||||
});
|
||||
|
||||
const topPanelHeight = computed({
|
||||
get: () => store.topPanelArea.height,
|
||||
set: (value) => store.topPanelArea.height = value
|
||||
});
|
||||
|
||||
const bottomPanelHeight = computed({
|
||||
get: () => store.bottomPanelArea.height,
|
||||
set: (value) => store.bottomPanelArea.height = value
|
||||
});
|
||||
|
||||
const leftPanelsHeightRatios = computed({
|
||||
get: () => store.leftPanelArea.heightRatios,
|
||||
set: (value) => store.leftPanelArea.heightRatios = value
|
||||
});
|
||||
|
||||
const rightPanelsHeightRatios = computed({
|
||||
get: () => store.rightPanelArea.heightRatios,
|
||||
set: (value) => store.rightPanelArea.heightRatios = value
|
||||
});
|
||||
|
||||
const topPanelsWidthRatios = computed({
|
||||
get: () => store.topPanelArea.widthRatios,
|
||||
set: (value) => store.topPanelArea.widthRatios = value
|
||||
});
|
||||
|
||||
const bottomPanelsWidthRatios = computed({
|
||||
get: () => store.bottomPanelArea.widthRatios,
|
||||
set: (value) => store.bottomPanelArea.widthRatios = value
|
||||
});
|
||||
|
||||
// 激活的标签页
|
||||
const activeCenterTab = computed({
|
||||
get: () => store.activeCenterTab,
|
||||
set: (value) => store.activeCenterTab = value
|
||||
});
|
||||
|
||||
// 调整大小状态
|
||||
const isResizing = computed({
|
||||
get: () => store.isResizing,
|
||||
set: (value) => store.isResizing = value
|
||||
});
|
||||
|
||||
const resizeTarget = computed({
|
||||
get: () => store.resizeTarget,
|
||||
set: (value) => store.resizeTarget = value
|
||||
});
|
||||
|
||||
const resizeStartPos = computed({
|
||||
get: () => store.resizeStartPos,
|
||||
set: (value) => store.resizeStartPos = value
|
||||
});
|
||||
|
||||
// 缓存容器DOM引用,减少重复查询
|
||||
const container = ref(null);
|
||||
|
||||
// 初始化Pinia store
|
||||
const store = useDockPanelStore();
|
||||
|
||||
// 初始化布局管理器
|
||||
const layoutManager = useLayoutManager(store);
|
||||
|
||||
// 拖拽状态
|
||||
const dragState = computed({
|
||||
get: () => store.dragState,
|
||||
set: (value) => store.dragState = value
|
||||
});
|
||||
|
||||
// 停靠预览
|
||||
const dockPreview = computed({
|
||||
get: () => store.dockPreview,
|
||||
set: (value) => store.dockPreview = value
|
||||
});
|
||||
|
||||
// 右键菜单
|
||||
const contextMenu = computed({
|
||||
get: () => store.contextMenu,
|
||||
set: (value) => store.contextMenu = value
|
||||
});
|
||||
|
||||
// 隐藏右键菜单
|
||||
function hideContextMenu() {
|
||||
store.contextMenu.visible = false;
|
||||
}
|
||||
|
||||
// 调用store中的getPanelPositionById方法
|
||||
function getPanelPositionById(panelId) {
|
||||
return store.getPanelPositionById(panelId);
|
||||
}
|
||||
|
||||
// 处理面板标题栏拖拽开始
|
||||
function handlePanelTitleBarDragStart(panelId, event) {
|
||||
// 阻止默认行为
|
||||
event.preventDefault();
|
||||
|
||||
// 设置拖拽状态
|
||||
store.dragState.active = true;
|
||||
store.dragState.panelId = panelId;
|
||||
store.dragState.startX = event.clientX;
|
||||
store.dragState.startY = event.clientY;
|
||||
store.dragState.originalPosition = store.getPanelPositionById(panelId);
|
||||
store.dragState.isPanelDrag = true;
|
||||
}
|
||||
|
||||
// 从store获取面板大小影响关系
|
||||
const panelSizeInfluence = computed(() => ({
|
||||
influence: {
|
||||
left: store.panelSizeInfluence.influence.left,
|
||||
right: store.panelSizeInfluence.influence.right,
|
||||
top: store.panelSizeInfluence.influence.top,
|
||||
bottom: store.panelSizeInfluence.influence.bottom
|
||||
},
|
||||
influencedBy: {
|
||||
left: store.panelSizeInfluence.influencedBy.left,
|
||||
right: store.panelSizeInfluence.influencedBy.right,
|
||||
top: store.panelSizeInfluence.influencedBy.top,
|
||||
bottom: store.panelSizeInfluence.influencedBy.bottom
|
||||
}
|
||||
}));
|
||||
|
||||
// 初始化面板大小影响关系和受影响关系 - 已在store中处理
|
||||
function initializePanelSizeInfluence() {
|
||||
// 调用store方法初始化影响关系
|
||||
store.initializePanelSizeInfluence();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 辅助函数:获取面板位置的中文名称
|
||||
function getPositionName(position) {
|
||||
const positionNames = {
|
||||
left: '左区域',
|
||||
right: '右区域',
|
||||
top: '顶面板',
|
||||
bottom: '底面板',
|
||||
center: '中心区域'
|
||||
};
|
||||
return positionNames[position] || position;
|
||||
}
|
||||
|
||||
// 调整大小上下文(用于存储调整大小过程中的相关信息)
|
||||
const resizeContext = computed({
|
||||
get: () => store.resizeContext,
|
||||
set: (value) => store.resizeContext = value
|
||||
});
|
||||
|
||||
// LayoutCoordinator已移至dockPanelStore中集中管理
|
||||
|
||||
// 调用store中的initializePanels方法
|
||||
function initializePanels() {
|
||||
store.initializePanels(props.panels);
|
||||
}
|
||||
|
||||
// 调用store中的addPanel方法
|
||||
function addPanel(panel) {
|
||||
store.addPanel(panel);
|
||||
// 保存布局
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
|
||||
// 调用store中的resetLayout方法
|
||||
function resetLayout() {
|
||||
store.resetLayout();
|
||||
}
|
||||
|
||||
// 调用store中的resetPanelsSizeRatios方法
|
||||
function resetPanelsSizeRatios(position) {
|
||||
store.resetPanelsSizeRatios(position);
|
||||
}
|
||||
|
||||
// 调用store中的updatePanelsSize方法
|
||||
function updatePanelsSize(position, panelArea) {
|
||||
if (panelArea) {
|
||||
store.updatePanelsSize(position, panelArea, container.value, props.minSizes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 调用store中的addFloatingWindow方法
|
||||
function addFloatingWindow(window) {
|
||||
store.addFloatingWindow(window);
|
||||
}
|
||||
|
||||
// 调用store中的closePanel方法
|
||||
function closePanel(panelId) {
|
||||
store.closePanel(panelId, container.value);
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
|
||||
// 调用store中的closeFloatingWindow方法
|
||||
function closeFloatingWindow(windowId) {
|
||||
store.closeFloatingWindow(windowId);
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
|
||||
// 调用store中的floatPanel方法
|
||||
function floatPanel(panelId) {
|
||||
store.floatPanel(panelId, container.value);
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
|
||||
// 调用store中的dockPanel方法
|
||||
function dockPanel(panelId, position) {
|
||||
store.dockPanel(panelId, position);
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
|
||||
// 停靠浮动窗口
|
||||
function dockFloatingWindow(windowId, position) {
|
||||
dockPanel(windowId, position);
|
||||
}
|
||||
|
||||
// 移动浮动窗口
|
||||
function moveFloatingWindow(windowId, x, y) {
|
||||
const window = store.floatingWindows.find(w => w.id === windowId);
|
||||
if (window && !window.maximized) {
|
||||
window.x = x;
|
||||
window.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
// 调用store中的minimizeFloatingWindow方法
|
||||
function minimizeFloatingWindow(windowId) {
|
||||
store.minimizeFloatingWindow(windowId);
|
||||
}
|
||||
|
||||
// 调用store中的restoreMinimizedWindow方法
|
||||
function restoreMinimizedWindow(windowId) {
|
||||
store.restoreMinimizedWindow(windowId);
|
||||
}
|
||||
|
||||
// 调用store中的maximizeFloatingWindow方法
|
||||
function maximizeFloatingWindow(windowId) {
|
||||
store.maximizeFloatingWindow(windowId, container.value);
|
||||
}
|
||||
|
||||
// 调用store中的toggleCollapse方法
|
||||
function toggleCollapse(panelId) {
|
||||
store.toggleCollapse(panelId, props.minSizes);
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
|
||||
// 处理分隔线调整大小开始
|
||||
function startResize(target, event) {
|
||||
layoutManager.startResize(target, event);
|
||||
}
|
||||
|
||||
// 处理面板调整大小开始
|
||||
function startPanelResize(position, panelId, panelIndex, event) {
|
||||
layoutManager.startPanelResize(position, panelId, panelIndex, event);
|
||||
}
|
||||
|
||||
// 处理鼠标移动
|
||||
function handleMouseMove(event) {
|
||||
layoutManager.handleMouseMove(event, props.minSizes, leftPanelArea, rightPanelArea, topPanelArea, bottomPanelArea, centerPanelArea);
|
||||
}
|
||||
|
||||
// 通用函数:处理顶部和底部面板的水平调整
|
||||
function adjustPanelsHorizontal(position, panelIndex, deltaX, originalSizes) {
|
||||
try {
|
||||
// 获取面板区域的实际宽度
|
||||
if (!container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.value.getBoundingClientRect();
|
||||
// 计算可用宽度 - 顶部和底部面板的宽度应该是容器宽度减去所有受影响面板的宽度
|
||||
let availableWidth = containerRect.width;
|
||||
|
||||
// 获取当前位置面板的受影响关系
|
||||
const influencedByArray = store.panelSizeInfluence.influencedBy[position];
|
||||
if (influencedByArray && Array.isArray(influencedByArray)) {
|
||||
// 根据受影响关系调整可用空间
|
||||
influencedByArray.forEach(influence => {
|
||||
if (influence.influence && influence.property === 'width') {
|
||||
const panelArea = influence.position === 'left' ? store.leftPanelArea : store.rightPanelArea;
|
||||
const occupiedWidth = panelArea.panels.reduce((sum, panel) => sum + (panel.width || 0), 0);
|
||||
availableWidth -= occupiedWidth;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取对应的面板区对象
|
||||
const panelArea = position === 'top' ? store.topPanelArea : store.bottomPanelArea;
|
||||
const panels = panelArea.panels;
|
||||
|
||||
// 统一使用与顶部面板相同的处理逻辑
|
||||
const updatedPanels = store.adjustAdjacentPanels(
|
||||
position, panelIndex, deltaX, originalSizes, availableWidth
|
||||
);
|
||||
|
||||
if (updatedPanels !== panels) {
|
||||
// 更新面板数组
|
||||
panelArea.panels = updatedPanels;
|
||||
|
||||
// 手动调整后更新比例 - 直接使用从DOM获取的availableWidth作为基准,避免二次计算导致的不一致
|
||||
panelArea.widthRatios = updatedPanels.map(panel =>
|
||||
availableWidth > 0 ? (panel.width || 300) / availableWidth : 1 / updatedPanels.length
|
||||
);
|
||||
|
||||
// 调用影响处理函数,确保面板间影响关系正确处理
|
||||
store.handlePanelSizeInfluence(position, container.value);
|
||||
|
||||
// 保存布局,确保调整后的比例被持久化
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in adjustPanelsHorizontal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 通用函数:处理左侧和右侧面板的垂直调整
|
||||
function adjustPanelsVertical(position, panelIndex, deltaY, originalSizes) {
|
||||
try {
|
||||
// 获取容器元素以获取实际高度
|
||||
if (!container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取对应的面板区对象
|
||||
const panelArea = position === 'left' ? store.leftPanelArea : store.rightPanelArea;
|
||||
const panels = panelArea.panels;
|
||||
|
||||
// 计算可用高度 - 考虑顶部和底部面板的影响以及分隔条高度
|
||||
let availableHeight;
|
||||
|
||||
// 计算左侧/右侧面板的当前总高度(包括分隔条高度)
|
||||
const separatorHeight = 8; // 分隔条高度,从CSS类h-2推断(0.5rem = 8px)
|
||||
const panelsHeight = panels.reduce((sum, panel) => sum + (panel.height || 0), 0);
|
||||
// 分隔条数量 = 面板数量 - 1
|
||||
const totalSeparatorsHeight = (panels.length > 1) ? separatorHeight * (panels.length - 1) : 0;
|
||||
const currentTotalHeight = panelsHeight + totalSeparatorsHeight;
|
||||
|
||||
// 如果当前总高度有效,则使用它作为可用高度基准
|
||||
if (currentTotalHeight > 0) {
|
||||
availableHeight = currentTotalHeight;
|
||||
} else {
|
||||
// 否则,从容器高度开始计算,考虑顶部和底部面板的影响
|
||||
const containerRect = container.value.getBoundingClientRect();
|
||||
availableHeight = containerRect.height;
|
||||
|
||||
// 获取当前位置面板的受影响关系
|
||||
const influencedByArray = store.panelSizeInfluence.influencedBy[position];
|
||||
if (influencedByArray && Array.isArray(influencedByArray)) {
|
||||
// 根据受影响关系调整可用空间
|
||||
influencedByArray.forEach(influence => {
|
||||
if (influence.influence && influence.property === 'height') {
|
||||
if (influence.position === 'top') {
|
||||
// 顶部面板:使用max(上面板的子面板高度)作为高度
|
||||
const maxTopPanelHeight = store.topPanelArea.panels.length > 0
|
||||
? Math.max(...store.topPanelArea.panels.map(panel => panel.height || 0))
|
||||
: 0;
|
||||
availableHeight -= maxTopPanelHeight;
|
||||
} else if (influence.position === 'bottom') {
|
||||
// 底部面板:使用max(下面板的子面板高度)作为高度
|
||||
const maxBottomPanelHeight = store.bottomPanelArea.panels.length > 0
|
||||
? Math.max(...store.bottomPanelArea.panels.map(panel => panel.height || 0))
|
||||
: 0;
|
||||
availableHeight -= maxBottomPanelHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 统一使用与水平调整相同的处理逻辑
|
||||
const updatedPanels = store.adjustAdjacentPanelsVertical(
|
||||
position, panelIndex, deltaY, originalSizes, availableHeight
|
||||
);
|
||||
|
||||
if (updatedPanels !== panels) {
|
||||
// 更新面板数组
|
||||
panelArea.panels = updatedPanels;
|
||||
|
||||
// 手动调整后更新比例 - 直接使用从可用高度作为基准,避免二次计算导致的不一致
|
||||
panelArea.heightRatios = updatedPanels.map(panel =>
|
||||
availableHeight > 0 ? (panel.height || 200) / availableHeight : 1 / updatedPanels.length
|
||||
);
|
||||
|
||||
// 使用公共方法处理面板影响
|
||||
store.handlePanelSizeInfluence(position, container.value);
|
||||
|
||||
// 保存布局,确保调整后的比例被持久化
|
||||
layoutManager.saveLayout();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in adjustPanelsVertical:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 通用函数:处理区域调整大小
|
||||
function handleRegionResize(target, deltaX, deltaY) {
|
||||
try {
|
||||
// 检查容器元素是否存在
|
||||
if (!container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取容器高度,用于限制顶底面板总高度
|
||||
const containerHeight = container.value.clientHeight;
|
||||
|
||||
// 准备面板区域信息
|
||||
const panelAreas = {
|
||||
top: store.topPanelArea,
|
||||
bottom: store.bottomPanelArea,
|
||||
left: store.leftPanelArea,
|
||||
right: store.rightPanelArea,
|
||||
center: store.centerPanelArea
|
||||
};
|
||||
|
||||
switch (target) {
|
||||
case 'left':
|
||||
// 修复左侧面板拖拽速度问题:右拖应该缓慢增大宽度,进一步减小deltaX的影响
|
||||
store.adjustRegionSize('left', deltaX);
|
||||
break;
|
||||
case 'right':
|
||||
// 修复右侧面板拖拽方向相反的问题:向左拖应该增大宽度,所以反转deltaX
|
||||
store.adjustRegionSize('right', deltaX);
|
||||
break;
|
||||
case 'top':
|
||||
store.adjustRegionSize('top', deltaY, container.value);
|
||||
break;
|
||||
case 'bottom':
|
||||
store.adjustRegionSize('bottom', deltaY, container.value);
|
||||
break;
|
||||
}
|
||||
|
||||
// 使用store方法处理面板影响
|
||||
store.handlePanelSizeInfluence(target, container.value);
|
||||
} catch (error) {
|
||||
console.error('Error in handleRegionResize:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理鼠标松开
|
||||
function handleMouseUp(event) {
|
||||
layoutManager.handleMouseUp(event, hideContextMenu);
|
||||
}
|
||||
|
||||
// 处理键盘事件
|
||||
function handleKeyDown(event) {
|
||||
// Ctrl+Shift+T 恢复最近关闭的面板
|
||||
if (event.ctrlKey && event.shiftKey && event.key === 'T') {
|
||||
event.preventDefault();
|
||||
if (store.closedPanelHistory.length > 0) {
|
||||
const panel = store.closedPanelHistory.pop();
|
||||
addPanel({
|
||||
...panel,
|
||||
position: panel.lastPosition || 'center',
|
||||
floating: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Tab 切换中心标签页
|
||||
if (event.ctrlKey && event.key === 'Tab' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (store.centerPanels.length > 0) {
|
||||
store.activeCenterTab = (store.activeCenterTab + 1) % store.centerPanels.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Tab 反向切换中心标签页
|
||||
if (event.ctrlKey && event.shiftKey && event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
if (store.centerPanels.length > 0) {
|
||||
store.activeCenterTab = (store.activeCenterTab - 1 + store.centerPanels.length) % store.centerPanels.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape 键隐藏右键菜单
|
||||
if (event.key === 'Escape') {
|
||||
hideContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 初始化容器引用
|
||||
const container = document.querySelector('.dock-panel-container');
|
||||
layoutManager.setContainer(container);
|
||||
|
||||
// 检查URL参数是否有强制重置布局的标记
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const shouldResetLayout = urlParams.get('resetLayout') === 'true';
|
||||
|
||||
// 清除URL中的resetLayout参数,避免后续刷新时重复重置
|
||||
if (shouldResetLayout) {
|
||||
urlParams.delete('resetLayout');
|
||||
const newUrl = `${window.location.pathname}${urlParams.toString() ? `?${urlParams.toString()}` : ''}`;
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
}
|
||||
|
||||
if (shouldResetLayout) {
|
||||
// 强制重置模式:使用store的resetLayout方法
|
||||
store.resetLayout();
|
||||
|
||||
// 强制初始化默认面板
|
||||
initializePanels();
|
||||
} else {
|
||||
// 正常模式:先尝试加载保存的布局
|
||||
const loaded = layoutManager.loadLayout();
|
||||
|
||||
// 如果没有保存的布局或加载失败,初始化默认面板
|
||||
if (!loaded ||
|
||||
(store.leftPanelArea.panels.length === 0 && store.rightPanelArea.panels.length === 0 &&
|
||||
store.topPanelArea.panels.length === 0 && store.bottomPanelArea.panels.length === 0 &&
|
||||
store.centerPanels.length === 0)) {
|
||||
initializePanels();
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化面板大小影响关系
|
||||
initializePanelSizeInfluence();
|
||||
|
||||
// 添加事件监听
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 点击其他地方关闭右键菜单
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
|
||||
// 立即更新面板宽度以确保正确初始化
|
||||
handleResize();
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.removeEventListener('click', hideContextMenu);
|
||||
});
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
addPanel,
|
||||
closePanel,
|
||||
floatPanel,
|
||||
dockPanel,
|
||||
exportLayout: layoutManager.exportLayout.bind(layoutManager),
|
||||
importLayout: layoutManager.importLayout.bind(layoutManager),
|
||||
clearSavedLayout: layoutManager.clearSavedLayout.bind(layoutManager),
|
||||
toggleCollapse
|
||||
});
|
||||
|
||||
// 处理中心标签页切换
|
||||
function handleCenterTabSwitch(index) {
|
||||
store.activeCenterTab = index;
|
||||
}
|
||||
|
||||
// 处理窗口大小变化
|
||||
function handleResize() {
|
||||
if (!container.value) return; // 如果仍然无法获取容器,则终止执行
|
||||
|
||||
// 更新所有面板的尺寸
|
||||
updatePanelsSize('top', store.topPanelArea);
|
||||
updatePanelsSize('bottom', store.bottomPanelArea);
|
||||
updatePanelsSize('left', store.leftPanelArea);
|
||||
updatePanelsSize('right', store.rightPanelArea);
|
||||
updatePanelsSize('center', store.centerPanelArea);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* DockPanelContainer特定的作用域样式 */
|
||||
/* 共享样式已在styles/panel-shared.css中定义 */
|
||||
|
||||
.dock-panel-container {
|
||||
font-family: var(--font-family-base);
|
||||
}
|
||||
|
||||
/* 右键菜单样式 */
|
||||
.context-menu-separator {
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.context-menu-item-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 停靠预览动画 */
|
||||
.dock-preview {
|
||||
opacity: 0.5;
|
||||
z-index: var(--z-index-dock-preview);
|
||||
animation: dockPreviewPulse 1.5s infinite;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.dock-panel-container {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* 在小屏幕上调整最小尺寸 */
|
||||
.dock-panel-container {
|
||||
--min-panel-width: var(--min-panel-width-sm);
|
||||
--min-panel-height: var(--min-panel-height-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
506
AutoRobot/Windows/Robot/Web/src/components/FloatingWindow.vue
Normal file
506
AutoRobot/Windows/Robot/Web/src/components/FloatingWindow.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div
|
||||
class="floating-window"
|
||||
:style="windowStyle"
|
||||
:data-window-id="window.id"
|
||||
@click="handleWindowActivate"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div
|
||||
class="floating-window-titlebar"
|
||||
:style="titleBarStyle"
|
||||
@mousedown="handleTitleBarMouseDown"
|
||||
>
|
||||
<span v-if="window.icon" class="window-icon">{{ window.icon }}</span>
|
||||
<span class="window-title">{{ window.title }}</span>
|
||||
|
||||
<!-- 窗口操作按钮 -->
|
||||
<div class="window-controls">
|
||||
<button
|
||||
class="window-btn window-btn-dock"
|
||||
@click.stop="handleDock"
|
||||
title="停靠窗口"
|
||||
>
|
||||
📌
|
||||
</button>
|
||||
<button
|
||||
class="window-btn window-btn-minimize"
|
||||
@click.stop="handleMinimize"
|
||||
title="最小化窗口"
|
||||
>
|
||||
━
|
||||
</button>
|
||||
<button
|
||||
class="window-btn window-btn-maximize"
|
||||
@click.stop="handleMaximize"
|
||||
:title="window.maximized ? '还原窗口' : '最大化窗口'"
|
||||
>
|
||||
{{ window.maximized ? '□' : '◻' }}
|
||||
</button>
|
||||
<button
|
||||
class="window-btn window-btn-close"
|
||||
@click.stop="handleClose"
|
||||
title="关闭窗口"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div
|
||||
class="floating-window-content"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<!-- 渲染窗口内容 -->
|
||||
<template v-if="window.component">
|
||||
<component
|
||||
:is="window.component"
|
||||
v-bind="window.props || {}"
|
||||
:style="{ height: '100%', width: '100%', overflow: 'auto' }"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="window.content">
|
||||
<div class="window-default-content">
|
||||
{{ window.content }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="window-empty-content">
|
||||
无内容
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 调整大小手柄 -->
|
||||
<template v-if="!window.maximized">
|
||||
<!-- 左上 -->
|
||||
<div
|
||||
class="resize-handle resize-tl"
|
||||
@mousedown="startResize({ top: true, left: true }, $event)"
|
||||
/>
|
||||
<!-- 右上 -->
|
||||
<div
|
||||
class="resize-handle resize-tr"
|
||||
@mousedown="startResize({ top: true, right: true }, $event)"
|
||||
/>
|
||||
<!-- 左下 -->
|
||||
<div
|
||||
class="resize-handle resize-bl"
|
||||
@mousedown="startResize({ bottom: true, left: true }, $event)"
|
||||
/>
|
||||
<!-- 右下 -->
|
||||
<div
|
||||
class="resize-handle resize-br"
|
||||
@mousedown="startResize({ bottom: true, right: true }, $event)"
|
||||
/>
|
||||
<!-- 上 -->
|
||||
<div
|
||||
class="resize-handle resize-t"
|
||||
@mousedown="startResize({ top: true }, $event)"
|
||||
/>
|
||||
<!-- 下 -->
|
||||
<div
|
||||
class="resize-handle resize-b"
|
||||
@mousedown="startResize({ bottom: true }, $event)"
|
||||
/>
|
||||
<!-- 左 -->
|
||||
<div
|
||||
class="resize-handle resize-l"
|
||||
@mousedown="startResize({ left: true }, $event)"
|
||||
/>
|
||||
<!-- 右 -->
|
||||
<div
|
||||
class="resize-handle resize-r"
|
||||
@mousedown="startResize({ right: true }, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 窗口数据
|
||||
window: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits([
|
||||
'close', // 关闭窗口
|
||||
'dock', // 停靠窗口
|
||||
'move', // 移动窗口
|
||||
'minimize', // 最小化窗口
|
||||
'maximize' // 最大化窗口
|
||||
]);
|
||||
|
||||
// 窗口位置和尺寸状态
|
||||
const position = ref({
|
||||
x: props.window.x || 100,
|
||||
y: props.window.y || 100
|
||||
});
|
||||
|
||||
const size = ref({
|
||||
width: props.window.width || 400,
|
||||
height: props.window.height || 300
|
||||
});
|
||||
|
||||
// 窗口状态
|
||||
const isDragging = ref(false);
|
||||
const dragStartPos = ref({ x: 0, y: 0 });
|
||||
const isResizing = ref(false);
|
||||
const resizeStartPos = ref({ x: 0, y: 0 });
|
||||
const resizeEdges = ref({});
|
||||
|
||||
// 计算窗口样式
|
||||
const windowStyle = computed(() => {
|
||||
const style = {
|
||||
position: 'fixed',
|
||||
top: `${position.value.y}px`,
|
||||
left: `${position.value.x}px`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
zIndex: props.window.zIndex || 50,
|
||||
display: props.window.minimized ? 'none' : 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
transition: 'box-shadow 0.2s ease'
|
||||
};
|
||||
|
||||
// 最大化状态样式
|
||||
if (props.window.maximized) {
|
||||
style.top = '0px';
|
||||
style.left = '0px';
|
||||
style.width = '100vw';
|
||||
style.height = '100vh';
|
||||
style.borderRadius = '0px';
|
||||
style.zIndex = 100;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
// 计算标题栏样式
|
||||
const titleBarStyle = computed(() => {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 12px',
|
||||
height: '36px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
cursor: 'move',
|
||||
userSelect: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500'
|
||||
};
|
||||
});
|
||||
|
||||
// 计算内容区样式
|
||||
const contentStyle = computed(() => {
|
||||
return {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
};
|
||||
});
|
||||
|
||||
// 处理标题栏鼠标按下事件
|
||||
const handleTitleBarMouseDown = (event) => {
|
||||
if (event.target.tagName.toLowerCase() === 'button') {
|
||||
return; // 如果点击的是按钮,不触发拖拽
|
||||
}
|
||||
|
||||
isDragging.value = true;
|
||||
dragStartPos.value = {
|
||||
x: event.clientX - position.value.x,
|
||||
y: event.clientY - position.value.y
|
||||
};
|
||||
|
||||
// 提升窗口层级
|
||||
if (!props.window.maximized) {
|
||||
props.window.zIndex = Math.max(...Array.from(document.querySelectorAll('.floating-window')).map(el => parseInt(el.style.zIndex) || 50)) + 1;
|
||||
}
|
||||
|
||||
// 阻止默认行为
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// 处理窗口关闭
|
||||
const handleClose = () => {
|
||||
emit('close', props.window.id);
|
||||
};
|
||||
|
||||
// 处理窗口最小化
|
||||
const handleMinimize = () => {
|
||||
emit('minimize', props.window.id);
|
||||
};
|
||||
|
||||
// 处理窗口最大化/还原
|
||||
const handleMaximize = () => {
|
||||
emit('maximize', props.window.id);
|
||||
};
|
||||
|
||||
// 处理窗口停靠
|
||||
const handleDock = () => {
|
||||
emit('dock', props.window.id);
|
||||
};
|
||||
|
||||
// 开始调整大小
|
||||
const startResize = (edges, event) => {
|
||||
isResizing.value = true;
|
||||
resizeStartPos.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
resizeEdges.value = edges;
|
||||
|
||||
// 阻止默认行为
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = (event) => {
|
||||
if (isDragging.value) {
|
||||
// 处理窗口移动
|
||||
const newX = event.clientX - dragStartPos.value.x;
|
||||
const newY = event.clientY - dragStartPos.value.y;
|
||||
|
||||
position.value = {
|
||||
x: newX,
|
||||
y: newY
|
||||
};
|
||||
|
||||
emit('move', props.window.id, newX, newY);
|
||||
} else if (isResizing.value) {
|
||||
// 处理窗口调整大小
|
||||
const deltaX = event.clientX - resizeStartPos.value.x;
|
||||
const deltaY = event.clientY - resizeStartPos.value.y;
|
||||
|
||||
let newWidth = size.value.width;
|
||||
let newHeight = size.value.height;
|
||||
let newX = position.value.x;
|
||||
let newY = position.value.y;
|
||||
|
||||
// 根据边缘调整尺寸和位置
|
||||
if (resizeEdges.value.left) {
|
||||
newWidth = Math.max(200, size.value.width - deltaX);
|
||||
newX = position.value.x + deltaX;
|
||||
}
|
||||
if (resizeEdges.value.right) {
|
||||
newWidth = Math.max(200, size.value.width + deltaX);
|
||||
}
|
||||
if (resizeEdges.value.top) {
|
||||
newHeight = Math.max(150, size.value.height - deltaY);
|
||||
newY = position.value.y + deltaY;
|
||||
}
|
||||
if (resizeEdges.value.bottom) {
|
||||
newHeight = Math.max(150, size.value.height + deltaY);
|
||||
}
|
||||
|
||||
// 更新窗口状态
|
||||
size.value = {
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
position.value = {
|
||||
x: newX,
|
||||
y: newY
|
||||
};
|
||||
|
||||
// 通知父组件
|
||||
emit('move', props.window.id, newX, newY);
|
||||
|
||||
// 更新拖拽起点
|
||||
resizeStartPos.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标松开
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
isResizing.value = false;
|
||||
};
|
||||
|
||||
// 处理窗口激活
|
||||
const handleWindowActivate = () => {
|
||||
// 提升窗口层级
|
||||
if (!props.window.maximized) {
|
||||
props.window.zIndex = Math.max(...Array.from(document.querySelectorAll('.floating-window')).map(el => parseInt(el.style.zIndex) || 50)) + 1;
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
// 同步外部窗口状态
|
||||
if (props.window.x !== undefined) position.value.x = props.window.x;
|
||||
if (props.window.y !== undefined) position.value.y = props.window.y;
|
||||
if (props.window.width !== undefined) size.value.width = props.window.width;
|
||||
if (props.window.height !== undefined) size.value.height = props.window.height;
|
||||
|
||||
// 添加全局事件监听
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理全局事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 窗口图标样式 */
|
||||
.window-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 窗口标题样式 */
|
||||
.window-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 窗口控制按钮组样式 */
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 窗口按钮通用样式 */
|
||||
.window-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 2px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.window-btn:hover {
|
||||
background-color: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* 关闭按钮特殊样式 */
|
||||
.window-btn-close:hover {
|
||||
background-color: #ef4444 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* 默认内容样式 */
|
||||
.window-default-content {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 空内容样式 */
|
||||
.window-empty-content {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 调整大小手柄样式 */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background-color: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 四角手柄 */
|
||||
.resize-tl {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-tr {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-bl {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-br {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
/* 四边手柄 */
|
||||
.resize-t {
|
||||
top: -4px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-b {
|
||||
bottom: -4px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-l {
|
||||
left: -4px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-r {
|
||||
right: -4px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
</style>
|
||||
20
AutoRobot/Windows/Robot/Web/src/components/Footer.vue
Normal file
20
AutoRobot/Windows/Robot/Web/src/components/Footer.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<!-- 页脚 - 精简设计 -->
|
||||
<footer class="bg-white/80 backdrop-blur-sm border-t border-gray-100 py-4 mt-6 shadow-inner">
|
||||
<div class="container mx-auto px-4 text-center text-sm text-gray-500">
|
||||
<p class="flex items-center justify-center space-x-1">
|
||||
<i class="fa-solid fa-code"></i>
|
||||
<span>WebSocket 通信中心 © {{ year }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Computed
|
||||
const year = computed(() => {
|
||||
return new Date().getFullYear();
|
||||
});
|
||||
</script>
|
||||
91
AutoRobot/Windows/Robot/Web/src/components/Header.vue
Normal file
91
AutoRobot/Windows/Robot/Web/src/components/Header.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<!-- 导航栏 - 现代化设计 -->
|
||||
<header class="bg-white/80 backdrop-blur-md shadow-lg sticky top-0 z-50 transition-all duration-300 hover:bg-white">
|
||||
<div class="container mx-auto px-4 sm:px-6 py-2 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-2 group flex-nowrap min-w-max">
|
||||
<div class="w-8 h-8 rounded-xl bg-[#165DFF] flex items-center justify-center text-white shadow-md shadow-primary/20 transition-all duration-300 group-hover:scale-110 flex-shrink-0">
|
||||
<i class="fa-solid fa-bolt"></i>
|
||||
</div>
|
||||
<h1 class="text-base sm:text-lg font-bold text-primary whitespace-nowrap flex-shrink-0">
|
||||
WebSocket 通信中心
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 连接状态指示器 -->
|
||||
<div class="hidden sm:flex items-center space-x-2 px-2 py-1 rounded-full bg-white shadow-sm border border-gray-100 transition-all duration-300 hover:shadow-md">
|
||||
<div class="relative">
|
||||
<span :class="connectionIconClass" class="animate-pulse block w-2 h-2"></span>
|
||||
<span :class="connectionIconClass" class="absolute top-0 left-0 animate-ping opacity-30 block w-2 h-2 rounded-full"></span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="connectionTextClass">{{ connectionStatusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 连接控制按钮 -->
|
||||
<button
|
||||
@click="toggleConnection"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-all duration-300 flex items-center space-x-2 shadow-sm hover:shadow-md transform hover:-translate-y-0.5"
|
||||
:class="connectButtonClass"
|
||||
>
|
||||
<i :class="connectBtnIconClass"></i>
|
||||
<span>{{ connectBtnText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
isConnected: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
wsUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['toggle-connection']);
|
||||
|
||||
// Computed
|
||||
const connectionStatusText = computed(() => {
|
||||
return props.isConnected ? '已连接' : '未连接';
|
||||
});
|
||||
|
||||
const connectionIconClass = computed(() => {
|
||||
return props.isConnected
|
||||
? 'rounded-full bg-green-500'
|
||||
: 'rounded-full bg-red-500';
|
||||
});
|
||||
|
||||
const connectionTextClass = computed(() => {
|
||||
return props.isConnected ? 'text-green-600' : 'text-red-600';
|
||||
});
|
||||
|
||||
const connectButtonClass = computed(() => {
|
||||
return props.isConnected
|
||||
? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100'
|
||||
: 'bg-[#165DFF] text-white hover:bg-[#1453e0]';
|
||||
});
|
||||
|
||||
const connectBtnIconClass = computed(() => {
|
||||
return props.isConnected
|
||||
? 'fa-solid fa-plug-circle-xmark'
|
||||
: 'fa-solid fa-plug-circle-plus';
|
||||
});
|
||||
|
||||
const connectBtnText = computed(() => {
|
||||
return props.isConnected ? '断开连接' : '连接服务器';
|
||||
});
|
||||
|
||||
// Methods
|
||||
const toggleConnection = () => {
|
||||
emit('toggle-connection');
|
||||
};
|
||||
</script>
|
||||
696
AutoRobot/Windows/Robot/Web/src/components/LayoutCoordinator.js
Normal file
696
AutoRobot/Windows/Robot/Web/src/components/LayoutCoordinator.js
Normal file
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* 布局协调器类 - 负责管理和计算面板布局
|
||||
*/
|
||||
export class LayoutCoordinator {
|
||||
constructor(minSizes) {
|
||||
this.minSizes = minSizes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整面板区域大小
|
||||
* @param {String} target - 调整目标 ('left', 'right', 'top', 'bottom')
|
||||
* @param {Number} currentSize - 当前尺寸
|
||||
* @param {Number} delta - 变化量
|
||||
* @param {Object} panelAreas - 面板区域对象集合(用于获取其他面板区域信息)
|
||||
* @param {Number} containerHeight - 容器高度(用于限制顶底面板总高度)
|
||||
* @returns {Number} - 调整后的尺寸
|
||||
*/
|
||||
adjustRegionSize(target, currentSize, delta, panelAreas = null, containerHeight = null) {
|
||||
if (target === 'left' || target === 'right') {
|
||||
return Math.max(this.minSizes.panelWidth, currentSize + delta);
|
||||
} else if (target === 'top' || target === 'bottom') {
|
||||
// 计算调整后的尺寸
|
||||
const newSize = currentSize + delta;
|
||||
|
||||
// 应用最小高度限制
|
||||
let adjustedSize = Math.max(this.minSizes.panelHeight, newSize);
|
||||
|
||||
// 如果提供了面板区域对象和容器高度,应用总高度限制
|
||||
if (panelAreas && containerHeight && panelAreas.top && panelAreas.bottom) {
|
||||
// 计算当前顶面板和底面板的总高度
|
||||
const otherPanelHeight = target === 'top'
|
||||
? (panelAreas.bottom.height || 0)
|
||||
: (panelAreas.top.height || 0);
|
||||
|
||||
// 计算左中右面板的最小高度总和
|
||||
// 左侧面板区域最小高度(所有面板最小高度之和)
|
||||
const leftPanelMinHeight = panelAreas.left && panelAreas.left.panels ?
|
||||
panelAreas.left.panels.reduce((sum, panel) => sum + (panel.minHeight || this.minSizes.panelHeight), 0) :
|
||||
this.minSizes.panelHeight;
|
||||
|
||||
// 中心面板最小高度
|
||||
const centerPanelMinHeight = this.minSizes.panelHeight;
|
||||
|
||||
// 右侧面板区域最小高度(所有面板最小高度之和)
|
||||
const rightPanelMinHeight = panelAreas.right && panelAreas.right.panels ?
|
||||
panelAreas.right.panels.reduce((sum, panel) => sum + (panel.minHeight || this.minSizes.panelHeight), 0) :
|
||||
this.minSizes.panelHeight;
|
||||
|
||||
// 计算左中右面板的最大最小高度要求
|
||||
const maxPanelsMinHeight = Math.max(leftPanelMinHeight, centerPanelMinHeight, rightPanelMinHeight);
|
||||
|
||||
// 计算最大允许的高度(容器高度减去其他面板的高度、安全边界和左中右面板的最大最小高度)
|
||||
const safetyMargin = 40; // 为内容区域保留一些空间
|
||||
const maxAllowedHeight = containerHeight - otherPanelHeight - safetyMargin - maxPanelsMinHeight;
|
||||
|
||||
// 应用最大高度限制
|
||||
adjustedSize = Math.min(maxAllowedHeight, adjustedSize);
|
||||
}
|
||||
|
||||
return adjustedSize;
|
||||
}
|
||||
return currentSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整相邻面板的宽度或高度
|
||||
* @param {Array} panels - 面板数组
|
||||
* @param {Number} panelIndex - 当前面板索引
|
||||
* @param {Number} delta - 变化量(X或Y方向)
|
||||
* @param {Array} originalSizes - 原始尺寸数组
|
||||
* @param {Number} regionSize - 面板区域总尺寸(可选)
|
||||
* @param {String} dimension - 调整维度('width'或'height')
|
||||
* @returns {Array} - 调整后的面板数组
|
||||
*/
|
||||
adjustAdjacentPanels(panels, panelIndex, delta, originalSizes, regionSize = null, dimension = 'width') {
|
||||
const updatedPanels = [...panels];
|
||||
const minSize = dimension === 'width' ? this.minSizes.panelWidth : this.minSizes.panelHeight;
|
||||
|
||||
// 确保不是最后一个面板
|
||||
if (panelIndex < panels.length - 1) {
|
||||
// 获取当前面板和下一个面板
|
||||
const currentPanel = updatedPanels[panelIndex];
|
||||
const nextPanel = updatedPanels[panelIndex + 1];
|
||||
|
||||
// 1. 计算总尺寸 - 统一使用传入的regionSize(来自DOM实际尺寸)
|
||||
let totalSize = 0;
|
||||
|
||||
// 确保使用从DOM获取的实际尺寸作为唯一计算依据
|
||||
if (regionSize && regionSize > 0) {
|
||||
totalSize = regionSize;
|
||||
}
|
||||
// 为了确保安全性,保留一个基本的默认值处理
|
||||
else {
|
||||
totalSize = panels.length * minSize;
|
||||
}
|
||||
|
||||
// 2. 计算每个面板应占的比例
|
||||
const panelCount = panels.length;
|
||||
const baseSize = totalSize / panelCount;
|
||||
|
||||
// 3. 计算当前面板和下一面板的原始尺寸
|
||||
const getOriginalSize = (index) => {
|
||||
if (originalSizes && originalSizes[index]) {
|
||||
return originalSizes[index][dimension] || baseSize;
|
||||
}
|
||||
return baseSize;
|
||||
};
|
||||
|
||||
const currentSize = getOriginalSize(panelIndex);
|
||||
const nextSize = getOriginalSize(panelIndex + 1);
|
||||
|
||||
// 4. 应用delta变化量,并确保不小于最小尺寸限制
|
||||
const newCurrentSize = Math.max(minSize, currentSize + delta);
|
||||
const actualDelta = newCurrentSize - currentSize; // 实际应用的变化量
|
||||
const newNextSize = Math.max(minSize, nextSize - actualDelta);
|
||||
|
||||
// 5. 创建新对象以触发Vue响应式更新,并设置相应的尺寸属性
|
||||
updatedPanels[panelIndex] = {
|
||||
...updatedPanels[panelIndex],
|
||||
[dimension]: newCurrentSize
|
||||
};
|
||||
updatedPanels[panelIndex + 1] = {
|
||||
...updatedPanels[panelIndex + 1],
|
||||
[dimension]: newNextSize
|
||||
};
|
||||
|
||||
// 6. 更新其他面板的尺寸以保持总尺寸一致,并保持原有比例
|
||||
const remainingSize = totalSize - newCurrentSize - newNextSize;
|
||||
const remainingPanelsCount = panelCount - 2;
|
||||
|
||||
if (remainingPanelsCount > 0) {
|
||||
// 计算非相邻面板的原始总尺寸
|
||||
let originalRemainingTotalSize = 0;
|
||||
const originalRemainingSizes = [];
|
||||
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (i !== panelIndex && i !== panelIndex + 1) {
|
||||
const originalPanelSize = getOriginalSize(i);
|
||||
originalRemainingSizes.push(originalPanelSize);
|
||||
originalRemainingTotalSize += originalPanelSize;
|
||||
} else {
|
||||
originalRemainingSizes.push(null); // 标记为相邻面板
|
||||
}
|
||||
}
|
||||
|
||||
// 根据原始比例分配剩余尺寸
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (i !== panelIndex && i !== panelIndex + 1) {
|
||||
let newPanelSize;
|
||||
if (originalRemainingTotalSize > 0) {
|
||||
// 按原始比例分配
|
||||
newPanelSize = (originalRemainingSizes[i] / originalRemainingTotalSize) * remainingSize;
|
||||
} else {
|
||||
// 如果原始总尺寸为0,则平均分配
|
||||
newPanelSize = remainingSize / remainingPanelsCount;
|
||||
}
|
||||
|
||||
updatedPanels[i] = {
|
||||
...updatedPanels[i],
|
||||
[dimension]: Math.max(minSize, newPanelSize)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedPanels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整相邻面板的宽度(水平方向)
|
||||
* @param {Array} panels - 面板数组
|
||||
* @param {Number} panelIndex - 当前面板索引
|
||||
* @param {Number} deltaX - X轴变化量
|
||||
* @param {Array} originalSizes - 原始尺寸数组
|
||||
* @param {Number} regionWidth - 面板区域总宽度(可选)
|
||||
* @returns {Array} - 调整后的面板数组
|
||||
*/
|
||||
adjustAdjacentPanelsHorizontal(panels, panelIndex, deltaX, originalSizes, regionWidth = null) {
|
||||
return this.adjustAdjacentPanels(panels, panelIndex, deltaX, originalSizes, regionWidth, 'width');
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整相邻面板的高度(垂直方向)
|
||||
* @param {Array} panels - 面板数组
|
||||
* @param {Number} panelIndex - 当前面板索引
|
||||
* @param {Number} deltaY - Y轴变化量
|
||||
* @param {Array} originalSizes - 原始尺寸数组
|
||||
* @param {Number} regionHeight - 面板区域总高度(可选)
|
||||
* @returns {Array} - 调整后的面板数组
|
||||
*/
|
||||
adjustAdjacentPanelsVertical(panels, panelIndex, deltaY, originalSizes, regionHeight = null) {
|
||||
return this.adjustAdjacentPanels(panels, panelIndex, deltaY, originalSizes, regionHeight, 'height');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证面板尺寸是否符合约束
|
||||
* @param {Object} panel - 面板对象
|
||||
* @param {String} position - 面板位置
|
||||
* @returns {Boolean} - 是否符合约束
|
||||
*/
|
||||
validatePanelSize(panel, position) {
|
||||
if (position === 'left' || position === 'right' || position === 'top' || position === 'bottom') {
|
||||
if (panel.width && panel.width < this.minSizes.panelWidth) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化面板大小影响关系
|
||||
* @returns {Object} - 面板大小影响关系对象
|
||||
*/
|
||||
/**
|
||||
* 初始化面板大小影响关系和受影响关系
|
||||
* @returns {Object} - 包含影响关系数组和受影响关系数组的对象
|
||||
*/
|
||||
initializePanelSizeInfluence() {
|
||||
return {
|
||||
// 影响数组:当某个面板大小变化时,会影响哪些其他面板
|
||||
influence: {
|
||||
left: [
|
||||
{ position: 'center', property: 'width', influence: true }
|
||||
],
|
||||
right: [
|
||||
{ position: 'center', property: 'width', influence: true }
|
||||
],
|
||||
top: [
|
||||
{ position: 'left', property: 'height', influence: true },
|
||||
{ position: 'center', property: 'height', influence: true },
|
||||
{ position: 'right', property: 'height', influence: true }
|
||||
],
|
||||
bottom: [
|
||||
{ position: 'left', property: 'height', influence: true },
|
||||
{ position: 'center', property: 'height', influence: true },
|
||||
{ position: 'right', property: 'height', influence: true }
|
||||
],
|
||||
center: []
|
||||
},
|
||||
// 受影响数组:计算某个面板大小时,需要考虑哪些其他面板
|
||||
influencedBy: {
|
||||
left: [
|
||||
{ position: 'top', property: 'height', influence: true },
|
||||
{ position: 'center', property: 'width', influence: true },
|
||||
{ position: 'right', property: 'width', influence: true },
|
||||
{ position: 'bottom', property: 'height', influence: true }
|
||||
],
|
||||
right: [
|
||||
{ position: 'top', property: 'height', influence: true },
|
||||
{ position: 'left', property: 'width', influence: true },
|
||||
{ position: 'center', property: 'width', influence: true },
|
||||
{ position: 'bottom', property: 'height', influence: true }
|
||||
],
|
||||
top:[
|
||||
{ position: 'center', property: 'height', influence: true },
|
||||
{ position: 'bottom', property: 'height', influence: true }
|
||||
],
|
||||
bottom: [
|
||||
{ position: 'top', property: 'height', influence: true },
|
||||
{ position: 'center', property: 'height', influence: true }
|
||||
],
|
||||
center: [
|
||||
{ position: 'left', property: 'width', influence: true },
|
||||
{ position: 'right', property: 'width', influence: true },
|
||||
{ position: 'top', property: 'height', influence: true },
|
||||
{ position: 'bottom', property: 'height', influence: true }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理面板大小变化对其他面板的影响
|
||||
* @param {String} panelPosition - 触发影响的面板位置
|
||||
* @param {Object} influenceData - 面板影响关系数据,包含influence和influencedBy
|
||||
* @param {Object} panelData - 包含各位置面板数据的对象
|
||||
* @param {HTMLElement} container - 容器元素
|
||||
* @returns {Object} - 包含更新后面板数据的对象
|
||||
*/
|
||||
handlePanelSizeInfluence(panelPosition, influenceData, panelData, container) {
|
||||
const updatedPanels = { ...panelData };
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// 存储已更新的面板位置,避免重复计算
|
||||
const updatedPositions = new Set();
|
||||
|
||||
// 预先计算所有位置的可用高度和宽度,避免重复计算
|
||||
const availableHeights = {
|
||||
left: this.calculateAvailableHeight('left', influenceData, updatedPanels, containerRect.height, this.minSizes),
|
||||
right: this.calculateAvailableHeight('right', influenceData, updatedPanels, containerRect.height, this.minSizes),
|
||||
top: this.calculateAvailableHeight('top', influenceData, updatedPanels, containerRect.height, this.minSizes),
|
||||
bottom: this.calculateAvailableHeight('bottom', influenceData, updatedPanels, containerRect.height, this.minSizes),
|
||||
center: this.calculateAvailableHeight('center', influenceData, updatedPanels, containerRect.height, this.minSizes)
|
||||
};
|
||||
|
||||
const availableWidths = {
|
||||
left: this.calculateAvailableWidth('left', influenceData, updatedPanels, containerRect.width, this.minSizes),
|
||||
right: this.calculateAvailableWidth('right', influenceData, updatedPanels, containerRect.width, this.minSizes),
|
||||
top: this.calculateAvailableWidth('top', influenceData, updatedPanels, containerRect.width, this.minSizes),
|
||||
bottom: this.calculateAvailableWidth('bottom', influenceData, updatedPanels, containerRect.width, this.minSizes),
|
||||
center: this.calculateAvailableWidth('center', influenceData, updatedPanels, containerRect.width, this.minSizes)
|
||||
};
|
||||
|
||||
// 1. 处理influence数组:当某个面板大小变化时,会影响哪些其他面板
|
||||
if (influenceData.influence && influenceData.influence[panelPosition]) {
|
||||
const influenceArray = influenceData.influence[panelPosition].value !== undefined
|
||||
? influenceData.influence[panelPosition].value
|
||||
: influenceData.influence[panelPosition];
|
||||
|
||||
if (influenceArray && Array.isArray(influenceArray)) {
|
||||
influenceArray.forEach(influence => {
|
||||
if (influence.influence && updatedPanels[influence.position] &&
|
||||
!updatedPositions.has(influence.position)) {
|
||||
|
||||
// 检查updatedPanels[influence.position]是否为数组或包含panels数组
|
||||
const panelArea = updatedPanels[influence.position] || {};
|
||||
const panels = panelArea.panels || [];
|
||||
|
||||
if (panels.length > 0) {
|
||||
// 根据影响的属性执行相应操作
|
||||
if (influence.property === 'width') {
|
||||
const availableWidth = availableWidths[influence.position] || containerRect.width;
|
||||
// 从面板对象内部获取比例数据
|
||||
const widthRatios = panelArea.widthRatios || null;
|
||||
|
||||
const result = this.calculatePanelsWidth(
|
||||
panels,
|
||||
availableWidth,
|
||||
this.minSizes,
|
||||
widthRatios
|
||||
);
|
||||
|
||||
// 更新原始数据结构,确保始终返回包含panels属性的对象
|
||||
if (!updatedPanels[influence.position]) {
|
||||
updatedPanels[influence.position] = {};
|
||||
}
|
||||
updatedPanels[influence.position].panels = result.panels;
|
||||
// 将比例数据存储在面板对象内部
|
||||
updatedPanels[influence.position].widthRatios = result.widthRatios;
|
||||
} else if (influence.property === 'height') {
|
||||
// 使用预先计算好的可用高度
|
||||
const availableHeight = availableHeights[influence.position] || this.calculateAvailableHeight(
|
||||
influence.position,
|
||||
influenceData,
|
||||
updatedPanels,
|
||||
containerRect.height,
|
||||
this.minSizes
|
||||
);
|
||||
// 从面板对象内部获取比例数据
|
||||
const heightRatios = panelArea.heightRatios || null;
|
||||
|
||||
const result = this.calculatePanelsHeight(
|
||||
panels,
|
||||
availableHeight,
|
||||
this.minSizes,
|
||||
heightRatios
|
||||
);
|
||||
|
||||
// 更新原始数据结构,确保始终返回包含panels属性的对象
|
||||
if (!updatedPanels[influence.position]) {
|
||||
updatedPanels[influence.position] = {};
|
||||
}
|
||||
updatedPanels[influence.position].panels = result.panels;
|
||||
// 将比例数据存储在面板对象内部
|
||||
updatedPanels[influence.position].heightRatios = result.heightRatios;
|
||||
}
|
||||
|
||||
// 标记该位置已更新
|
||||
updatedPositions.add(influence.position);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理受影响数组:在计算该面板大小时,需要考虑哪些其他面板
|
||||
if (influenceData.influencedBy && influenceData.influencedBy[panelPosition] &&
|
||||
updatedPanels[panelPosition] && !updatedPositions.has(panelPosition)) {
|
||||
|
||||
// 检查updatedPanels[panelPosition]是否为数组或包含panels数组
|
||||
const panelArea = updatedPanels[panelPosition] || {};
|
||||
const panels = panelArea.panels || [];
|
||||
|
||||
if (panels.length > 0) {
|
||||
const influencedByArray = influenceData.influencedBy[panelPosition].value !== undefined
|
||||
? influenceData.influencedBy[panelPosition].value
|
||||
: influenceData.influencedBy[panelPosition];
|
||||
|
||||
if (influencedByArray && Array.isArray(influencedByArray)) {
|
||||
// 使用预先计算的可用空间值,避免重复计算
|
||||
const availableWidth = availableWidths[panelPosition];
|
||||
const availableHeight = availableHeights[panelPosition];
|
||||
|
||||
// 更新当前面板的尺寸
|
||||
// 从面板对象内部获取比例数据
|
||||
const widthRatios = panelArea.widthRatios || null;
|
||||
const widthResult = this.calculatePanelsWidth(
|
||||
panels,
|
||||
availableWidth,
|
||||
this.minSizes,
|
||||
widthRatios
|
||||
);
|
||||
|
||||
// 从面板对象内部获取比例数据
|
||||
const heightRatios = panelArea.heightRatios || null;
|
||||
const heightResult = this.calculatePanelsHeight(
|
||||
widthResult.panels, // 使用宽度计算后的面板作为输入
|
||||
availableHeight,
|
||||
this.minSizes,
|
||||
heightRatios
|
||||
);
|
||||
|
||||
// 确保始终返回包含panels属性的对象结构
|
||||
if (!updatedPanels[panelPosition]) {
|
||||
updatedPanels[panelPosition] = {};
|
||||
}
|
||||
updatedPanels[panelPosition].panels = heightResult.panels;
|
||||
// 将比例数据存储在面板对象内部
|
||||
updatedPanels[panelPosition].widthRatios = widthResult.widthRatios;
|
||||
updatedPanels[panelPosition].heightRatios = heightResult.heightRatios;
|
||||
|
||||
// 标记该位置已更新
|
||||
updatedPositions.add(panelPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedPanels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指定位置的可用高度,考虑所有被影响面板的高度
|
||||
* @param {String} position - 面板位置
|
||||
* @param {Object} influenceData - 面板影响关系数据
|
||||
* @param {Object} updatedPanels - 更新后面板数据
|
||||
* @param {Number} containerHeight - 容器高度
|
||||
* @param {Object} minSizes - 最小尺寸限制
|
||||
* @returns {Number} - 可用高度
|
||||
*/
|
||||
calculateAvailableHeight(position, influenceData, updatedPanels, containerHeight, minSizes) {
|
||||
// 从容器高度开始
|
||||
let availableHeight = containerHeight;
|
||||
let panelHeight = updatedPanels[position].height || 0;
|
||||
// 如果有受影响关系数据,考虑被其他面板占用的空间
|
||||
if (influenceData.influencedBy && influenceData.influencedBy[position]) {
|
||||
const influencedByArray = influenceData.influencedBy[position].value !== undefined
|
||||
? influenceData.influencedBy[position].value
|
||||
: influenceData.influencedBy[position];
|
||||
|
||||
if (influencedByArray && Array.isArray(influencedByArray)) {
|
||||
influencedByArray.forEach(influencedBy => {
|
||||
if (influencedBy.influence && influencedBy.property === 'height') {
|
||||
// 检查是否是面板区对象,如果是则提取panels数组
|
||||
const panelArea = updatedPanels[influencedBy.position] || {};
|
||||
const occupiedHeight = panelArea.panels && panelArea.panels.length > 0 ? panelArea.height : 0;
|
||||
availableHeight -= occupiedHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 确保可用空间不小于最小值
|
||||
// 检查是否是面板区对象,如果是则提取panels数组
|
||||
const panelArea = updatedPanels[position] || {};
|
||||
const panels = panelArea.panels || [];
|
||||
const cnt = position == 'center' ? 1 : panels.length;
|
||||
panelHeight = Math.max(minSizes.panelHeight * cnt, panelHeight);
|
||||
const height = availableHeight > minSizes.panelHeight ? availableHeight : panelHeight;
|
||||
return cnt > 0 ? height : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指定位置的可用宽度,考虑所有被影响面板的宽度
|
||||
* @param {String} position - 面板位置
|
||||
* @param {Object} influenceData - 面板影响关系数据
|
||||
* @param {Object} updatedPanels - 更新后面板数据
|
||||
* @param {Number} containerWidth - 容器宽度
|
||||
* @param {Object} minSizes - 最小尺寸限制
|
||||
* @returns {Number} - 可用宽度
|
||||
*/
|
||||
calculateAvailableWidth(position, influenceData, updatedPanels, containerWidth, minSizes) {
|
||||
// 从容器宽度开始
|
||||
let availableWidth = containerWidth;
|
||||
let panelWidth = updatedPanels[position].width || 0;
|
||||
// 如果有受影响关系数据,考虑被其他面板占用的空间
|
||||
if (influenceData.influencedBy && influenceData.influencedBy[position]) {
|
||||
const influencedByArray = influenceData.influencedBy[position].value !== undefined
|
||||
? influenceData.influencedBy[position].value
|
||||
: influenceData.influencedBy[position];
|
||||
|
||||
if (influencedByArray && Array.isArray(influencedByArray)) {
|
||||
influencedByArray.forEach(influencedBy => {
|
||||
if (influencedBy.influence && influencedBy.property === 'width') {
|
||||
// 检查是否是面板区对象,如果是则提取panels数组
|
||||
const panelArea = updatedPanels[influencedBy.position] || {};
|
||||
const occupiedWidth = panelArea.panels && panelArea.panels.length > 0 ? panelArea.width : 0;
|
||||
availableWidth -= occupiedWidth;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 确保可用空间不小于最小值
|
||||
// 检查是否是面板区对象,如果是则提取panels数组
|
||||
const panelArea = updatedPanels[position] || {};
|
||||
const panels = panelArea.panels || [];
|
||||
const cnt = position == 'center' ? 1 : panels.length;
|
||||
panelWidth = Math.max(minSizes.panelWidth * cnt, panelWidth);
|
||||
const width = availableWidth > minSizes.panelWidth ? availableWidth : panelWidth;
|
||||
return panels.length > 0 ? width : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并更新面板的宽度,确保它们均匀分布并撑满整个区域
|
||||
* @param {Array} panels - 面板数组
|
||||
* @param {Number} availableWidth - 可用宽度
|
||||
* @param {Object} minSizes - 最小尺寸限制
|
||||
* @param {Array} widthRatios - 宽度比例数组(可选)
|
||||
* @returns {Object} - 包含更新后面板和宽度比例的对象
|
||||
*/
|
||||
calculatePanelsWidth(panels, availableWidth, minSizes, widthRatios = null) {
|
||||
if (panels.length === 0) return { panels, widthRatios: null };
|
||||
|
||||
const panelCount = panels.length;
|
||||
let updatedPanels = [...panels];
|
||||
|
||||
// 更新每个面板的宽度
|
||||
updatedPanels = panels.map((panel, index) => {
|
||||
let calculatedWidth;
|
||||
|
||||
// 如果只有一个面板,直接使用可用宽度
|
||||
if (panelCount === 1) {
|
||||
calculatedWidth = Math.max(minSizes.panelWidth, availableWidth);
|
||||
} else {
|
||||
// 如果已有保存的宽度比例,则根据比例重新计算宽度
|
||||
if (widthRatios && widthRatios.length === panelCount) {
|
||||
// 使用保存的比例计算宽度
|
||||
calculatedWidth = Math.max(minSizes.panelWidth,
|
||||
Math.floor(availableWidth * widthRatios[index])
|
||||
);
|
||||
} else {
|
||||
// 多个面板时均匀分配宽度
|
||||
const baseWidth = Math.floor(availableWidth / panelCount);
|
||||
const remainder = availableWidth % panelCount; // 用于处理整数除法的余数
|
||||
|
||||
calculatedWidth = Math.max(minSizes.panelWidth,
|
||||
// 将余数分配给前面的面板,使总宽度刚好等于availableWidth
|
||||
index < remainder ? baseWidth + 1 : baseWidth
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...panel,
|
||||
width: calculatedWidth
|
||||
};
|
||||
});
|
||||
|
||||
// 计算并保存当前的宽度比例
|
||||
const totalWidth = updatedPanels.reduce((sum, panel) => sum + (panel.width || 300), 0);
|
||||
const newWidthRatios = updatedPanels.map(panel =>
|
||||
totalWidth > 0 ? (panel.width || 300) / totalWidth : 1 / panelCount
|
||||
);
|
||||
|
||||
return { panels: updatedPanels, widthRatios: newWidthRatios };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并更新面板的高度,确保它们均匀分布并撑满整个区域
|
||||
* @param {Array} panels - 面板数组
|
||||
* @param {Number} availableHeight - 可用高度
|
||||
* @param {Object} minSizes - 最小尺寸限制
|
||||
* @param {Array} heightRatios - 高度比例数组(可选)
|
||||
* @returns {Object} - 包含更新后面板和高度比例的对象
|
||||
*/
|
||||
calculatePanelsHeight(panels, availableHeight, minSizes, heightRatios = null) {
|
||||
if (panels.length === 0) return { panels, heightRatios: null };
|
||||
|
||||
const panelCount = panels.length;
|
||||
let updatedPanels = [...panels];
|
||||
|
||||
// 更新每个面板的高度
|
||||
updatedPanels = panels.map((panel, index) => {
|
||||
let calculatedHeight;
|
||||
|
||||
// 如果只有一个面板,直接使用可用高度
|
||||
if (panelCount === 1) {
|
||||
calculatedHeight = Math.max(minSizes.panelHeight, availableHeight);
|
||||
} else {
|
||||
// 如果已有保存的高度比例,则根据比例重新计算高度
|
||||
if (heightRatios && heightRatios.length === panelCount) {
|
||||
// 使用保存的比例计算高度
|
||||
calculatedHeight = Math.max(minSizes.panelHeight,
|
||||
Math.floor(availableHeight * heightRatios[index])
|
||||
);
|
||||
} else {
|
||||
// 多个面板时均匀分配高度
|
||||
const baseHeight = Math.floor(availableHeight / panelCount);
|
||||
const remainder = availableHeight % panelCount; // 用于处理整数除法的余数
|
||||
|
||||
calculatedHeight = Math.max(minSizes.panelHeight,
|
||||
// 将余数分配给前面的面板,使总高度刚好等于availableHeight
|
||||
index < remainder ? baseHeight + 1 : baseHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...panel,
|
||||
height: calculatedHeight
|
||||
};
|
||||
});
|
||||
|
||||
// 计算并保存当前的高度比例
|
||||
const totalHeight = updatedPanels.reduce((sum, panel) => sum + (panel.height || 200), 0);
|
||||
const newHeightRatios = updatedPanels.map(panel =>
|
||||
totalHeight > 0 ? (panel.height || 200) / totalHeight : 1 / panelCount
|
||||
);
|
||||
|
||||
return { panels: updatedPanels, heightRatios: newHeightRatios };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置面板尺寸比例,确保均匀分布
|
||||
* @param {Number} panelCount - 面板数量
|
||||
* @returns {Array} - 重置后的比例数组
|
||||
*/
|
||||
resetPanelsSizeRatios(panelCount) {
|
||||
if (panelCount > 0) {
|
||||
// 为每个面板分配相同的比例
|
||||
return Array(panelCount).fill(1 / panelCount);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新面板尺寸,确保它们均匀分布并撑满整个区域
|
||||
* @param {String} position - 面板位置 ('top', 'bottom', 'left', 'right')
|
||||
* @param {Array} panels - 面板数组
|
||||
* @param {Array} ratios - 尺寸比例数组
|
||||
* @param {HTMLElement} container - 容器元素
|
||||
* @param {Object} minSizes - 最小尺寸限制
|
||||
* @param {Object} panelHeights - 包含顶部和底部面板高度的对象
|
||||
* @returns {Object} - 包含更新后面板和比例的对象
|
||||
*/
|
||||
updatePanelsSize(position, panels, ratios, container, minSizes, panelHeights = {}) {
|
||||
if (!container || panels.length === 0) {
|
||||
return { panels, ratios };
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
case 'bottom': {
|
||||
const availableWidth = containerRect.width;
|
||||
|
||||
// 使用布局协调器计算面板宽度
|
||||
const result = this.calculatePanelsWidth(
|
||||
panels,
|
||||
availableWidth,
|
||||
minSizes,
|
||||
ratios
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'left':
|
||||
case 'right': {
|
||||
// 为垂直排列的面板计算高度
|
||||
const totalHeight = containerRect.height -
|
||||
(panelHeights.top || 0) -
|
||||
(panelHeights.bottom || 0);
|
||||
|
||||
if (totalHeight <= 0) {
|
||||
return { panels, ratios };
|
||||
}
|
||||
|
||||
// 使用布局协调器计算面板高度
|
||||
const result = this.calculatePanelsHeight(
|
||||
panels,
|
||||
totalHeight,
|
||||
minSizes,
|
||||
ratios
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
default:
|
||||
return { panels, ratios };
|
||||
}
|
||||
}
|
||||
}
|
||||
250
AutoRobot/Windows/Robot/Web/src/components/LayoutManager.js
Normal file
250
AutoRobot/Windows/Robot/Web/src/components/LayoutManager.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 布局管理器 - 统一管理面板布局的持久化、调整大小等功能
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { LayoutPersistence } from './LayoutPersistence';
|
||||
import { ResizeHandlers } from './ResizeHandlers';
|
||||
|
||||
/**
|
||||
* 布局管理器类
|
||||
* 负责统一管理面板布局的持久化、调整大小等功能
|
||||
*/
|
||||
export class LayoutManager {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} store - Pinia store实例
|
||||
*/
|
||||
constructor(store) {
|
||||
this.store = store;
|
||||
this.container = ref(null);
|
||||
this.layoutPersistence = null;
|
||||
this.resizeHandlers = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化布局管理器
|
||||
*/
|
||||
initialize() {
|
||||
// 创建布局持久化实例
|
||||
this.layoutPersistence = new LayoutPersistence(
|
||||
{
|
||||
leftPanelArea: this.store.leftPanelArea,
|
||||
rightPanelArea: this.store.rightPanelArea,
|
||||
topPanelArea: this.store.topPanelArea,
|
||||
bottomPanelArea: this.store.bottomPanelArea,
|
||||
centerPanelArea: this.store.centerPanelArea,
|
||||
floatingWindows: this.store.floatingWindows,
|
||||
},
|
||||
{
|
||||
activeCenterTab: this.store.activeCenterTab,
|
||||
minimizedWindows: this.store.minimizedWindows
|
||||
}
|
||||
);
|
||||
|
||||
// 创建调整大小处理器
|
||||
this.resizeHandlers = new ResizeHandlers(
|
||||
this.store,
|
||||
this.container,
|
||||
this.layoutPersistence
|
||||
);
|
||||
|
||||
// 初始化事件监听
|
||||
this.resizeHandlers.initialize();
|
||||
|
||||
// 初始化容器引用
|
||||
this.container.value = document.querySelector('.dock-panel-container');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载保存的布局
|
||||
* @returns {boolean} 是否成功加载布局
|
||||
*/
|
||||
loadLayout() {
|
||||
if (!this.layoutPersistence) {
|
||||
console.error('Layout persistence is not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.layoutPersistence.loadLayout();
|
||||
} catch (error) {
|
||||
console.error('Failed to load layout:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前布局
|
||||
*/
|
||||
saveLayout() {
|
||||
if (!this.layoutPersistence) {
|
||||
console.error('Layout persistence is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.layoutPersistence.saveLayout();
|
||||
} catch (error) {
|
||||
console.error('Failed to save layout:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出布局为JSON文件
|
||||
*/
|
||||
exportLayout() {
|
||||
if (!this.layoutPersistence) {
|
||||
console.error('Layout persistence is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.layoutPersistence.exportLayout();
|
||||
} catch (error) {
|
||||
console.error('Failed to export layout:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入布局
|
||||
* @param {string|Object} content - JSON字符串或解析后的对象
|
||||
* @returns {boolean} 是否成功导入布局
|
||||
*/
|
||||
importLayout(content) {
|
||||
if (!this.layoutPersistence) {
|
||||
console.error('Layout persistence is not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.layoutPersistence.importLayout(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to import layout:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除保存的布局
|
||||
*/
|
||||
clearSavedLayout() {
|
||||
if (!this.layoutPersistence) {
|
||||
console.error('Layout persistence is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.layoutPersistence.clearSavedLayout();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear saved layout:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有保存的布局
|
||||
* @returns {boolean} 是否有保存的布局
|
||||
*/
|
||||
hasSavedLayout() {
|
||||
if (!this.layoutPersistence) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.layoutPersistence.hasSavedLayout();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始调整大小
|
||||
*/
|
||||
startResize(target, event) {
|
||||
if (!this.resizeHandlers) {
|
||||
console.error('Resize handlers is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeHandlers.startResize(target, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始面板调整大小
|
||||
*/
|
||||
startPanelResize(position, panelId, panelIndex, event) {
|
||||
if (!this.resizeHandlers) {
|
||||
console.error('Resize handlers is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeHandlers.startPanelResize(position, panelId, panelIndex, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标移动
|
||||
*/
|
||||
handleMouseMove(event, minSizes, leftPanelArea, rightPanelArea, topPanelArea, bottomPanelArea, centerPanelArea) {
|
||||
if (!this.resizeHandlers) {
|
||||
console.error('Resize handlers is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeHandlers.handleMouseMove(event, minSizes, leftPanelArea, rightPanelArea, topPanelArea, bottomPanelArea, centerPanelArea);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标松开
|
||||
*/
|
||||
handleMouseUp(event, hideContextMenuCallback, checkDockZoneCallback = null) {
|
||||
if (!this.resizeHandlers) {
|
||||
console.error('Resize handlers is not initialized');
|
||||
if (hideContextMenuCallback) {
|
||||
hideContextMenuCallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeHandlers.handleMouseUp(event, hideContextMenuCallback, checkDockZoneCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.resizeHandlers) {
|
||||
this.resizeHandlers.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置容器引用
|
||||
* @param {HTMLElement} container - 容器元素
|
||||
*/
|
||||
setContainer(container) {
|
||||
this.container.value = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取容器引用
|
||||
* @returns {HTMLElement|null} 容器元素
|
||||
*/
|
||||
getContainer() {
|
||||
return this.container.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建LayoutManager的Vue组合式函数
|
||||
* @param {Object} store - Pinia store实例
|
||||
* @returns {Object} LayoutManager实例及相关方法
|
||||
*/
|
||||
export function useLayoutManager(store) {
|
||||
const layoutManager = new LayoutManager(store);
|
||||
|
||||
onMounted(() => {
|
||||
layoutManager.initialize();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
layoutManager.cleanup();
|
||||
});
|
||||
|
||||
return layoutManager;
|
||||
}
|
||||
309
AutoRobot/Windows/Robot/Web/src/components/LayoutPersistence.js
Normal file
309
AutoRobot/Windows/Robot/Web/src/components/LayoutPersistence.js
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 布局持久化工具类 - 负责布局的保存、加载、导入和导出功能
|
||||
*/
|
||||
export class LayoutPersistence {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} panelCollections - 面板集合对象
|
||||
* @param {Object} panelDimensions - 面板尺寸对象
|
||||
* @param {Object} layoutState - 布局状态对象
|
||||
* @param {Object} panelRatios - 面板比例对象
|
||||
* @param {string} storageKey - localStorage存储键名
|
||||
*/
|
||||
constructor(panelCollections, layoutState, storageKey = 'dockPanelLayout') {
|
||||
this.panelCollections = panelCollections;
|
||||
this.layoutState = layoutState;
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存布局到localStorage
|
||||
*/
|
||||
saveLayout() {
|
||||
try {
|
||||
const layout = this._getCurrentLayout();
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(layout));
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从localStorage加载布局
|
||||
*/
|
||||
loadLayout() {
|
||||
try {
|
||||
const savedLayout = localStorage.getItem(this.storageKey);
|
||||
if (!savedLayout) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const layout = JSON.parse(savedLayout);
|
||||
this._applyLayout(layout);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出布局为JSON文件
|
||||
*/
|
||||
exportLayout() {
|
||||
try {
|
||||
const layout = this._getCurrentLayout();
|
||||
const dataStr = JSON.stringify(layout, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `dock-layout-${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入布局
|
||||
* @param {string|Object} content - JSON字符串或解析后的对象
|
||||
*/
|
||||
importLayout(content) {
|
||||
try {
|
||||
let layout;
|
||||
if (typeof content === 'string') {
|
||||
layout = JSON.parse(content);
|
||||
} else {
|
||||
layout = content;
|
||||
}
|
||||
|
||||
this._applyLayout(layout);
|
||||
this.saveLayout();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前布局(内部方法)
|
||||
* @private
|
||||
*/
|
||||
_clearLayout() {
|
||||
// 清除各面板区域数据
|
||||
if (this.panelCollections.leftPanelArea) {
|
||||
this.panelCollections.leftPanelArea.panels = [];
|
||||
}
|
||||
if (this.panelCollections.rightPanelArea) {
|
||||
this.panelCollections.rightPanelArea.panels = [];
|
||||
}
|
||||
if (this.panelCollections.topPanelArea) {
|
||||
this.panelCollections.topPanelArea.panels = [];
|
||||
}
|
||||
if (this.panelCollections.bottomPanelArea) {
|
||||
this.panelCollections.bottomPanelArea.panels = [];
|
||||
}
|
||||
if (this.panelCollections.centerPanelArea) {
|
||||
this.panelCollections.centerPanelArea.panels = [];
|
||||
}
|
||||
if (this.panelCollections.floatingWindows) {
|
||||
this.panelCollections.floatingWindows = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前布局数据
|
||||
* @private
|
||||
* @returns {Object} 布局数据对象
|
||||
*/
|
||||
_getCurrentLayout() {
|
||||
// 获取ref对象的实际值
|
||||
// 系统中面板区域始终是ref响应式对象,直接通过.value访问
|
||||
const leftPanelValue = this.panelCollections.leftPanelArea?.value || {};
|
||||
const rightPanelValue = this.panelCollections.rightPanelArea?.value || {};
|
||||
const topPanelValue = this.panelCollections.topPanelArea?.value || {};
|
||||
const bottomPanelValue = this.panelCollections.bottomPanelArea?.value || {};
|
||||
const centerPanelValue = this.panelCollections.centerPanelArea?.value || {};
|
||||
|
||||
// 打印当前读取的面板宽度值
|
||||
console.log('LayoutPersistence - 读取左侧面板宽度:', leftPanelValue.width || 0);
|
||||
console.log('LayoutPersistence - 读取右侧面板宽度:', rightPanelValue.width || 0);
|
||||
|
||||
const layoutData = {
|
||||
// 面板区数据
|
||||
leftPanelArea: {
|
||||
panels: leftPanelValue.panels || [],
|
||||
width: leftPanelValue.width || 0,
|
||||
heightRatios: leftPanelValue.heightRatios || []
|
||||
},
|
||||
rightPanelArea: {
|
||||
panels: rightPanelValue.panels || [],
|
||||
width: rightPanelValue.width || 0,
|
||||
heightRatios: rightPanelValue.heightRatios || []
|
||||
},
|
||||
topPanelArea: {
|
||||
panels: topPanelValue.panels || [],
|
||||
height: topPanelValue.height || 0,
|
||||
widthRatios: topPanelValue.widthRatios || []
|
||||
},
|
||||
bottomPanelArea: {
|
||||
panels: bottomPanelValue.panels || [],
|
||||
height: bottomPanelValue.height || 0,
|
||||
widthRatios: bottomPanelValue.widthRatios || []
|
||||
},
|
||||
// 中心面板区数据
|
||||
centerPanelArea: {
|
||||
panels: centerPanelValue.panels || [],
|
||||
width: centerPanelValue.width || 0,
|
||||
height: centerPanelValue.height || 0,
|
||||
widthRatios: centerPanelValue.widthRatios || [],
|
||||
heightRatios: centerPanelValue.heightRatios || []
|
||||
},
|
||||
|
||||
// 浮动窗口数据
|
||||
floatingWindows: this.panelCollections.floatingWindows?.value || this.panelCollections.floatingWindows || [],
|
||||
|
||||
activeCenterTab: this.layoutState.activeCenterTab.value,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
return layoutData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用布局数据
|
||||
* @private
|
||||
* @param {Object} layout - 布局数据对象
|
||||
*/
|
||||
_applyLayout(layout) {
|
||||
// 优先应用面板区数据(新结构)
|
||||
// 系统中面板区域始终是ref响应式对象
|
||||
if (layout.leftPanelArea && this.panelCollections.leftPanelArea) {
|
||||
const panelArea = this.panelCollections.leftPanelArea.value || {};
|
||||
const newPanelArea = { ...panelArea };
|
||||
|
||||
if (Array.isArray(layout.leftPanelArea.panels)) {
|
||||
newPanelArea.panels = layout.leftPanelArea.panels;
|
||||
}
|
||||
if (layout.leftPanelArea.width !== undefined) {
|
||||
newPanelArea.width = layout.leftPanelArea.width;
|
||||
}
|
||||
if (Array.isArray(layout.leftPanelArea.heightRatios)) {
|
||||
newPanelArea.heightRatios = layout.leftPanelArea.heightRatios;
|
||||
}
|
||||
|
||||
// 更新整个对象以触发响应式更新
|
||||
this.panelCollections.leftPanelArea.value = newPanelArea;
|
||||
}
|
||||
|
||||
if (layout.rightPanelArea && this.panelCollections.rightPanelArea) {
|
||||
const panelArea = this.panelCollections.rightPanelArea.value || {};
|
||||
const newPanelArea = { ...panelArea };
|
||||
|
||||
if (Array.isArray(layout.rightPanelArea.panels)) {
|
||||
newPanelArea.panels = layout.rightPanelArea.panels;
|
||||
}
|
||||
if (layout.rightPanelArea.width !== undefined) {
|
||||
newPanelArea.width = layout.rightPanelArea.width;
|
||||
}
|
||||
if (Array.isArray(layout.rightPanelArea.heightRatios)) {
|
||||
newPanelArea.heightRatios = layout.rightPanelArea.heightRatios;
|
||||
}
|
||||
|
||||
// 更新整个对象以触发响应式更新
|
||||
this.panelCollections.rightPanelArea.value = newPanelArea;
|
||||
}
|
||||
|
||||
if (layout.topPanelArea && this.panelCollections.topPanelArea) {
|
||||
const panelArea = this.panelCollections.topPanelArea.value || {};
|
||||
const newPanelArea = { ...panelArea };
|
||||
|
||||
if (Array.isArray(layout.topPanelArea.panels)) {
|
||||
newPanelArea.panels = layout.topPanelArea.panels;
|
||||
}
|
||||
if (layout.topPanelArea.height !== undefined) {
|
||||
newPanelArea.height = layout.topPanelArea.height;
|
||||
}
|
||||
if (Array.isArray(layout.topPanelArea.widthRatios)) {
|
||||
newPanelArea.widthRatios = layout.topPanelArea.widthRatios;
|
||||
}
|
||||
|
||||
// 更新整个对象以触发响应式更新
|
||||
this.panelCollections.topPanelArea.value = newPanelArea;
|
||||
}
|
||||
|
||||
if (layout.bottomPanelArea && this.panelCollections.bottomPanelArea) {
|
||||
const panelArea = this.panelCollections.bottomPanelArea.value || {};
|
||||
const newPanelArea = { ...panelArea };
|
||||
|
||||
if (Array.isArray(layout.bottomPanelArea.panels)) {
|
||||
newPanelArea.panels = layout.bottomPanelArea.panels;
|
||||
}
|
||||
if (layout.bottomPanelArea.height !== undefined) {
|
||||
newPanelArea.height = layout.bottomPanelArea.height;
|
||||
}
|
||||
if (Array.isArray(layout.bottomPanelArea.widthRatios)) {
|
||||
newPanelArea.widthRatios = layout.bottomPanelArea.widthRatios;
|
||||
}
|
||||
|
||||
// 更新整个对象以触发响应式更新
|
||||
this.panelCollections.bottomPanelArea.value = newPanelArea;
|
||||
}
|
||||
|
||||
// 应用中心面板区数据
|
||||
if (layout.centerPanelArea && this.panelCollections.centerPanelArea) {
|
||||
const panelArea = this.panelCollections.centerPanelArea.value || {};
|
||||
const newPanelArea = { ...panelArea };
|
||||
|
||||
if (Array.isArray(layout.centerPanelArea.panels)) {
|
||||
newPanelArea.panels = layout.centerPanelArea.panels;
|
||||
}
|
||||
if (typeof layout.centerPanelArea.width === 'number') {
|
||||
newPanelArea.width = layout.centerPanelArea.width;
|
||||
}
|
||||
if (typeof layout.centerPanelArea.height === 'number') {
|
||||
newPanelArea.height = layout.centerPanelArea.height;
|
||||
}
|
||||
if (Array.isArray(layout.centerPanelArea.widthRatios)) {
|
||||
newPanelArea.widthRatios = layout.centerPanelArea.widthRatios;
|
||||
}
|
||||
if (Array.isArray(layout.centerPanelArea.heightRatios)) {
|
||||
newPanelArea.heightRatios = layout.centerPanelArea.heightRatios;
|
||||
}
|
||||
|
||||
// 更新整个对象以触发响应式更新
|
||||
this.panelCollections.centerPanelArea.value = newPanelArea;
|
||||
}
|
||||
|
||||
if (Array.isArray(layout.floatingWindows) && this.panelCollections.floatingWindows) {
|
||||
// 直接赋值新的浮动窗口数组
|
||||
this.panelCollections.floatingWindows.value = layout.floatingWindows;
|
||||
}
|
||||
|
||||
// 应用激活标签
|
||||
if (layout.activeCenterTab !== undefined) {
|
||||
this.layoutState.activeCenterTab = layout.activeCenterTab;
|
||||
}
|
||||
|
||||
// 面板比例已通过面板区域对象直接应用,无需额外处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前布局
|
||||
*/
|
||||
clearSavedLayout() {
|
||||
try {
|
||||
localStorage.removeItem(this.storageKey);
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有保存的布局
|
||||
* @returns {boolean} 是否有保存的布局
|
||||
*/
|
||||
hasSavedLayout() {
|
||||
return localStorage.getItem(this.storageKey) !== null;
|
||||
}
|
||||
}
|
||||
92
AutoRobot/Windows/Robot/Web/src/components/MessageArea.vue
Normal file
92
AutoRobot/Windows/Robot/Web/src/components/MessageArea.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<!-- 消息显示区域 - 优化的卡片设计 -->
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="bg-white/90 backdrop-blur-sm rounded-2xl shadow-xl flex flex-col border border-gray-100 h-full">
|
||||
<!-- 消息头部 -->
|
||||
<div class="p-4 border-b border-gray-100 bg-gray-50/80 backdrop-blur-sm">
|
||||
<h2 class="font-semibold text-lg flex items-center">
|
||||
<i class="fa-solid fa-comments text-primary mr-2"></i>
|
||||
<span>消息记录</span>
|
||||
<span class="ml-2 text-xs text-gray-500 px-2 py-0.5 rounded-full bg-gray-200">
|
||||
{{ messages.length }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表区域 - 增强的滚动体验 -->
|
||||
<div ref="messageContainer" class="flex-grow h-full p-4 min-h-0 overflow-y-auto">
|
||||
<!-- 自定义滚动条 -->
|
||||
<div class="scrollbar-thin scrollbar-thumb-primary/30 scrollbar-track-gray-100">
|
||||
<!-- 消息列表 -->
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="flex items-start animate-fadeIn"
|
||||
:class="{ 'justify-end': message.sender === 'me', 'delay-300': message.isSystem }"
|
||||
>
|
||||
<!-- 系统消息 -->
|
||||
<template v-if="message.isSystem">
|
||||
<div class="w-full flex justify-start my-4">
|
||||
<div class="bg-primary/5 px-4 py-2 rounded-full inline-flex items-center max-w-md border border-primary/10 transition-all duration-300 hover:bg-primary/10">
|
||||
<i class="fa-solid fa-info-circle text-primary mr-2"></i>
|
||||
<span class="text-sm text-gray-600">{{ message.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 普通消息 -->
|
||||
<template v-else>
|
||||
<!-- 对方头像 -->
|
||||
<template v-if="message.sender !== 'me'">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mr-3 flex-shrink-0 border border-gray-200 shadow-sm transition-all duration-300 hover:bg-gray-200">
|
||||
<i class="fa-solid fa-user text-primary"></i>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<div class="flex flex-col" :class="{ 'items-end': message.sender === 'me' }">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-xs font-medium text-gray-500">{{ message.sender === 'me' ? '我' : message.sender }}</span>
|
||||
<span class="text-xs text-gray-400">{{ message.time }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="message.sender === 'me' ? 'bg-[#165DFF] text-white shadow-lg shadow-[#165DFF]/20 !important' : 'bg-gray-100 shadow-sm border border-gray-200'"
|
||||
class="px-4 py-3 rounded-2xl max-w-md word-wrap break-word transition-all duration-300 transform hover:scale-[1.02] hover:shadow-xl"
|
||||
style="background-color: #165DFF !important; color: white !important;"
|
||||
>
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Refs
|
||||
const messageContainer = ref(null);
|
||||
|
||||
// 监听消息变化,自动滚动到底部
|
||||
watch(() => props.messages, () => {
|
||||
nextTick(() => {
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, { deep: true });
|
||||
</script>
|
||||
144
AutoRobot/Windows/Robot/Web/src/components/PanelArea.vue
Normal file
144
AutoRobot/Windows/Robot/Web/src/components/PanelArea.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="panels.length > 0"
|
||||
:class="containerClasses"
|
||||
:style="containerStyle"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<template v-for="(panel, index) in panels" :key="panel.id">
|
||||
<DockPanel
|
||||
:panel="panel"
|
||||
:position="position"
|
||||
:index="index"
|
||||
@close="closePanel(panel.id)"
|
||||
@float="floatPanel(panel.id)"
|
||||
@dock="dockPanel"
|
||||
@toggleCollapse="toggleCollapse(panel.id)"
|
||||
@titleBarDragStart="handlePanelTitleBarDragStart(panel.id, $event)"
|
||||
/>
|
||||
<!-- 面板间分隔条 - 不显示在最后一个面板旁 -->
|
||||
<div
|
||||
v-if="index < panels.length - 1"
|
||||
:class="separatorClasses"
|
||||
@mousedown="startPanelResize(panel.id, index, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import DockPanel from './DockPanel.js';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 面板位置(left, right, top, bottom)
|
||||
position: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['left', 'right', 'top', 'bottom'].includes(value)
|
||||
},
|
||||
// 面板列表
|
||||
panels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 区域尺寸(宽度或高度)
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
// 面板尺寸比例
|
||||
sizeRatios: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// store实例
|
||||
store: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits([
|
||||
'closePanel',
|
||||
'floatPanel',
|
||||
'dockPanel',
|
||||
'toggleCollapse',
|
||||
'titleBarDragStart',
|
||||
'panelResizeStart'
|
||||
]);
|
||||
|
||||
// 根据位置计算容器类名
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses = 'overflow-hidden bg-white';
|
||||
|
||||
const positionClasses = {
|
||||
left: 'flex flex-col border-r border-gray-300 panel-area-left',
|
||||
right: 'flex flex-col border-l border-gray-300 panel-area-right',
|
||||
top: 'flex border-b border-gray-300 panel-area-top',
|
||||
bottom: 'flex border-t border-gray-300 panel-area-bottom'
|
||||
};
|
||||
|
||||
return `${baseClasses} ${positionClasses[props.position]}`;
|
||||
});
|
||||
|
||||
// 根据位置计算容器样式
|
||||
const containerStyle = computed(() => {
|
||||
const isVertical = ['left', 'right'].includes(props.position);
|
||||
const styleProperty = isVertical ? 'width' : 'height';
|
||||
|
||||
return {
|
||||
[styleProperty]: `${props.size}px`
|
||||
};
|
||||
});
|
||||
|
||||
// 根据位置计算分隔条类名
|
||||
const separatorClasses = computed(() => {
|
||||
const isVertical = ['left', 'right'].includes(props.position);
|
||||
|
||||
if (isVertical) {
|
||||
// 左右面板中的子面板是垂直排列的,应该使用水平分隔条
|
||||
return 'h-1 cursor-row-resize z-30 dock-panel-separator dock-panel-separator-horizontal';
|
||||
} else {
|
||||
// 顶部底部面板中的子面板是水平排列的,应该使用垂直分隔条
|
||||
return 'w-1 cursor-col-resize z-30 dock-panel-separator dock-panel-separator-vertical';
|
||||
}
|
||||
});
|
||||
|
||||
// 处理关闭面板
|
||||
function closePanel(panelId) {
|
||||
emit('closePanel', panelId);
|
||||
}
|
||||
|
||||
// 处理浮动面板
|
||||
function floatPanel(panelId) {
|
||||
emit('floatPanel', panelId);
|
||||
}
|
||||
|
||||
// 处理停靠面板
|
||||
function dockPanel(panelId, position) {
|
||||
emit('dockPanel', panelId, position);
|
||||
}
|
||||
|
||||
// 处理折叠面板
|
||||
function toggleCollapse(panelId) {
|
||||
emit('toggleCollapse', panelId);
|
||||
}
|
||||
|
||||
// 处理面板标题栏拖拽开始
|
||||
function handlePanelTitleBarDragStart(panelId, event) {
|
||||
emit('titleBarDragStart', panelId, event);
|
||||
}
|
||||
|
||||
// 处理面板调整大小开始
|
||||
function startPanelResize(panelId, panelIndex, event) {
|
||||
emit('panelResizeStart', props.position, panelId, panelIndex, event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* PanelArea特有的作用域样式 */
|
||||
/* 共享样式已在styles/panel-shared.css中定义 */
|
||||
</style>
|
||||
172
AutoRobot/Windows/Robot/Web/src/components/PanelResizer.vue
Normal file
172
AutoRobot/Windows/Robot/Web/src/components/PanelResizer.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShow"
|
||||
:class="resizerClass"
|
||||
:style="resizerStyle"
|
||||
@mousedown="handleMouseDown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 分割条位置:left, right, top, bottom
|
||||
position: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['left', 'right', 'top', 'bottom'].includes(value)
|
||||
},
|
||||
// 是否显示分割条
|
||||
shouldShow: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 左侧面板宽度(仅position为left时使用)
|
||||
leftPanelWidth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 右侧面板宽度(仅position为right时使用)
|
||||
rightPanelWidth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 顶部面板高度(仅position为top时使用)
|
||||
topPanelHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 底部面板高度(仅position为bottom时使用)
|
||||
bottomPanelHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 顶部面板是否存在
|
||||
hasTopPanels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 底部面板是否存在
|
||||
hasBottomPanels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 左侧面板是否存在
|
||||
hasLeftPanels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 右侧面板是否存在
|
||||
hasRightPanels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['resizeStart']);
|
||||
|
||||
// 计算分割条的样式类
|
||||
const resizerClass = computed(() => {
|
||||
const baseClasses = 'absolute z-30 bg-gray-200 hover:bg-gray-400 transition-colors';
|
||||
|
||||
switch (props.position) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
return `${baseClasses} w-2 cursor-col-resize`;
|
||||
case 'top':
|
||||
case 'bottom':
|
||||
return `${baseClasses} h-2 cursor-row-resize`;
|
||||
default:
|
||||
return baseClasses;
|
||||
}
|
||||
});
|
||||
|
||||
// 计算分割条的样式
|
||||
const resizerStyle = computed(() => {
|
||||
switch (props.position) {
|
||||
case 'left':
|
||||
// 左侧分割条样式计算
|
||||
const topForLeft = props.hasTopPanels ? `${props.topPanelHeight}px` : 0;
|
||||
let heightForLeft = '100%';
|
||||
|
||||
if (props.hasTopPanels && props.hasBottomPanels) {
|
||||
heightForLeft = `calc(100% - ${props.topPanelHeight}px - ${props.bottomPanelHeight}px)`;
|
||||
} else if (props.hasTopPanels) {
|
||||
heightForLeft = `calc(100% - ${props.topPanelHeight}px)`;
|
||||
} else if (props.hasBottomPanels) {
|
||||
heightForLeft = `calc(100% - ${props.bottomPanelHeight}px)`;
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${props.leftPanelWidth}px`,
|
||||
top: topForLeft,
|
||||
height: heightForLeft
|
||||
};
|
||||
|
||||
case 'right':
|
||||
// 右侧分割条样式计算
|
||||
const topForRight = props.hasTopPanels ? `${props.topPanelHeight}px` : 0;
|
||||
let heightForRight = '100%';
|
||||
|
||||
if (props.hasTopPanels && props.hasBottomPanels) {
|
||||
heightForRight = `calc(100% - ${props.topPanelHeight}px - ${props.bottomPanelHeight}px)`;
|
||||
} else if (props.hasTopPanels) {
|
||||
heightForRight = `calc(100% - ${props.topPanelHeight}px)`;
|
||||
} else if (props.hasBottomPanels) {
|
||||
heightForRight = `calc(100% - ${props.bottomPanelHeight}px)`;
|
||||
}
|
||||
|
||||
return {
|
||||
right: `${props.rightPanelWidth}px`,
|
||||
top: topForRight,
|
||||
height: heightForRight
|
||||
};
|
||||
|
||||
case 'top':
|
||||
// 顶部分割条样式计算
|
||||
return {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: `${props.topPanelHeight}px`
|
||||
};
|
||||
|
||||
case 'bottom':
|
||||
// 底部分割条样式计算
|
||||
return {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: `${props.bottomPanelHeight}px`
|
||||
};
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// 处理鼠标按下事件
|
||||
function handleMouseDown(event) {
|
||||
// 阻止默认行为
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// 触发resizeStart事件,将位置和事件对象传递给父组件
|
||||
emit('resizeStart', props.position, event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 分割条悬停效果增强 */
|
||||
.cursor-col-resize:hover,
|
||||
.cursor-row-resize:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 调整大小时的视觉反馈 */
|
||||
.cursor-col-resize:active,
|
||||
.cursor-row-resize:active {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
96
AutoRobot/Windows/Robot/Web/src/components/QuickActions.vue
Normal file
96
AutoRobot/Windows/Robot/Web/src/components/QuickActions.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<!-- 快捷操作卡片 -->
|
||||
<div class="bg-white/90 backdrop-blur-sm rounded-2xl shadow-xl p-5 transform transition-all duration-300 hover:shadow-2xl border border-gray-100 h-full flex flex-col">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center mr-3">
|
||||
<i class="fa-solid fa-magic text-purple-500"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold">快捷操作</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 flex-grow">
|
||||
<button
|
||||
@click="clearMessages"
|
||||
class="p-4 rounded-lg font-medium text-base flex flex-col items-center justify-center space-y-2 transition-all duration-300 bg-gray-100 hover:bg-gray-200 text-gray-700"
|
||||
>
|
||||
<i class="fa-solid fa-trash-can text-gray-500 text-lg"></i>
|
||||
<span>清空消息</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="showConnectionHelp"
|
||||
class="p-4 rounded-lg font-medium text-base flex flex-col items-center justify-center space-y-2 transition-all duration-300 bg-gray-100 hover:bg-gray-200 text-gray-700"
|
||||
>
|
||||
<i class="fa-solid fa-question-circle text-gray-500 text-lg"></i>
|
||||
<span>帮助</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="copyDebugInfo"
|
||||
class="p-4 rounded-lg font-medium text-base flex flex-col items-center justify-center space-y-2 transition-all duration-300 bg-gray-100 hover:bg-gray-200 text-gray-700"
|
||||
>
|
||||
<i class="fa-solid fa-copy text-gray-500 text-lg"></i>
|
||||
<span>复制调试信息</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleDarkMode"
|
||||
class="p-4 rounded-lg font-medium text-base flex flex-col items-center justify-center space-y-2 transition-all duration-300 bg-gray-100 hover:bg-gray-200 text-gray-700"
|
||||
>
|
||||
<i class="fa-solid fa-moon text-gray-500 text-lg"></i>
|
||||
<span>切换主题</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="takePhoneScreenshot"
|
||||
:disabled="!isConnected"
|
||||
class="p-4 rounded-lg font-medium text-base flex flex-col items-center justify-center space-y-2 transition-all duration-300 bg-gray-100 hover:bg-gray-200 text-gray-700"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !isConnected }"
|
||||
>
|
||||
<i class="fa-solid fa-mobile-screen text-gray-500 text-lg"></i>
|
||||
<span>手机截屏</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Props
|
||||
const props = defineProps({
|
||||
isConnected: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['clear-messages', 'show-help', 'take-phone-screenshot']);
|
||||
|
||||
// Methods
|
||||
const clearMessages = () => {
|
||||
emit('clear-messages');
|
||||
};
|
||||
|
||||
const showConnectionHelp = () => {
|
||||
emit('show-help');
|
||||
};
|
||||
|
||||
const copyDebugInfo = () => {
|
||||
const debugInfo = `浏览器: ${navigator.userAgent}\n时间: ${new Date().toLocaleString()}`;
|
||||
navigator.clipboard.writeText(debugInfo).then(() => {
|
||||
alert('调试信息已复制到剪贴板');
|
||||
}).catch(err => {
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
};
|
||||
|
||||
const takePhoneScreenshot = () => {
|
||||
if (props.isConnected) {
|
||||
emit('take-phone-screenshot');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
396
AutoRobot/Windows/Robot/Web/src/components/ResizeHandlers.js
Normal file
396
AutoRobot/Windows/Robot/Web/src/components/ResizeHandlers.js
Normal file
@@ -0,0 +1,396 @@
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
/**
|
||||
* 面板调整大小处理模块
|
||||
* 负责处理面板调整大小的所有相关逻辑
|
||||
*/
|
||||
export class ResizeHandlers {
|
||||
constructor(store, container, layoutPersistence) {
|
||||
this.store = store;
|
||||
this.container = container;
|
||||
this.layoutPersistence = layoutPersistence;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始调整大小
|
||||
*/
|
||||
startResize(target, event) {
|
||||
this.store.isResizing = true;
|
||||
this.store.resizeTarget = target;
|
||||
this.store.resizeStartPos = { x: event.clientX, y: event.clientY };
|
||||
|
||||
// 阻止默认行为
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理面板调整大小开始
|
||||
*/
|
||||
startPanelResize(position, panelId, panelIndex, event) {
|
||||
this.store.isResizing = true;
|
||||
this.store.resizeTarget = position;
|
||||
this.store.resizeStartPos = { x: event.clientX, y: event.clientY };
|
||||
|
||||
// 保存调整大小的上下文信息
|
||||
this.store.resizeContext = {
|
||||
position: position,
|
||||
panelId: panelId,
|
||||
panelIndex: panelIndex,
|
||||
originalSizes: null
|
||||
};
|
||||
|
||||
// 根据不同位置获取原始尺寸
|
||||
switch (position) {
|
||||
case 'top':
|
||||
this.store.resizeContext.originalSizes = this.store.topPanelArea.panels.map(p => ({ width: p.width || 300 }));
|
||||
break;
|
||||
case 'bottom':
|
||||
this.store.resizeContext.originalSizes = this.store.bottomPanelArea.panels.map(p => ({ width: p.width || 300 }));
|
||||
break;
|
||||
case 'left':
|
||||
this.store.resizeContext.originalSizes = this.store.leftPanelArea.panels.map(p => ({ height: p.height || 200 }));
|
||||
break;
|
||||
case 'right':
|
||||
this.store.resizeContext.originalSizes = this.store.rightPanelArea.panels.map(p => ({ height: p.height || 200 }));
|
||||
break;
|
||||
}
|
||||
|
||||
// 阻止默认行为
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标移动
|
||||
*/
|
||||
handleMouseMove(event) {
|
||||
if (this.store.isResizing) {
|
||||
// 处理分隔线调整大小
|
||||
const deltaX = event.clientX - this.store.resizeStartPos.x;
|
||||
const deltaY = event.clientY - this.store.resizeStartPos.y;
|
||||
if(deltaX === 0 && deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
// 获取当前调整大小的上下文
|
||||
const context = this.store.resizeContext;
|
||||
|
||||
if (context && (context.position === 'top' || context.position === 'bottom')) {
|
||||
// 处理顶部和底部面板的水平调整
|
||||
this.adjustPanelsHorizontal(context.position, context.panelIndex, deltaX, context.originalSizes);
|
||||
|
||||
// 不重置resizeStartPos,避免抖动问题
|
||||
// 保持deltaX的累积计算,提供更平滑的调整体验
|
||||
} else if (context && (context.position === 'left' || context.position === 'right')) {
|
||||
// 处理左侧和右侧面板的垂直调整
|
||||
this.adjustPanelsVertical(context.position, context.panelIndex, deltaY, context.originalSizes);
|
||||
|
||||
// 不重置resizeStartPos,保持与水平调整一致的累积计算策略
|
||||
// 为垂直调整也提供更平滑的体验
|
||||
} else {
|
||||
// 处理原始的区域调整大小逻辑
|
||||
this.handleRegionResize(this.store.resizeTarget, deltaX, deltaY);
|
||||
|
||||
// 重置起始位置,以便下一次计算
|
||||
this.store.resizeStartPos = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用函数:处理顶部和底部面板的水平调整
|
||||
*/
|
||||
adjustPanelsHorizontal(position, panelIndex, deltaX, originalSizes) {
|
||||
try {
|
||||
// 获取面板区域的实际宽度
|
||||
if (!this.container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = this.container.value.getBoundingClientRect();
|
||||
// 计算可用宽度 - 顶部和底部面板的宽度应该是容器宽度减去所有受影响面板的宽度
|
||||
let availableWidth = containerRect.width;
|
||||
|
||||
// 获取当前位置面板的受影响关系
|
||||
const influencedByArray = this.store.panelSizeInfluence.influencedBy[position];
|
||||
if (influencedByArray && Array.isArray(influencedByArray)) {
|
||||
// 根据受影响关系调整可用空间
|
||||
influencedByArray.forEach(influence => {
|
||||
if (influence.influence && influence.property === 'width') {
|
||||
const panelArea = influence.position === 'left' ? this.store.leftPanelArea : this.store.rightPanelArea;
|
||||
const occupiedWidth = panelArea.panels.reduce((sum, panel) => sum + (panel.width || 0), 0);
|
||||
availableWidth -= occupiedWidth;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取对应的面板区对象
|
||||
const panelArea = position === 'top' ? this.store.topPanelArea : this.store.bottomPanelArea;
|
||||
const panels = panelArea.panels;
|
||||
|
||||
// 统一使用与顶部面板相同的处理逻辑
|
||||
const updatedPanels = this.store.adjustAdjacentPanels(
|
||||
position, panelIndex, deltaX, originalSizes, availableWidth
|
||||
);
|
||||
|
||||
if (updatedPanels !== panels) {
|
||||
// 更新面板数组
|
||||
panelArea.panels = updatedPanels;
|
||||
|
||||
// 手动调整后更新比例 - 直接使用从DOM获取的availableWidth作为基准,避免二次计算导致的不一致
|
||||
panelArea.widthRatios = updatedPanels.map(panel =>
|
||||
availableWidth > 0 ? (panel.width || 300) / availableWidth : 1 / updatedPanels.length
|
||||
);
|
||||
|
||||
// 调用影响处理函数,确保面板间影响关系正确处理
|
||||
this.store.handlePanelSizeInfluence(position, this.container.value);
|
||||
|
||||
// 保存布局,确保调整后的比例被持久化
|
||||
this.layoutPersistence.saveLayout();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in adjustPanelsHorizontal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用函数:处理左侧和右侧面板的垂直调整
|
||||
*/
|
||||
adjustPanelsVertical(position, panelIndex, deltaY, originalSizes) {
|
||||
try {
|
||||
// 获取容器元素以获取实际高度
|
||||
if (!this.container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取对应的面板区对象
|
||||
const panelArea = position === 'left' ? this.store.leftPanelArea : this.store.rightPanelArea;
|
||||
const panels = panelArea.panels;
|
||||
|
||||
// 计算可用高度 - 考虑顶部和底部面板的影响以及分隔条高度
|
||||
let availableHeight;
|
||||
|
||||
// 计算左侧/右侧面板的当前总高度(包括分隔条高度)
|
||||
const separatorHeight = 8; // 分隔条高度,从CSS类h-2推断(0.5rem = 8px)
|
||||
const panelsHeight = panels.reduce((sum, panel) => sum + (panel.height || 0), 0);
|
||||
// 分隔条数量 = 面板数量 - 1
|
||||
const totalSeparatorsHeight = (panels.length > 1) ? separatorHeight * (panels.length - 1) : 0;
|
||||
const currentTotalHeight = panelsHeight + totalSeparatorsHeight;
|
||||
|
||||
// 如果当前总高度有效,则使用它作为可用高度基准
|
||||
if (currentTotalHeight > 0) {
|
||||
availableHeight = currentTotalHeight;
|
||||
} else {
|
||||
// 否则,从容器高度开始计算,考虑顶部和底部面板的影响
|
||||
const containerRect = this.container.value.getBoundingClientRect();
|
||||
availableHeight = containerRect.height;
|
||||
|
||||
// 获取当前位置面板的受影响关系
|
||||
const influencedByArray = this.store.panelSizeInfluence.influencedBy[position];
|
||||
if (influencedByArray && Array.isArray(influencedByArray)) {
|
||||
// 根据受影响关系调整可用空间
|
||||
influencedByArray.forEach(influence => {
|
||||
if (influence.influence && influence.property === 'height') {
|
||||
if (influence.position === 'top') {
|
||||
// 顶部面板:使用max(上面板的子面板高度)作为高度
|
||||
const maxTopPanelHeight = this.store.topPanelArea.panels.length > 0
|
||||
? Math.max(...this.store.topPanelArea.panels.map(panel => panel.height || 0))
|
||||
: 0;
|
||||
availableHeight -= maxTopPanelHeight;
|
||||
} else if (influence.position === 'bottom') {
|
||||
// 底部面板:使用max(下面板的子面板高度)作为高度
|
||||
const maxBottomPanelHeight = this.store.bottomPanelArea.panels.length > 0
|
||||
? Math.max(...this.store.bottomPanelArea.panels.map(panel => panel.height || 0))
|
||||
: 0;
|
||||
availableHeight -= maxBottomPanelHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 统一使用与水平调整相同的处理逻辑
|
||||
const updatedPanels = this.store.adjustAdjacentPanelsVertical(
|
||||
position, panelIndex, deltaY, originalSizes, availableHeight
|
||||
);
|
||||
|
||||
if (updatedPanels !== panels) {
|
||||
// 更新面板数组
|
||||
panelArea.panels = updatedPanels;
|
||||
|
||||
// 手动调整后更新比例 - 直接使用从可用高度作为基准,避免二次计算导致的不一致
|
||||
panelArea.heightRatios = updatedPanels.map(panel =>
|
||||
availableHeight > 0 ? (panel.height || 200) / availableHeight : 1 / updatedPanels.length
|
||||
);
|
||||
|
||||
// 使用公共方法处理面板影响
|
||||
this.store.handlePanelSizeInfluence(position, this.container.value);
|
||||
|
||||
// 保存布局,确保调整后的比例被持久化
|
||||
this.layoutPersistence.saveLayout();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in adjustPanelsVertical:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用函数:处理区域调整大小
|
||||
*/
|
||||
handleRegionResize(target, deltaX, deltaY) {
|
||||
try {
|
||||
// 检查容器元素是否存在
|
||||
if (!this.container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取容器高度,用于限制顶底面板总高度
|
||||
const containerHeight = this.container.value.clientHeight;
|
||||
|
||||
// 准备面板区域信息
|
||||
const panelAreas = {
|
||||
top: this.store.topPanelArea,
|
||||
bottom: this.store.bottomPanelArea,
|
||||
left: this.store.leftPanelArea,
|
||||
right: this.store.rightPanelArea,
|
||||
center: this.store.centerPanelArea
|
||||
};
|
||||
|
||||
switch (target) {
|
||||
case 'left':
|
||||
// 修复左侧面板拖拽速度问题:右拖应该缓慢增大宽度,进一步减小deltaX的影响
|
||||
this.store.adjustRegionSize('left', deltaX);
|
||||
break;
|
||||
case 'right':
|
||||
// 修复右侧面板拖拽方向相反的问题:向左拖应该增大宽度,所以反转deltaX
|
||||
this.store.adjustRegionSize('right', deltaX);
|
||||
break;
|
||||
case 'top':
|
||||
this.store.adjustRegionSize('top', deltaY, this.container.value);
|
||||
break;
|
||||
case 'bottom':
|
||||
this.store.adjustRegionSize('bottom', deltaY, this.container.value);
|
||||
break;
|
||||
}
|
||||
|
||||
// 使用store方法处理面板影响
|
||||
this.store.handlePanelSizeInfluence(target, this.container.value);
|
||||
} catch (error) {
|
||||
console.error('Error in handleRegionResize:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标松开
|
||||
*/
|
||||
handleMouseUp(event, hideContextMenuCallback, checkDockZoneCallback) {
|
||||
if (this.store.isResizing) {
|
||||
this.store.isResizing = false;
|
||||
this.store.resizeTarget = '';
|
||||
// 重置调整大小上下文,避免不同位置调整之间的上下文混淆
|
||||
this.store.resizeContext = null;
|
||||
// 确保调整结束后重置resizeStartPos,为下一次调整做好准备
|
||||
// 无论之前采用哪种调整策略,此处统一重置以保持一致性
|
||||
this.store.resizeStartPos = { x: 0, y: 0 };
|
||||
|
||||
// 打印保存前的面板宽度值
|
||||
console.log('ResizeHandlers - 保存前左侧面板宽度:', this.store.leftPanelArea.width);
|
||||
this.layoutPersistence.saveLayout();
|
||||
console.log('ResizeHandlers - 保存后左侧面板宽度:', this.store.leftPanelArea.width);
|
||||
} else if (this.store.dragState.active && checkDockZoneCallback) {
|
||||
// 计算拖拽距离
|
||||
const dragDistance = Math.sqrt(
|
||||
Math.pow(event.clientX - this.store.dragState.startX, 2) +
|
||||
Math.pow(event.clientY - this.store.dragState.startY, 2)
|
||||
);
|
||||
|
||||
// 如果是面板拖拽且拖拽距离超过阈值,转换为浮动窗口
|
||||
if (this.store.dragState.isPanelDrag && dragDistance > 20) {
|
||||
// 检查是否在停靠区域内
|
||||
const dockZone = checkDockZoneCallback(event.clientX, event.clientY);
|
||||
if (!dockZone) {
|
||||
// 转换为浮动窗口,位置设置为鼠标当前位置
|
||||
if (this.container.value) {
|
||||
const rect = this.container.value.getBoundingClientRect();
|
||||
this.store.floatPanel(this.store.dragState.panelId);
|
||||
}
|
||||
|
||||
// 找到刚创建的浮动窗口并设置位置
|
||||
setTimeout(() => {
|
||||
const floatingWindow = this.store.floatingWindows.find(w => w.id === this.store.dragState.panelId);
|
||||
if (floatingWindow) {
|
||||
floatingWindow.x = event.clientX - 200; // 居中显示
|
||||
floatingWindow.y = event.clientY - 30; // 标题栏高度的一半
|
||||
}
|
||||
}, 10);
|
||||
} else {
|
||||
// 在停靠区域内,执行停靠
|
||||
this.store.dockPanel(this.store.dragState.panelId, dockZone.position);
|
||||
}
|
||||
} else {
|
||||
// 处理拖拽结束,执行停靠
|
||||
const dockZone = checkDockZoneCallback(event.clientX, event.clientY);
|
||||
if (dockZone) {
|
||||
this.store.dockPanel(this.store.dragState.panelId, dockZone.position);
|
||||
}
|
||||
}
|
||||
|
||||
// 重置拖拽状态
|
||||
this.store.dragState = {
|
||||
active: false,
|
||||
panelId: null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
originalPosition: null,
|
||||
isPanelDrag: false
|
||||
};
|
||||
this.store.dockPreview.visible = false;
|
||||
}
|
||||
|
||||
// 隐藏右键菜单
|
||||
if (hideContextMenuCallback) {
|
||||
hideContextMenuCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件监听
|
||||
*/
|
||||
initialize() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// 添加事件监听
|
||||
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理事件监听
|
||||
*/
|
||||
cleanup() {
|
||||
if (!this.isInitialized) return;
|
||||
|
||||
document.removeEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建ResizeHandlers的Vue组合式函数
|
||||
*/
|
||||
export function useResizeHandlers(store, container, layoutPersistence) {
|
||||
const resizeHandlers = new ResizeHandlers(store, container, layoutPersistence);
|
||||
|
||||
onMounted(() => {
|
||||
resizeHandlers.initialize();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeHandlers.cleanup();
|
||||
});
|
||||
|
||||
return resizeHandlers;
|
||||
}
|
||||
75
AutoRobot/Windows/Robot/Web/src/components/SendMessage.vue
Normal file
75
AutoRobot/Windows/Robot/Web/src/components/SendMessage.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<!-- 发送消息卡片 -->
|
||||
<div class="bg-white/90 backdrop-blur-sm rounded-2xl shadow-xl p-4 transform transition-all duration-300 hover:shadow-2xl border border-gray-100">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center mr-2">
|
||||
<i class="fa-solid fa-paper-plane text-primary"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-sm">发送消息</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<textarea
|
||||
v-model="messageInput"
|
||||
rows="3"
|
||||
class="w-full max-w-full px-3 py-2.5 border border-gray-200 rounded-xl transition-all resize-none outline-none bg-gray-50/70 focus:border-primary focus:outline-2 focus:outline-offset-0 focus:outline-primary/30 box-border"
|
||||
placeholder="输入要发送的消息..."
|
||||
:disabled="!isConnected"
|
||||
@keydown.enter.exact="sendMessage"
|
||||
@keydown.enter.shift.prevent=""
|
||||
></textarea>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ messageInput.length }} 字符
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="sendMessage"
|
||||
class="px-5 py-2.5 rounded-xl font-medium transition-all duration-300 flex items-center space-x-2 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
|
||||
:class="[sendButtonClass, { 'opacity-70': !isConnected || messageInput.trim() === '' }]"
|
||||
:disabled="!isConnected || messageInput.trim() === ''"
|
||||
>
|
||||
<i class="fa-solid fa-paper-plane"></i>
|
||||
<span>发送</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
isConnected: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['send-message']);
|
||||
|
||||
// Refs
|
||||
const messageInput = ref('');
|
||||
|
||||
// Computed
|
||||
const sendButtonClass = computed(() => {
|
||||
return props.isConnected
|
||||
? 'bg-[#165DFF] text-white hover:bg-[#1453e0] shadow-md hover:shadow-lg'
|
||||
: 'bg-gray-400 text-gray-900 cursor-not-allowed';
|
||||
});
|
||||
|
||||
// Methods
|
||||
const sendMessage = () => {
|
||||
if (!props.isConnected || messageInput.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = messageInput.value.trim();
|
||||
emit('send-message', message);
|
||||
messageInput.value = '';
|
||||
};
|
||||
</script>
|
||||
414
AutoRobot/Windows/Robot/Web/src/components/TabGroup.js
Normal file
414
AutoRobot/Windows/Robot/Web/src/components/TabGroup.js
Normal file
@@ -0,0 +1,414 @@
|
||||
import { defineComponent, h, computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
// 标签组组件
|
||||
export const TabGroup = defineComponent({
|
||||
name: 'TabGroup',
|
||||
props: {
|
||||
// 面板列表
|
||||
panels: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// 当前激活的标签索引
|
||||
activeTab: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'switchTab', // 切换标签
|
||||
'closeTab', // 关闭标签
|
||||
'floatTab', // 浮动标签
|
||||
'dockTab', // 停靠标签
|
||||
'dragTabStart' // 开始拖拽标签
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
// 拖拽状态
|
||||
const isDragging = ref(false);
|
||||
const draggedTabIndex = ref(-1);
|
||||
const dragOverIndex = ref(-1);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const tabPositions = ref([]); // 存储每个标签的位置和尺寸
|
||||
|
||||
// 计算标签容器样式
|
||||
const tabContainerStyle = computed(() => {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
backgroundColor: '#f9fafb',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
height: '36px',
|
||||
position: 'relative',
|
||||
userSelect: 'none'
|
||||
};
|
||||
});
|
||||
|
||||
// 计算标签样式
|
||||
const tabStyle = computed(() => {
|
||||
return (index) => {
|
||||
const isActive = index === props.activeTab;
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 12px',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
borderRight: '1px solid #e5e7eb',
|
||||
backgroundColor: isActive ? 'white' : '#f9fafb',
|
||||
borderBottom: isActive ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
fontSize: '12px',
|
||||
fontWeight: isActive ? '500' : '400',
|
||||
color: isActive ? '#111827' : '#6b7280',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap'
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
// 计算内容区样式
|
||||
const contentStyle = computed(() => {
|
||||
return {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
};
|
||||
});
|
||||
|
||||
// 处理标签点击
|
||||
const handleTabClick = (index) => {
|
||||
if (index !== props.activeTab) {
|
||||
emit('switchTab', index);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理标签关闭
|
||||
const handleTabClose = (event, index) => {
|
||||
event.stopPropagation();
|
||||
emit('closeTab', props.panels[index].id);
|
||||
};
|
||||
|
||||
// 处理标签浮动
|
||||
const handleTabFloat = (event, index) => {
|
||||
event.stopPropagation();
|
||||
emit('floatTab', props.panels[index].id);
|
||||
};
|
||||
|
||||
// 处理标签拖拽开始
|
||||
const handleTabDragStart = (event, index) => {
|
||||
event.stopPropagation();
|
||||
isDragging.value = true;
|
||||
draggedTabIndex.value = index;
|
||||
dragStartX.value = event.clientX;
|
||||
dragStartY.value = event.clientY;
|
||||
|
||||
// 记录所有标签的位置和尺寸
|
||||
const tabElements = document.querySelectorAll('.tab-group-tab');
|
||||
tabPositions.value = Array.from(tabElements).map(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
width: rect.width
|
||||
};
|
||||
});
|
||||
|
||||
emit('dragTabStart', props.panels[index].id, event);
|
||||
|
||||
// 阻止默认拖拽行为
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// 处理标签右键点击
|
||||
const handleTabContextMenu = (event, index) => {
|
||||
event.preventDefault();
|
||||
|
||||
// 触发自定义事件,通知父组件显示上下文菜单
|
||||
const customEvent = new CustomEvent('showContextMenu', {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
panelId: props.panels[index].id,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
}
|
||||
});
|
||||
event.target.dispatchEvent(customEvent);
|
||||
};
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = (event) => {
|
||||
if (isDragging.value) {
|
||||
// 计算拖拽距离
|
||||
const deltaX = event.clientX - dragStartX.value;
|
||||
const deltaY = event.clientY - dragStartY.value;
|
||||
|
||||
// 检测是否应该重新排序标签
|
||||
if (Math.abs(deltaX) > 10) {
|
||||
// 计算当前鼠标位置对应的标签索引
|
||||
const currentX = event.clientX;
|
||||
let newIndex = draggedTabIndex.value;
|
||||
|
||||
// 向左拖拽
|
||||
if (deltaX < 0) {
|
||||
for (let i = draggedTabIndex.value - 1; i >= 0; i--) {
|
||||
if (currentX < (tabPositions.value[i].left + tabPositions.value[i].width / 2)) {
|
||||
newIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 向右拖拽
|
||||
else if (deltaX > 0) {
|
||||
for (let i = draggedTabIndex.value + 1; i < props.panels.length; i++) {
|
||||
if (currentX > (tabPositions.value[i].left + tabPositions.value[i].width / 2)) {
|
||||
newIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新拖拽覆盖索引
|
||||
dragOverIndex.value = newIndex !== draggedTabIndex.value ? newIndex : -1;
|
||||
}
|
||||
|
||||
// 检测是否拖拽到标签组外部(准备浮动)
|
||||
if (Math.abs(deltaY) > 30) {
|
||||
// 可以在这里添加视觉提示,表示可以浮动
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标松开
|
||||
const handleMouseUp = (event) => {
|
||||
if (isDragging.value) {
|
||||
const deltaY = event.clientY - dragStartY.value;
|
||||
|
||||
// 如果拖拽距离超过阈值,考虑浮动标签
|
||||
if (Math.abs(deltaY) > 30) {
|
||||
emit('floatTab', props.panels[draggedTabIndex.value].id);
|
||||
}
|
||||
// 如果有拖拽到其他标签位置,重新排序
|
||||
else if (dragOverIndex.value !== -1 && dragOverIndex.value !== draggedTabIndex.value) {
|
||||
// 这里需要父组件来处理标签重新排序
|
||||
// 我们可以触发一个自定义事件,或者在父组件中实现
|
||||
// 注意:实际的重新排序逻辑需要在父组件中实现
|
||||
}
|
||||
|
||||
// 重置拖拽状态
|
||||
isDragging.value = false;
|
||||
draggedTabIndex.value = -1;
|
||||
dragOverIndex.value = -1;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染标签图标
|
||||
const renderTabIcon = (panel) => {
|
||||
if (!panel.icon) return null;
|
||||
|
||||
return h('span', {
|
||||
class: 'mr-2',
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}
|
||||
}, [panel.icon]);
|
||||
};
|
||||
|
||||
// 渲染标签标题
|
||||
const renderTabTitle = (panel) => {
|
||||
return h('span', null, panel.title);
|
||||
};
|
||||
|
||||
// 渲染标签关闭按钮
|
||||
const renderTabCloseButton = (index) => {
|
||||
return h('button', {
|
||||
onClick: (e) => handleTabClose(e, index),
|
||||
title: '关闭标签',
|
||||
style: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
borderRadius: '2px',
|
||||
color: '#6b7280',
|
||||
fontSize: '10px',
|
||||
marginLeft: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
onMouseEnter: (e) => {
|
||||
e.target.style.backgroundColor = '#e5e7eb';
|
||||
e.target.style.color = '#111827';
|
||||
},
|
||||
onMouseLeave: (e) => {
|
||||
e.target.style.backgroundColor = 'none';
|
||||
e.target.style.color = '#6b7280';
|
||||
}
|
||||
}, '×');
|
||||
};
|
||||
|
||||
// 渲染标签内容
|
||||
const renderTabContent = () => {
|
||||
if (props.panels.length === 0 || props.activeTab < 0 || props.activeTab >= props.panels.length) {
|
||||
return h('div', {
|
||||
style: {
|
||||
padding: '16px',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#9ca3af',
|
||||
fontSize: '14px'
|
||||
}
|
||||
}, '无标签内容');
|
||||
}
|
||||
|
||||
const activePanel = props.panels[props.activeTab];
|
||||
|
||||
if (activePanel.component) {
|
||||
// 如果面板定义了组件,渲染组件
|
||||
return h(activePanel.component, {
|
||||
...activePanel.props || {},
|
||||
style: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'auto'
|
||||
}
|
||||
});
|
||||
} else if (activePanel.content) {
|
||||
// 如果面板定义了内容,渲染内容
|
||||
return h('div', {
|
||||
style: {
|
||||
padding: '16px',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'auto'
|
||||
}
|
||||
}, activePanel.content);
|
||||
}
|
||||
|
||||
// 默认显示空内容
|
||||
return h('div', {
|
||||
style: {
|
||||
padding: '16px',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#9ca3af',
|
||||
fontSize: '14px'
|
||||
}
|
||||
}, '无内容');
|
||||
};
|
||||
|
||||
// 渲染拖拽指示器
|
||||
const renderDragIndicator = () => {
|
||||
if (!isDragging.value || dragOverIndex.value === -1 || dragOverIndex.value === draggedTabIndex.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算指示器位置
|
||||
let left = 0;
|
||||
if (dragOverIndex.value < draggedTabIndex.value) {
|
||||
// 拖拽到左侧标签之前
|
||||
left = tabPositions.value[dragOverIndex.value].left;
|
||||
} else {
|
||||
// 拖拽到右侧标签之后
|
||||
left = tabPositions.value[dragOverIndex.value].right;
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: 'tab-drag-indicator',
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
bottom: '0',
|
||||
left: `${left}px`,
|
||||
width: '2px',
|
||||
backgroundColor: '#3b82f6',
|
||||
zIndex: 20
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
// 添加全局事件监听
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理全局事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
// 渲染标签组
|
||||
return () => {
|
||||
return h('div', {
|
||||
class: 'tab-group',
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}
|
||||
}, [
|
||||
// 标签容器
|
||||
h('div', {
|
||||
style: tabContainerStyle.value,
|
||||
class: 'tab-group-tabs'
|
||||
}, [
|
||||
// 渲染拖拽指示器
|
||||
renderDragIndicator(),
|
||||
|
||||
// 渲染标签列表
|
||||
props.panels.map((panel, index) => {
|
||||
return h('div', {
|
||||
key: panel.id,
|
||||
style: tabStyle.value(index),
|
||||
class: ['tab-group-tab', index === props.activeTab ? 'tab-active' : ''],
|
||||
onClick: () => handleTabClick(index),
|
||||
onMousedown: (e) => handleTabDragStart(e, index),
|
||||
onContextmenu: (e) => handleTabContextMenu(e, index),
|
||||
onMouseEnter: (e) => {
|
||||
if (index !== props.activeTab) {
|
||||
e.target.style.backgroundColor = '#f3f4f6';
|
||||
}
|
||||
},
|
||||
onMouseLeave: (e) => {
|
||||
if (index !== props.activeTab) {
|
||||
e.target.style.backgroundColor = '#f9fafb';
|
||||
}
|
||||
}
|
||||
}, [
|
||||
renderTabIcon(panel),
|
||||
renderTabTitle(panel),
|
||||
renderTabCloseButton(index)
|
||||
]);
|
||||
})
|
||||
]),
|
||||
|
||||
// 内容区域
|
||||
h('div', {
|
||||
style: contentStyle.value,
|
||||
class: 'tab-group-content'
|
||||
}, [
|
||||
renderTabContent()
|
||||
])
|
||||
]);
|
||||
};
|
||||
}
|
||||
});
|
||||
83
AutoRobot/Windows/Robot/Web/src/components/TabPage.vue
Normal file
83
AutoRobot/Windows/Robot/Web/src/components/TabPage.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<!-- 标签页导航 -->
|
||||
<div class="flex border-b border-gray-200 bg-white">
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
@click="currentTab = index"
|
||||
class="px-4 py-3 text-sm font-medium transition-colors duration-200"
|
||||
:class="{
|
||||
'text-blue-600 border-b-2 border-blue-600': currentTab === index,
|
||||
'text-gray-500 hover:text-blue-500': currentTab !== index
|
||||
}"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 标签页内容 -->
|
||||
<div class="flex-grow p-4 bg-white rounded-b-lg min-h-0 flex flex-col">
|
||||
<slot name="tab-content" :active-tab="currentTab">
|
||||
<!-- 默认内容区域 -->
|
||||
<div v-if="tabs[currentTab]?.component">
|
||||
<component :is="tabs[currentTab].component" :key="currentTab" />
|
||||
</div>
|
||||
<div v-else-if="tabs[currentTab]?.content">
|
||||
{{ tabs[currentTab].content }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex items-center justify-center h-full min-h-[200px] text-gray-400">
|
||||
请选择一个标签页
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps } from 'vue';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{
|
||||
label: '首页',
|
||||
content: '首页内容'
|
||||
},
|
||||
{
|
||||
label: '设置',
|
||||
content: '设置内容'
|
||||
}
|
||||
]
|
||||
},
|
||||
defaultTab: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
// 当前激活的标签页
|
||||
const currentTab = ref(props.defaultTab);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 标签页容器样式 */
|
||||
.tab-content {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
AutoRobot/Windows/Robot/Web/src/components/Taskbar.vue
Normal file
48
AutoRobot/Windows/Robot/Web/src/components/Taskbar.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<!-- 任务栏 - 显示最小化的窗口 -->
|
||||
<div v-if="minimizedWindows.length > 0" class="taskbar flex items-center justify-center gap-2" style="max-height: var(--taskbar-height);">
|
||||
<div v-for="window in minimizedWindows" :key="window.id"
|
||||
class="taskbar-item taskbar-minimized flex items-center gap-1 px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded-md cursor-pointer text-sm transition-all whitespace-nowrap"
|
||||
@click="onRestoreWindow(window.id)">
|
||||
<span v-if="window.icon" class="text-xs taskbar-item-icon"> {{ window.icon }} </span>
|
||||
<span class="taskbar-item-text"> {{ window.title }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
minimizedWindows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
// 定义emits
|
||||
const emit = defineEmits(['restore-window']);
|
||||
|
||||
// 处理恢复最小化窗口
|
||||
function onRestoreWindow(windowId) {
|
||||
emit('restore-window', windowId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Taskbar特有的作用域样式 */
|
||||
/* 共享样式已在styles/taskbar-shared.css中定义 */
|
||||
.taskbar {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.taskbar-minimized {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.taskbar-minimized:hover {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user