Add AutoRobot directory with Windows line endings

This commit is contained in:
2025-10-20 09:04:09 +08:00
parent a7ade87dde
commit d663118a73
124 changed files with 22719 additions and 0 deletions

View 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>

View 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

View File

@@ -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>

View 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>

View 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 通信中心 &copy; {{ year }}</span>
</p>
</div>
</footer>
</template>
<script setup>
import { computed } from 'vue';
// Computed
const year = computed(() => {
return new Date().getFullYear();
});
</script>

View 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>

View 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 };
}
}
}

View 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;
}

View 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;
}
}

View 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>

View 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>

View 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>

View 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>

View 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;
}

View 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>

View 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()
])
]);
};
}
});

View 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>

View 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>