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

679 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="panel bg-white overflow-hidden"
:style="{ width: '100%', height: '100%' }"
:data-panel-id="id">
<div class="flex flex-col h-full">
<!-- 标题栏 -->
<div class="title-bar h-6 bg-[#435d9c] text-white px-2 flex items-center justify-between select-none cursor-move"
@mousedown="onDragStart">
<div class="flex items-center">
<span class="text-xs">{{ title }}</span>
</div>
<div class="title-bar-buttons flex items-center gap-0.5">
<button class="button-icon p-[2px] rounded hover:opacity-100 opacity-80"
@click.stop="onToggleCollapse"
@mousedown.stop
aria-label="折叠/展开">
<!-- 向下小三角使用内联SVG避免样式作用域问题 -->
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<polygon points="5.5,8 2,3.5 9,3.5" fill="#cbd6ff" />
</svg>
</button>
<button class="button-icon p-[2px] rounded hover:opacity-100 opacity-80"
@click.stop="onMaximize"
@mousedown.stop
:aria-label="maximized ? '还原' : '最大化'">
<!-- 最大化图标 -->
<template v-if="!maximized">
<!-- 最大化图标外框 + 内部线条 -->
<svg class="icon-square-svg" width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<rect x="0.5" y="0.5" width="10" height="10" fill="#cbd6ff" stroke="#8ea3d8" stroke-width="1" />
<rect x="3" y="3" width="5" height="1" fill="#b8c6ff" />
<rect x="1" y="3" width="8.5" height="6.5" fill="#435d9c" />
</svg>
</template>
<!-- 还原图标 -->
<template v-else>
<svg class="icon-square-svg" width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<path
fill="#CED4DD"
fill-rule="evenodd"
clip-rule="evenodd"
d="M1 4 L4 4 L4 1 L11 1 L11 8 L8 8 L8 11 L1 11 Z
M5 4 L5 3 L10 3 L10 7 L8 7 L8 4 Z
M2 6 L12.6 5 L7 6 L7 10 L2 10 Z" />
</svg>
</template>
</button>
<button class="button-icon p-[2px] rounded hover:opacity-100 opacity-80"
@click.stop="onClose"
@mousedown.stop
aria-label="关闭">
<!-- 关闭图标X内联SVG确保1px线条 -->
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<line x1="2" y1="2" x2="9" y2="9" stroke="#e6efff" stroke-width="1" />
<line x1="2" y1="9" x2="9" y2="2" stroke="#e6efff" stroke-width="1" />
</svg>
</button>
</div>
</div>
<!-- 工具栏位于标题栏下方右侧扩展钮 -->
<div class="toolbar h-6 bg-[#d5e2f6] text-[#2c3e7a] px-2 flex items-center justify-between border-b border-[#c7d2ea]">
<div class="toolbar-left flex items-center gap-2">
<span class="text-xs">工具栏</span>
<button v-if="toolbarExpanded" class="toolbar-button px-2 py-0.5 text-xs bg-white/60 rounded hover:bg-white">示例按钮</button>
</div>
<button class="toolbar-toggle px-2 py-0.5 text-xs rounded hover:bg-white/40"
@click="onToggleToolbar"
aria-label="展开工具栏">
<i class="fa-solid" :class="toolbarExpanded ? 'fa-angles-left' : 'fa-angles-right'"></i>
</button>
</div>
<!-- 内容区可折叠添加滚动条 -->
<div class="content-area bg-[#f5f7fb] flex-1 p-4 overflow-auto min-h-0" v-show="!collapsed">
<div v-if="content" class="panel-content">
<div class="mb-4">
<h3 class="text-lg font-bold mb-2" :style="{ color: content.color }">
{{ content.title }}
</h3>
<p class="text-sm text-gray-600 mb-3">
类型{{ content.type }} | 创建时间{{ content.timestamp }}
</p>
</div>
<div class="grid grid-cols-1 gap-3">
<div
v-for="(item, index) in content.data"
:key="item.id"
class="data-item p-3 border rounded-lg shadow-sm"
:style="{
borderLeftColor: content.color,
borderLeftWidth: '4px'
}"
>
<div class="flex justify-between items-center">
<span class="font-medium">{{ item.label }}</span>
<span class="text-sm font-bold" :style="{ color: content.color }">
{{ item.value }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-gray-500 text-center">
<p>暂无内容</p>
<div class="mt-2 text-xs">面板ID: {{ id }} - 标题: {{ title }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, onMounted, onUnmounted } from 'vue';
import {
eventBus,
EVENT_TYPES,
emitEvent,
onEvent
} from './eventBus';
// 定义组件属性
const props = defineProps({
id: {
type: String,
required: true
},
title: {
type: String,
default: ''
},
x: {
type: Number,
default: 0
},
y: {
type: Number,
default: 0
},
width: {
type: Number,
default: 300
},
height: {
type: Number,
default: 180
},
collapsed: {
type: Boolean,
default: false
},
toolbarExpanded: {
type: Boolean,
default: false
},
maximized: {
type: Boolean,
default: false
},
content: {
type: Object,
default: null
},
// 移除areaId属性因为面板会被拖拽到不同区域
// 改为通过DOM动态获取当前所在区域
});
// 事件订阅管理 - 使用Set避免key冲突并添加唯一标识符
const subscriptions = new Set();
const subscriptionRegistry = new Map();
const getCurrentAreaId = () => {
const panelElement = document.querySelector(`[data-panel-id="${props.id}"]`);
if (panelElement) {
const areaElement = panelElement.closest('[data-area-id]');
if (areaElement) {
return areaElement.getAttribute('data-area-id');
}
}
const parentElement = document.querySelector(`[data-panel-id="${props.id}"]`)?.parentElement;
if (parentElement) {
const areaElement = parentElement.closest('.vs-area');
if (areaElement) {
return areaElement.getAttribute('data-area-id');
}
}
console.warn(`[Panel:${props.id}] 无法找到当前所在的AreaPanel可能未正确挂载`);
return null;
};
// 事件处理函数 - 使用事件总线
const onToggleCollapse = () => {
console.log(`[Panel:${props.id}] 触发折叠/展开事件`)
emitEvent(EVENT_TYPES.PANEL_TOGGLE_COLLAPSE, {
panelId: props.id,
areaId: getCurrentAreaId(),
currentState: props.collapsed
}, {
source: { component: 'Panel', panelId: props.id }
})
};
const onMaximize = () => {
console.log(`[Panel:${props.id}] 触发最大化事件`)
emitEvent(EVENT_TYPES.PANEL_MAXIMIZE, {
panelId: props.id,
areaId: getCurrentAreaId(),
currentState: props.maximized
}, {
source: { component: 'Panel', panelId: props.id }
})
};
const onClose = () => {
// console.log(`[Panel:${props.id}] 触发关闭请求事件`)
// 首先发送关闭请求,让父组件可以处理确认逻辑
emitEvent(EVENT_TYPES.PANEL_CLOSE_REQUEST, {
panelId: props.id,
areaId: getCurrentAreaId(),
panelTitle: props.title,
requestTime: Date.now()
}, {
source: { component: 'Panel', panelId: props.id }
})
};
const onToggleToolbar = () => {
console.log(`[Panel:${props.id}] 触发工具栏切换事件`)
emitEvent(EVENT_TYPES.PANEL_TOGGLE_TOOLBAR, {
panelId: props.id,
areaId: getCurrentAreaId(),
currentState: props.toolbarExpanded
}, {
source: { component: 'Panel', panelId: props.id }
})
};
// 拖拽相关状态
let isDragging = false
let currentDragId = null
// 全局内存泄漏保护机制
if (!window.__panelMemoryProtection) {
window.__panelMemoryProtection = {
// 存储所有面板组件实例追踪信息
panelInstances: new Map(),
// 定时检测内存泄漏(开发环境)
startLeakDetection() {
if (import.meta.env.DEV) {
setInterval(() => {
this.detectMemoryLeaks()
}, 30000) // 每30秒检测一次
}
},
// 检测内存泄漏
detectMemoryLeaks() {
const activePanels = window.__panelDragHandlers ? window.__panelDragHandlers.size : 0
const registeredPanels = this.panelInstances.size
if (activePanels !== registeredPanels) {
console.warn(`[内存泄漏检测] 发现面板内存不一致 - 活动拖拽: ${activePanels}, 注册实例: ${registeredPanels}`)
// 清理 orphaned handlers
if (window.__panelDragHandlers && activePanels > 0) {
window.__panelDragHandlers.forEach((handlers, panelId) => {
if (!this.panelInstances.has(panelId)) {
console.warn(`[内存泄漏检测] 清理orphaned handler: ${panelId}`)
document.removeEventListener('mousemove', handlers.dragMoveHandler, false)
document.removeEventListener('mouseup', handlers.dragEndHandler, false)
document.removeEventListener('mouseleave', handlers.dragEndHandler, false)
window.__panelDragHandlers.delete(panelId)
}
})
}
}
},
// 注册面板实例
registerPanel(panelId) {
this.panelInstances.set(panelId, {
createdAt: Date.now(),
lastActivity: Date.now()
})
},
// 注销面板实例
unregisterPanel(panelId) {
this.panelInstances.delete(panelId)
},
// 更新活动状态
updateActivity(panelId) {
const panel = this.panelInstances.get(panelId)
if (panel) {
panel.lastActivity = Date.now()
}
}
}
// 启动内存泄漏检测
window.__panelMemoryProtection.startLeakDetection()
}
/**
* 添加Document拖拽事件监听器
*/
const addDocumentDragListeners = () => {
// 移除可能存在的旧监听器
cleanupDragEventListeners()
// 使用组件实例标识符确保清理正确性
const componentId = `panel_${props.id}`
const dragMoveHandler = (e) => onDragMove(e)
const dragEndHandler = (e) => onDragEnd(e)
// 将处理函数绑定到组件作用域,避免匿名函数导致的清理问题
if (!window.__panelDragHandlers) {
window.__panelDragHandlers = new Map()
}
window.__panelDragHandlers.set(componentId, {
dragMoveHandler,
dragEndHandler
})
document.addEventListener('mousemove', dragMoveHandler, false)
document.addEventListener('mouseup', dragEndHandler, false)
document.addEventListener('mouseleave', dragEndHandler, false)
if (import.meta.env.DEV) {
console.log(`[Panel:${props.id}] Document拖拽事件监听器已添加: ${componentId}`)
}
}
/**
* 清理Document拖拽事件监听器
*/
const cleanupDragEventListeners = () => {
try {
const componentId = `panel_${props.id}`
// 从全局拖拽处理函数映射中获取处理函数
const handlers = window.__panelDragHandlers?.get(componentId)
if (handlers) {
// 使用正确的处理函数引用进行清理
document.removeEventListener('mousemove', handlers.dragMoveHandler, false)
document.removeEventListener('mouseup', handlers.dragEndHandler, false)
document.removeEventListener('mouseleave', handlers.dragEndHandler, false)
// 从映射中移除
window.__panelDragHandlers.delete(componentId)
// 清理映射,如果为空则删除整个映射
if (window.__panelDragHandlers.size === 0) {
delete window.__panelDragHandlers
}
if (import.meta.env.DEV) {
console.log(`[Panel:${props.id}] Document拖拽事件监听器已清理: ${componentId}`)
}
}
// 立即重置拖拽状态,确保清理完整性
isDragging = false
} catch (error) {
console.warn(`[Panel:${props.id}] 清理拖拽事件监听器时出错:`, error)
// 发生错误时仍然重置状态
isDragging = false
}
}
// 拖拽开始
const onDragStart = (e) => {
// 只有当点击的是标题栏区域(不是按钮)时才触发拖拽
if (!e.target.closest('.title-bar-buttons') && !e.target.closest('button')) {
// 1. 立即重置之前的拖拽状态
isDragging = false
currentDragId = null
cleanupDragEventListeners()
isDragging = true
// 生成统一的 dragId
currentDragId = `panel_${props.id}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
console.log(`[Panel:${props.id}] 开始拖拽, dragId: ${currentDragId}`)
const areaId = getCurrentAreaId();
emitEvent(EVENT_TYPES.PANEL_DRAG_START, {
dragId: currentDragId,
panelId: props.id,
areaId: areaId,
position: { x: e.clientX, y: e.clientY },
timestamp: Date.now()
}, {
source: { component: 'Panel', panelId: props.id, dragId: currentDragId }
})
e.preventDefault()
e.stopPropagation()
addDocumentDragListeners()
}
};
// 拖拽移动
const onDragMove = (e) => {
if (isDragging && currentDragId) {
// 防止文本选择和默认行为
e.preventDefault();
e.stopPropagation();
const areaId = getCurrentAreaId();
emitEvent(EVENT_TYPES.PANEL_DRAG_MOVE, {
dragId: currentDragId,
panelId: props.id,
areaId: areaId,
position: { x: e.clientX, y: e.clientY },
timestamp: Date.now()
}, {
source: { component: 'Panel', panelId: props.id, dragId: currentDragId }
})
}
};
const onDragEnd = () => {
if (isDragging && currentDragId) {
isDragging = false;
console.log(`[Panel:${props.id}] 结束拖拽, dragId: ${currentDragId}`)
const areaId = getCurrentAreaId();
emitEvent(EVENT_TYPES.PANEL_DRAG_END, {
dragId: currentDragId,
panelId: props.id,
areaId: areaId,
timestamp: Date.now()
}, {
source: { component: 'Panel', panelId: props.id, dragId: currentDragId }
})
currentDragId = null;
cleanupDragEventListeners();
}
};
/**
* 监听面板关闭事件,更新组件状态(可选)
*/
const setupEventListeners = () => {
try {
// 监听面板最大化同步事件
const unsubscribeMaximizeSync = onEvent(EVENT_TYPES.PANEL_MAXIMIZE_SYNC, (data) => {
if (data.panelId === props.id) {
console.log(`[Panel:${props.id}] 收到最大化同步事件`)
}
}, { componentId: `panel-${props.id}` })
const subscriptionId = `maximizeSync_${props.id}_${Date.now()}`
subscriptions.add(unsubscribeMaximizeSync)
subscriptionRegistry.set(subscriptionId, {
unsubscribe: unsubscribeMaximizeSync,
name: 'maximizeSync',
createdAt: Date.now()
})
console.log(`[Panel:${props.id}] 事件监听器注册完成ID: ${subscriptionId}`)
} catch (error) {
console.error(`[Panel:${props.id}] 注册事件监听器失败:`, error)
}
}
/**
* 增强版清理事件监听器,返回清理结果统计
*/
const cleanupEventListeners = () => {
const cleanupResult = {
eventSubscriptions: 0,
documentListeners: 0,
errors: []
}
try {
if (import.meta.env.DEV) {
console.log(`[Panel:${props.id}] 开始清理所有事件监听器...`)
}
// 1. 清理事件订阅
let unsubscribeCount = 0
const subscriptionsToCleanup = Array.from(subscriptions)
subscriptionsToCleanup.forEach((subscription, index) => {
try {
if (subscription && typeof subscription === 'function') {
subscription() // 执行取消订阅函数
unsubscribeCount++
} else {
console.warn(`[Panel:${props.id}] 发现无效的订阅函数,索引: ${index}`)
}
} catch (error) {
console.warn(`[Panel:${props.id}] 取消订阅时出错,索引: ${index}:`, error)
cleanupResult.errors.push(`取消订阅错误 (${index}): ${error.message}`)
}
})
// 清空订阅集合和注册表
subscriptions.clear()
subscriptionRegistry.clear()
cleanupResult.eventSubscriptions = unsubscribeCount
if (import.meta.env.DEV) {
console.log(`[Panel:${props.id}] 已清理 ${unsubscribeCount} 个事件订阅`)
}
} catch (error) {
console.error(`[Panel:${props.id}] 清理事件订阅时发生严重错误:`, error)
cleanupResult.errors.push(`事件订阅清理错误: ${error.message}`)
}
try {
// 2. 清理Document事件监听器
cleanupDragEventListeners()
cleanupResult.documentListeners = 3 // mousemove, mouseup, mouseleave
} catch (error) {
console.error(`[Panel:${props.id}] 清理Document事件监听器时发生错误:`, error)
cleanupResult.errors.push(`Document事件清理错误: ${error.message}`)
}
// 3. 重置状态
try {
isDragging = false
if (import.meta.env.DEV) {
console.log(`[Panel:${props.id}] 拖拽状态已重置`)
}
} catch (error) {
cleanupResult.errors.push(`状态重置错误: ${error.message}`)
}
// 4. 输出清理结果摘要
if (import.meta.env.DEV) {
const totalErrors = cleanupResult.errors.length
if (totalErrors > 0) {
console.warn(`[Panel:${props.id}] 清理完成,存在 ${totalErrors} 个错误:`, cleanupResult.errors)
} else {
console.log(`[Panel:${props.id}] 清理完全成功,无错误`)
}
}
return cleanupResult
}
// 生命周期钩子
onMounted(() => {
console.log(`[Panel:${props.id}] 组件已挂载`)
// 注册到全局内存保护机制
if (window.__panelMemoryProtection) {
window.__panelMemoryProtection.registerPanel(`panel_${props.id}`)
}
// 启用调试模式(开发环境)
if (import.meta.env.DEV) {
eventBus.setDebugMode(true)
}
// 设置事件监听器
setupEventListeners()
// 更新活动状态
if (window.__panelMemoryProtection) {
window.__panelMemoryProtection.updateActivity(`panel_${props.id}`)
}
if (import.meta.env.DEV) {
console.log(`[Panel:${props.id}] 所有监听器设置完成`)
}
})
onUnmounted(() => {
console.log(`[Panel:${props.id}] 组件即将卸载`)
// 立即注销全局内存保护机制
if (window.__panelMemoryProtection) {
window.__panelMemoryProtection.unregisterPanel(`panel_${props.id}`)
}
try {
// 1. 立即设置标志位,防止新的异步操作
isDragging.value = false
// 2. 同步清理所有可以直接清理的资源
const cleanupResult = cleanupEventListeners()
// 3. 记录清理结果
if (import.meta.env.DEV) {
console.log(`[Panel:${props.id}] 组件清理结果:`, cleanupResult)
}
} catch (error) {
console.error(`[Panel:${props.id}] 清理过程中出现异常:`, error)
// 即使出现异常,也要尝试强制清理
try {
isDragging.value = false
} catch (forceError) {
console.error(`[Panel:${props.id}] 强制清理也失败:`, forceError)
}
}
})
</script>
<style scoped>
/* 面板基础样式 */
.panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
/* 图标样式优化 */
.icon-square-svg {
/* 优化SVG渲染避免1px边框显示过粗的问题 */
shape-rendering: crispEdges;
}
/* 内容区域滚动条样式 */
.content-area {
/* 确保滚动条正确显示 */
scrollbar-width: thin;
scrollbar-color: #c7d2ea #f5f7fb;
}
/* Webkit浏览器滚动条样式 */
.content-area::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.content-area::-webkit-scrollbar-track {
background: #f5f7fb;
border-radius: 6px;
}
.content-area::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #d0d6ea, #c0c7e0);
border: 1px solid #b0b6d6;
border-radius: 6px;
}
.content-area::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #c1c7e2, #b2b8d9);
}
.content-area::-webkit-scrollbar-corner {
background: #f5f7fb;
}
/* 禁用可能存在的旧伪元素样式 */
:deep(.icon-square::before),
:deep(.icon-square::after) {
content: none !important;
display: none !important;
border: 0 !important;
}
</style>