From dc6ab95709c72a4b85e8bce3c8f8deaba262d4da Mon Sep 17 00:00:00 2001 From: qiuchengw Date: Tue, 2 Dec 2025 19:44:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=8F=AF=E8=A7=86=E5=8C=96ta?= =?UTF-8?q?sk=E8=BF=9E=E6=8E=A5=E7=BA=BF=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LinkAnchor.vue | 254 ++++++++++++++++++ src/components/LinkDragGuide.vue | 254 ++++++++++++++++++ src/components/TaskBar.vue | 91 ++++++- src/components/Timeline.vue | 439 ++++++++++++++++++++++++++++++- 4 files changed, 1031 insertions(+), 7 deletions(-) create mode 100644 src/components/LinkAnchor.vue create mode 100644 src/components/LinkDragGuide.vue diff --git a/src/components/LinkAnchor.vue b/src/components/LinkAnchor.vue new file mode 100644 index 0000000..00941d1 --- /dev/null +++ b/src/components/LinkAnchor.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/src/components/LinkDragGuide.vue b/src/components/LinkDragGuide.vue new file mode 100644 index 0000000..18eb0f8 --- /dev/null +++ b/src/components/LinkDragGuide.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/src/components/TaskBar.vue b/src/components/TaskBar.vue index a0e9264..83a7208 100644 --- a/src/components/TaskBar.vue +++ b/src/components/TaskBar.vue @@ -4,6 +4,7 @@ import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots } from import type { Task } from '../models/classes/Task' import { TimelineScale } from '../models/types/TimelineScale' import TaskContextMenu from './TaskContextMenu.vue' +import LinkAnchor from './LinkAnchor.vue' import { useI18n } from '../composables/useI18n' import type { TaskBarConfig } from '../models/configs/TaskBarConfig' @@ -35,6 +36,14 @@ interface Props { isPrimaryHighlight?: boolean // 是否处于高亮模式(有任务被高亮) isInHighlightMode?: boolean + // 连接线拖拽模式:'predecessor' | 'successor' | null + dragLinkMode?: 'predecessor' | 'successor' | null + // 是否是连接线拖拽的起始任务 + isLinkDragSource?: boolean + // 是否是有效的连接目标 + isValidLinkTarget?: boolean + // 是否是无效的连接目标 + isInvalidLinkTarget?: boolean } interface TaskStatus { @@ -73,6 +82,9 @@ const emit = defineEmits([ 'delete', 'context-menu', 'long-press', + 'link-drag-start', + 'link-drag-move', + 'link-drag-end', ]) defineSlots<{ @@ -198,6 +210,9 @@ const longPressTimer = ref(null) const longPressTriggered = ref(false) const LONG_PRESS_DURATION = 1000 // 1秒(缩短了) +// TaskBar 悬停状态(用于显示 LinkAnchor) +const isTaskBarHovered = ref(false) + // 防误触配置 - 使用配置项或默认值 const dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5) const isDragThresholdMet = ref(false) // 是否达到拖拽阈值 @@ -2227,7 +2242,48 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => { closeContextMenu() } -// 监听全局关闭菜单事件 +// 连接线触点事件处理 +const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'successor'; mouseEvent: MouseEvent }) => { + emit('link-drag-start', event) +} + +const handleLinkDragMove = (event: { mouseX: number; mouseY: number }) => { + emit('link-drag-move', event) +} + +const handleLinkDragEnd = (event: { task: Task; type: 'predecessor' | 'successor' }) => { + emit('link-drag-end', event) +} + +// 处理 LinkAnchor 的 drag-start 事件(转换为统一格式) +const handleAnchorDragStart = (anchorEvent: { taskId: number; type: 'predecessor' | 'successor'; x: number; y: number }) => { + const mouseEvent = { + clientX: anchorEvent.x, + clientY: anchorEvent.y, + } as MouseEvent + + handleLinkDragStart({ + task: props.task, + type: anchorEvent.type, + mouseEvent, + }) +} + +// 处理 LinkAnchor 的 drag-move 事件 +const handleAnchorDragMove = (anchorEvent: { x: number; y: number }) => { + handleLinkDragMove({ + mouseX: anchorEvent.x, + mouseY: anchorEvent.y, + }) +} + +// 处理 LinkAnchor 的 drag-end 事件 +const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor' | 'successor' }) => { + handleLinkDragEnd({ + task: props.task, + type: anchorEvent.type, + }) +}// 监听全局关闭菜单事件 onMounted(() => { window.addEventListener('close-all-taskbar-menus', closeContextMenu) }) @@ -2246,6 +2302,7 @@ onUnmounted(() => {
@@ -2382,6 +2441,36 @@ onUnmounted(() => { @mousedown="e => handleMouseDown(e, 'resize-right')" >
+ + + + + +
{ } } +// ==================== 连接线拖拽状态管理 ==================== +const dragLinkMode = ref<'predecessor' | 'successor' | null>(null) // 当前拖拽模式 +const linkDragSourceTask = ref(null) // 拖拽起始任务 +const linkDragCurrentX = ref(0) // 当前鼠标X坐标 +const linkDragCurrentY = ref(0) // 当前鼠标Y坐标 +const linkDragTargetTask = ref(null) // 当前悬停的目标任务 +const isValidLinkTarget = ref(false) // 是否是有效的连接目标 +const linkAutoScrollInterval = ref(null) // 自动滚动定时器 + +// 开始连接线拖拽 +const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'successor'; mouseEvent: MouseEvent }) => { + dragLinkMode.value = event.type + linkDragSourceTask.value = event.task + + // 将视口坐标转换为相对于 bodyContent 的坐标 + if (!bodyContentRef.value) { + linkDragCurrentX.value = event.mouseEvent.clientX + linkDragCurrentY.value = event.mouseEvent.clientY + } else { + const baseRect = bodyContentRef.value.getBoundingClientRect() + linkDragCurrentX.value = event.mouseEvent.clientX - baseRect.left + linkDragCurrentY.value = event.mouseEvent.clientY - baseRect.top + } + + linkDragTargetTask.value = null + isValidLinkTarget.value = false + + // 启动自动滚动检测 + startLinkAutoScroll() + + // 添加 ESC 键监听,允许取消拖拽 + document.addEventListener('keydown', handleLinkDragEscape) + + // 在 Timeline 层级添加全局鼠标监听器(防止 LinkAnchor 因虚拟滚动卸载导致拖拽中断) + document.addEventListener('mousemove', handleGlobalMouseMove) + document.addEventListener('mouseup', handleGlobalMouseUp) +} + +// 使用 requestAnimationFrame 节流鼠标移动处理 +let mouseMoveRafId: number | null = null +let lastMouseX = 0 +let lastMouseY = 0 + +// 全局鼠标移动处理 +const handleGlobalMouseMove = (e: MouseEvent) => { + if (!dragLinkMode.value) return + + // 保存最新的鼠标位置 + lastMouseX = e.clientX + lastMouseY = e.clientY + + // 如果已经有待处理的更新,直接返回 + if (mouseMoveRafId !== null) return + + // 使用 requestAnimationFrame 节流 + mouseMoveRafId = requestAnimationFrame(() => { + mouseMoveRafId = null + handleLinkDragMove({ + mouseX: lastMouseX, + mouseY: lastMouseY, + }) + }) +} + +// 全局鼠标释放处理 +const handleGlobalMouseUp = () => { + if (!dragLinkMode.value) return + + // 取消待处理的 mousemove + if (mouseMoveRafId !== null) { + cancelAnimationFrame(mouseMoveRafId) + mouseMoveRafId = null + } + + // 触发拖拽结束 + if (linkDragSourceTask.value) { + handleLinkDragEnd({ + task: linkDragSourceTask.value, + type: dragLinkMode.value, + }) + } +} + +// 处理 ESC 键取消拖拽 +const handleLinkDragEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && dragLinkMode.value) { + cleanupLinkDrag() + document.removeEventListener('keydown', handleLinkDragEscape) + } +} + +// 缓存 bodyContent 的位置信息(避免频繁调用 getBoundingClientRect) +let cachedBodyRect: DOMRect | null = null +let bodyRectCacheTime = 0 +const BODY_RECT_CACHE_DURATION = 50 // 50ms 缓存 + +// 拖拽过程中更新鼠标位置 +const handleLinkDragMove = (event: { mouseX: number; mouseY: number }) => { + // 将视口坐标转换为相对于 bodyContent 的坐标 + if (!bodyContentRef.value) { + linkDragCurrentX.value = event.mouseX + linkDragCurrentY.value = event.mouseY + } else { + // 使用缓存的 rect 或获取新的 + const now = Date.now() + if (!cachedBodyRect || now - bodyRectCacheTime > BODY_RECT_CACHE_DURATION) { + cachedBodyRect = bodyContentRef.value.getBoundingClientRect() + bodyRectCacheTime = now + } + linkDragCurrentX.value = event.mouseX - cachedBodyRect.left + linkDragCurrentY.value = event.mouseY - cachedBodyRect.top + } + + // 检测目标任务(使用原始视口坐标) + detectLinkTarget(event.mouseX, event.mouseY) +} + +// 结束连接线拖拽 +const handleLinkDragEnd = (event: { task: Task; type: 'predecessor' | 'successor' }) => { + // 清除缓存的 rect + cachedBodyRect = null + + // 移除 ESC 键监听 + document.removeEventListener('keydown', handleLinkDragEscape) // 移除全局鼠标监听器 + document.removeEventListener('mousemove', handleGlobalMouseMove) + document.removeEventListener('mouseup', handleGlobalMouseUp) + + // 停止自动滚动 + stopLinkAutoScroll() // 如果有有效目标,创建连接 + if (linkDragTargetTask.value && isValidLinkTarget.value) { + createLink(event.task, linkDragTargetTask.value, event.type) + } + + // 重置拖拽状态 + dragLinkMode.value = null + linkDragSourceTask.value = null + linkDragTargetTask.value = null + isValidLinkTarget.value = false +} + +// 检测鼠标位置下的目标任务 +const detectLinkTarget = (mouseX: number, mouseY: number) => { + if (!linkDragSourceTask.value) return + + // 使用 elementFromPoint 直接获取鼠标下的元素(性能更好) + const element = document.elementFromPoint(mouseX, mouseY) + let foundTarget: Task | null = null + + if (element) { + // 向上查找最近的 .task-bar 元素 + const taskBar = element.closest('.task-bar') as HTMLElement + if (taskBar && taskBar.dataset.taskId) { + const taskId = parseInt(taskBar.dataset.taskId) + // 只有当目标任务变化时才查找(避免重复的 find 操作) + if (!foundTarget || foundTarget.id !== taskId) { + foundTarget = tasks.value.find(t => t.id === taskId) || null + } + } + } + + // 只有当目标任务真正变化时才更新和验证 + if (foundTarget?.id !== linkDragTargetTask.value?.id) { + linkDragTargetTask.value = foundTarget + + // 验证连接的有效性 + if (foundTarget && linkDragSourceTask.value) { + isValidLinkTarget.value = validateLink( + linkDragSourceTask.value, + foundTarget, + dragLinkMode.value! + ) + } else { + isValidLinkTarget.value = false + } + } +} +// 验证连接是否有效 +const validateLink = (sourceTask: Task, targetTask: Task, mode: 'predecessor' | 'successor'): boolean => { + // 1. 不能连接到自己 + if (sourceTask.id === targetTask.id) { + return false + } + + // 2. 不能连接父级任务或里程碑 + if (targetTask.isParent || targetTask.type === 'milestone') { + return false + } + + // 3. 不能创建循环依赖 + if (mode === 'predecessor') { + // 如果目标任务已经依赖源任务(直接或间接),会形成循环 + if (hasCircularDependency(targetTask.id, sourceTask.id)) { + return false + } + } else { + // successor模式:源任务不能已经依赖目标任务 + if (hasCircularDependency(sourceTask.id, targetTask.id)) { + return false + } + } + + // 4. 检查是否已存在该连接 + if (mode === 'predecessor') { + // 检查目标任务的前置任务列表 + if (targetTask.predecessor) { + const predecessorIds = getPredecessorIds(targetTask.predecessor) + if (predecessorIds.includes(sourceTask.id)) { + return false // 已存在 + } + } + } else { + // successor模式:检查源任务的前置任务列表 + if (sourceTask.predecessor) { + const predecessorIds = getPredecessorIds(sourceTask.predecessor) + if (predecessorIds.includes(targetTask.id)) { + return false // 已存在 + } + } + } + + return true +} + +// 检查循环依赖 +const hasCircularDependency = (taskId: number, targetId: number): boolean => { + const visited = new Set() + const queue: number[] = [taskId] + + while (queue.length > 0) { + const currentId = queue.shift()! + if (currentId === targetId) { + return true // 找到循环依赖 + } + + if (visited.has(currentId)) { + continue + } + visited.add(currentId) + + // 查找当前任务 + const currentTask = tasks.value.find(t => t.id === currentId) + if (currentTask && currentTask.predecessor) { + const predecessorIds = getPredecessorIds(currentTask.predecessor) + queue.push(...predecessorIds) + } + } + + return false +} + +// 创建连接 +const createLink = (sourceTask: Task, targetTask: Task, mode: 'predecessor' | 'successor') => { + if (mode === 'predecessor') { + // 前置模式:将源任务添加到目标任务的前置任务列表 + const predecessorIds = targetTask.predecessor ? getPredecessorIds(targetTask.predecessor) : [] + if (!predecessorIds.includes(sourceTask.id)) { + predecessorIds.push(sourceTask.id) + // 将数组转换为适当的格式(数组或逗号分隔的字符串) + if (Array.isArray(targetTask.predecessor)) { + targetTask.predecessor = predecessorIds + } else { + targetTask.predecessor = predecessorIds.join(',') + } + // 触发任务更新事件 + updateTask(targetTask) + } + } else { + // 后置模式:将目标任务的 ID 添加到源任务的前置任务列表 + // 注意:这里实际是在源任务的 predecessor 中添加目标任务 + // 因为"后置任务"的意思是:目标任务依赖于源任务 + const predecessorIds = targetTask.predecessor ? getPredecessorIds(targetTask.predecessor) : [] + if (!predecessorIds.includes(sourceTask.id)) { + predecessorIds.push(sourceTask.id) + // 将数组转换为适当的格式 + if (Array.isArray(targetTask.predecessor)) { + targetTask.predecessor = predecessorIds + } else { + targetTask.predecessor = predecessorIds.join(',') + } + // 触发任务更新事件 + updateTask(targetTask) + } + } +} + +// 启动自动滚动检测 +const startLinkAutoScroll = () => { + linkAutoScrollInterval.value = window.setInterval(() => { + if (!timelineContainerElement.value || !bodyContentRef.value) return + + const horizontalContainer = timelineContainerElement.value // .timeline 负责横向滚动 + const verticalContainer = timelineBodyElement.value // .timeline-body 负责纵向滚动 + + if (!verticalContainer) return + + const bodyContent = bodyContentRef.value + const rect = horizontalContainer.getBoundingClientRect() + const SCROLL_ZONE = 80 // 边缘滚动区域宽度(增大以提供更好的体验) + const SCROLL_SPEED = 15 // 滚动速度 + + // 获取鼠标在视口中的实际位置(需要加上 bodyContent 的偏移) + const bodyRect = bodyContent.getBoundingClientRect() + const mouseX = linkDragCurrentX.value + bodyRect.left + const mouseY = linkDragCurrentY.value + bodyRect.top + + let scrolled = false + + // 检测水平滚动(使用 horizontalContainer) + if (mouseX < rect.left + SCROLL_ZONE && horizontalContainer.scrollLeft > 0) { + // 向左滚动 + horizontalContainer.scrollLeft -= SCROLL_SPEED + scrolled = true + } else if (mouseX > rect.right - SCROLL_ZONE) { + // 向右滚动 + const maxScrollLeft = horizontalContainer.scrollWidth - horizontalContainer.clientWidth + if (horizontalContainer.scrollLeft < maxScrollLeft) { + horizontalContainer.scrollLeft += SCROLL_SPEED + scrolled = true + } + } + + // 检测垂直滚动(使用 verticalContainer) + if (mouseY < rect.top + SCROLL_ZONE && verticalContainer.scrollTop > 0) { + // 向上滚动 + verticalContainer.scrollTop -= SCROLL_SPEED + scrolled = true + } else if (mouseY > rect.bottom - SCROLL_ZONE) { + // 向下滚动 + const maxScrollTop = verticalContainer.scrollHeight - verticalContainer.clientHeight + if (verticalContainer.scrollTop < maxScrollTop) { + verticalContainer.scrollTop += SCROLL_SPEED + scrolled = true + } + } + + // 如果发生了滚动,需要更新鼠标相对坐标 + if (scrolled) { + // 触发重新检测目标(因为滚动可能改变了元素位置) + detectLinkTarget(mouseX, mouseY) + } + }, 30) // 降低间隔以提供更流畅的滚动体验 +} + +// 停止自动滚动 +const stopLinkAutoScroll = () => { + if (linkAutoScrollInterval.value !== null) { + clearInterval(linkAutoScrollInterval.value) + linkAutoScrollInterval.value = null + } +} + +// 清理连接线拖拽状态(用于异常情况) +const cleanupLinkDrag = () => { + stopLinkAutoScroll() + + // 清除缓存 + cachedBodyRect = null + + // 取消待处理的 mousemove + if (mouseMoveRafId !== null) { + cancelAnimationFrame(mouseMoveRafId) + mouseMoveRafId = null + } + + // 移除全局监听器 + document.removeEventListener('keydown', handleLinkDragEscape) + document.removeEventListener('mousemove', handleGlobalMouseMove) + document.removeEventListener('mouseup', handleGlobalMouseUp) + + dragLinkMode.value = null + linkDragSourceTask.value = null + linkDragTargetTask.value = null + isValidLinkTarget.value = false +} + +// 获取连接线拖拽起始点的X坐标 +const getLinkDragStartX = (): number => { + if (!linkDragSourceTask.value) return 0 + + const position = taskBarPositions.value[linkDragSourceTask.value.id] + if (!position) return 0 + + // 根据拖拽模式决定起始点位置 + if (dragLinkMode.value === 'predecessor') { + // 前置任务:从任务条左侧开始 + return position.left + } else { + // 后置任务:从任务条右侧开始 + return position.left + position.width + } +} + +// 获取连接线拖拽起始点的Y坐标 +const getLinkDragStartY = (): number => { + if (!linkDragSourceTask.value) return 0 + + const position = taskBarPositions.value[linkDragSourceTask.value.id] + if (!position) return 0 + + // 从任务条中心位置开始 + return position.top + position.height / 2 +} + // 拖拽状态管理 const isSplitterDragging = ref(false) @@ -1997,6 +2401,7 @@ function updateSvgSize() { // 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取 // 因为 handleTimelineScroll 已经实时更新了这个值 const scrollLeft = timelineScrollLeft.value + const scrollTop = timelineBodyScrollTop.value // 虚拟渲染策略(统一模式): // Canvas 始终使用固定安全宽度,通过 offsetLeft 动态定位 @@ -2024,7 +2429,6 @@ function updateSvgSize() { svgWidth.value = canvasWidth.value svgHeight.value = clampedHeight - const scrollTop = timelineBodyScrollTop.value const bufferTop = clampedHeight / 3 let idealOffsetTop = Math.max(0, scrollTop - bufferTop) @@ -2037,9 +2441,7 @@ function updateSvgSize() { canvasOffsetTop.value = idealOffsetTop } -} - -function handleBarMounted(payload: { +}function handleBarMounted(payload: { id: number left: number top: number @@ -2597,6 +2999,9 @@ onUnmounted(() => { // 停止自动滚动 stopAutoScroll() + // 清理连接线拖拽状态 + cleanupLinkDrag() + // 清理事件监听器 window.removeEventListener('task-row-double-click', handleTaskListDoubleClick as EventListener) window.removeEventListener('task-list-hover', handleTaskListHover as EventListener) @@ -3411,6 +3816,21 @@ const handleAddSuccessor = (task: Task) => { :show-vertical-lines="currentTimeScale === TimelineScale.WEEK" /> + + +
{ :is-highlighted="highlightedTaskIds.has(task.id)" :is-primary-highlight="highlightedTaskId === task.id" :is-in-highlight-mode="isInHighlightMode" + :drag-link-mode="dragLinkMode" + :is-link-drag-source="linkDragSourceTask?.id === task.id" + :is-valid-link-target="linkDragTargetTask?.id === task.id && isValidLinkTarget" + :is-invalid-link-target="linkDragTargetTask?.id === task.id && !isValidLinkTarget" @update:task="updateTask" @bar-mounted="handleBarMounted" @click="handleTaskBarClick(task, $event)" @@ -3707,6 +4131,9 @@ const handleAddSuccessor = (task: Task) => { @add-successor="handleAddSuccessor" @delete="handleTaskDelete" @long-press="setHighlightTask" + @link-drag-start="handleLinkDragStart" + @link-drag-move="handleLinkDragMove" + @link-drag-end="handleLinkDragEnd" >