实现可视化task连接线操作
This commit is contained in:
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// 触点类型
|
||||||
|
type: 'predecessor' | 'successor' // left触点=前置, right触点=后置
|
||||||
|
taskId: number
|
||||||
|
// 显示控制
|
||||||
|
visible?: boolean // 是否显示(默认在 hover 时显示)
|
||||||
|
// 拖拽状态
|
||||||
|
isDragSource?: boolean // 是否是拖拽源
|
||||||
|
isDragTarget?: boolean // 是否是拖拽目标
|
||||||
|
isValidTarget?: boolean // 是否是合法目标
|
||||||
|
// 功能开关
|
||||||
|
enabled?: boolean // 是否启用连接功能(默认 true)
|
||||||
|
// 全局拖拽状态(用于优化显示逻辑)
|
||||||
|
globalDragging?: boolean // 是否有全局拖拽进行中
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
visible: false,
|
||||||
|
isDragSource: false,
|
||||||
|
isDragTarget: false,
|
||||||
|
isValidTarget: true,
|
||||||
|
enabled: true,
|
||||||
|
globalDragging: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'drag-start': [{ taskId: number; type: 'predecessor' | 'successor'; x: number; y: number }]
|
||||||
|
'drag-move': [{ x: number; y: number }]
|
||||||
|
'drag-end': [{ taskId: number; type: 'predecessor' | 'successor' }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 触点自身悬停状态
|
||||||
|
const isHoveredAnchor = ref(false)
|
||||||
|
|
||||||
|
// 优化的显示逻辑
|
||||||
|
const shouldShow = computed(() => {
|
||||||
|
return (
|
||||||
|
props.visible || // TaskBar 悬停
|
||||||
|
props.globalDragging || // 全局拖拽进行中
|
||||||
|
props.isDragSource || // 是拖拽的源任务
|
||||||
|
isHoveredAnchor.value // 触点本身被悬停
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全局拖拽监听器管理(组件自治)
|
||||||
|
let globalMouseMoveListener: ((e: MouseEvent) => void) | null = null
|
||||||
|
let globalMouseUpListener: ((e: MouseEvent) => void) | null = null
|
||||||
|
let isDragging = false
|
||||||
|
|
||||||
|
// 开始拖拽
|
||||||
|
function handleMouseDown(event: MouseEvent) {
|
||||||
|
if (!props.enabled) return
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
isDragging = true
|
||||||
|
|
||||||
|
// 发射拖拽开始事件
|
||||||
|
emit('drag-start', {
|
||||||
|
taskId: props.taskId,
|
||||||
|
type: props.type,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加全局监听器(组件自治)
|
||||||
|
globalMouseMoveListener = (e: MouseEvent) => {
|
||||||
|
if (!isDragging) return
|
||||||
|
// 发射拖拽移动事件
|
||||||
|
emit('drag-move', {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
globalMouseUpListener = () => {
|
||||||
|
if (!isDragging) return
|
||||||
|
// 发射拖拽结束事件
|
||||||
|
emit('drag-end', {
|
||||||
|
taskId: props.taskId,
|
||||||
|
type: props.type,
|
||||||
|
})
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', globalMouseMoveListener)
|
||||||
|
document.addEventListener('mouseup', globalMouseUpListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
if (!props.enabled) return
|
||||||
|
isHoveredAnchor.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
if (!props.enabled) return
|
||||||
|
isHoveredAnchor.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理全局监听器
|
||||||
|
function cleanup() {
|
||||||
|
isDragging = false
|
||||||
|
if (globalMouseMoveListener) {
|
||||||
|
document.removeEventListener('mousemove', globalMouseMoveListener)
|
||||||
|
globalMouseMoveListener = null
|
||||||
|
}
|
||||||
|
if (globalMouseUpListener) {
|
||||||
|
document.removeEventListener('mouseup', globalMouseUpListener)
|
||||||
|
globalMouseUpListener = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="enabled && shouldShow"
|
||||||
|
class="link-anchor"
|
||||||
|
:class="{
|
||||||
|
'anchor-predecessor': type === 'predecessor',
|
||||||
|
'anchor-successor': type === 'successor',
|
||||||
|
'drag-source': isDragSource,
|
||||||
|
'drag-target': isDragTarget,
|
||||||
|
'valid-target': isDragTarget && isValidTarget,
|
||||||
|
'invalid-target': isDragTarget && !isValidTarget,
|
||||||
|
'visible': shouldShow,
|
||||||
|
}"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
>
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<div v-if="!isDragSource && !isDragTarget && !globalDragging" class="anchor-tooltip">
|
||||||
|
{{ type === 'predecessor' ? '添加前置任务' : '添加后置任务' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-anchor {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #409eff;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
z-index: 200;
|
||||||
|
/* 默认状态:垂直居中 */
|
||||||
|
transform: translateY(-50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 前置任务触点(左侧) */
|
||||||
|
.link-anchor.anchor-predecessor {
|
||||||
|
left: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 后置任务触点(右侧) */
|
||||||
|
.link-anchor.anchor-successor {
|
||||||
|
right: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当应该显示时,设置可见 */
|
||||||
|
.link-anchor.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-anchor:hover {
|
||||||
|
/* 保持垂直居中的同时放大 */
|
||||||
|
transform: translateY(-50%) scale(1.3);
|
||||||
|
box-shadow: 0 0 8px rgba(64, 158, 255, 0.6);
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-anchor.drag-source {
|
||||||
|
opacity: 1;
|
||||||
|
/* 拖拽源也保持垂直居中 */
|
||||||
|
transform: translateY(-50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-anchor.drag-target {
|
||||||
|
opacity: 1;
|
||||||
|
/* 拖拽目标:保持垂直居中并放大 */
|
||||||
|
transform: translateY(-50%) scale(1.5);
|
||||||
|
animation: pulse 0.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-anchor.valid-target {
|
||||||
|
background: #67c23a;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-anchor.invalid-target {
|
||||||
|
background: #f56c6c;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-4px);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-anchor:hover .anchor-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 8px rgba(64, 158, 255, 0.6);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 16px rgba(64, 158, 255, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色主题支持 */
|
||||||
|
:global(html[data-theme='dark']) .link-anchor {
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='dark']) .link-anchor.valid-target,
|
||||||
|
:global(html[data-theme='dark']) .link-anchor.invalid-target {
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='dark']) .anchor-tooltip {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: boolean
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
endX: number
|
||||||
|
endY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
offsetLeft?: number
|
||||||
|
offsetTop?: number
|
||||||
|
isValidTarget?: boolean // 是否是合法的连接目标
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
offsetLeft: 0,
|
||||||
|
offsetTop: 0,
|
||||||
|
isValidTarget: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 节流重绘
|
||||||
|
let rafId: number | null = null
|
||||||
|
let pendingDraw = false
|
||||||
|
|
||||||
|
// 缓存 canvas 上下文和尺寸信息,避免重复初始化
|
||||||
|
let cachedCtx: CanvasRenderingContext2D | null = null
|
||||||
|
let cachedWidth = 0
|
||||||
|
let cachedHeight = 0
|
||||||
|
let cachedDpr = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化或更新 canvas 尺寸
|
||||||
|
*/
|
||||||
|
const initCanvas = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return null
|
||||||
|
|
||||||
|
const displayWidth = props.width
|
||||||
|
const displayHeight = props.height
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
|
||||||
|
// 只在尺寸或 DPR 变化时重新初始化
|
||||||
|
if (
|
||||||
|
!cachedCtx ||
|
||||||
|
cachedWidth !== displayWidth ||
|
||||||
|
cachedHeight !== displayHeight ||
|
||||||
|
cachedDpr !== dpr
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false })
|
||||||
|
if (!ctx) return null
|
||||||
|
|
||||||
|
const pixelWidth = displayWidth * dpr
|
||||||
|
const pixelHeight = displayHeight * dpr
|
||||||
|
|
||||||
|
canvas.width = pixelWidth
|
||||||
|
canvas.height = pixelHeight
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
|
||||||
|
cachedCtx = ctx
|
||||||
|
cachedWidth = displayWidth
|
||||||
|
cachedHeight = displayHeight
|
||||||
|
cachedDpr = dpr
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制拖拽引导线
|
||||||
|
* 使用贝塞尔曲线,与 GanttLinks 保持一致的视觉风格
|
||||||
|
*/
|
||||||
|
const drawGuideLine = () => {
|
||||||
|
// 如果已经有待处理的绘制请求,取消标记
|
||||||
|
if (rafId !== null) {
|
||||||
|
pendingDraw = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 确保在下一帧绘制
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = null
|
||||||
|
performDraw()
|
||||||
|
|
||||||
|
// 如果在绘制期间又有新的请求,再次绘制
|
||||||
|
if (pendingDraw) {
|
||||||
|
pendingDraw = false
|
||||||
|
drawGuideLine()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const performDraw = () => {
|
||||||
|
if (!props.active) return
|
||||||
|
|
||||||
|
const ctx = initCanvas()
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const displayWidth = cachedWidth
|
||||||
|
const displayHeight = cachedHeight
|
||||||
|
|
||||||
|
// 清空画布(使用缓存的尺寸)
|
||||||
|
ctx.clearRect(0, 0, displayWidth, displayHeight)
|
||||||
|
|
||||||
|
// 转换为 Canvas 局部坐标
|
||||||
|
const localX1 = props.startX - props.offsetLeft
|
||||||
|
const localY1 = props.startY - props.offsetTop
|
||||||
|
const localX2 = props.endX - props.offsetLeft
|
||||||
|
const localY2 = props.endY - props.offsetTop
|
||||||
|
|
||||||
|
// 检查坐标是否在 Canvas 范围内(增加缓冲区以允许部分可见)
|
||||||
|
const buffer = 100
|
||||||
|
const isInBounds =
|
||||||
|
(localX1 >= -buffer || localX2 >= -buffer) &&
|
||||||
|
(localX1 <= displayWidth + buffer || localX2 <= displayWidth + buffer) &&
|
||||||
|
(localY1 >= -buffer || localY2 >= -buffer) &&
|
||||||
|
(localY1 <= displayHeight + buffer || localY2 <= displayHeight + buffer)
|
||||||
|
|
||||||
|
if (!isInBounds) return
|
||||||
|
|
||||||
|
// 贝塞尔曲线控制点(与 GanttLinks 一致)
|
||||||
|
const c1x = localX1 + 40
|
||||||
|
const c1y = localY1
|
||||||
|
const c2x = localX2 - 40
|
||||||
|
const c2y = localY2
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
|
||||||
|
// 根据是否是合法目标设置颜色
|
||||||
|
const color = props.isValidTarget ? '#67c23a' : '#f56c6c'
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.setLineDash([8, 4])
|
||||||
|
ctx.globalAlpha = 0.8
|
||||||
|
|
||||||
|
// 绘制贝塞尔曲线
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(localX1, localY1)
|
||||||
|
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, localX2, localY2)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// 绘制箭头
|
||||||
|
const arrowAngle = Math.atan2(localY2 - c2y, localX2 - c2x)
|
||||||
|
const arrowLength = 8
|
||||||
|
const arrowWidth = 4
|
||||||
|
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(localX2, localY2)
|
||||||
|
ctx.lineTo(
|
||||||
|
localX2 - arrowLength * Math.cos(arrowAngle) - arrowWidth * Math.sin(arrowAngle),
|
||||||
|
localY2 - arrowLength * Math.sin(arrowAngle) + arrowWidth * Math.cos(arrowAngle),
|
||||||
|
)
|
||||||
|
ctx.lineTo(
|
||||||
|
localX2 - arrowLength * Math.cos(arrowAngle) + arrowWidth * Math.sin(arrowAngle),
|
||||||
|
localY2 - arrowLength * Math.sin(arrowAngle) - arrowWidth * Math.cos(arrowAngle),
|
||||||
|
)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空画布
|
||||||
|
*/
|
||||||
|
const clearCanvas = () => {
|
||||||
|
const ctx = cachedCtx
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, cachedWidth, cachedHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载时清除缓存
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
cachedCtx = null
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => props.active,
|
||||||
|
() => props.startX,
|
||||||
|
() => props.startY,
|
||||||
|
() => props.endX,
|
||||||
|
() => props.endY,
|
||||||
|
() => props.isValidTarget,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
if (props.active) {
|
||||||
|
drawGuideLine()
|
||||||
|
} else {
|
||||||
|
clearCanvas()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听尺寸变化,需要重新初始化 canvas(尺寸变化频率低)
|
||||||
|
watch(
|
||||||
|
[() => props.width, () => props.height],
|
||||||
|
() => {
|
||||||
|
// 清除缓存,强制重新初始化
|
||||||
|
cachedCtx = null
|
||||||
|
if (props.active) {
|
||||||
|
drawGuideLine()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.active) {
|
||||||
|
drawGuideLine()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时取消待处理的动画帧
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas
|
||||||
|
v-if="active"
|
||||||
|
ref="canvasRef"
|
||||||
|
class="link-drag-guide-canvas"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${offsetLeft}px`,
|
||||||
|
top: `${offsetTop}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
zIndex: 1002,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-drag-guide-canvas {
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots } from
|
|||||||
import type { Task } from '../models/classes/Task'
|
import type { Task } from '../models/classes/Task'
|
||||||
import { TimelineScale } from '../models/types/TimelineScale'
|
import { TimelineScale } from '../models/types/TimelineScale'
|
||||||
import TaskContextMenu from './TaskContextMenu.vue'
|
import TaskContextMenu from './TaskContextMenu.vue'
|
||||||
|
import LinkAnchor from './LinkAnchor.vue'
|
||||||
|
|
||||||
import { useI18n } from '../composables/useI18n'
|
import { useI18n } from '../composables/useI18n'
|
||||||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||||
@@ -35,6 +36,14 @@ interface Props {
|
|||||||
isPrimaryHighlight?: boolean
|
isPrimaryHighlight?: boolean
|
||||||
// 是否处于高亮模式(有任务被高亮)
|
// 是否处于高亮模式(有任务被高亮)
|
||||||
isInHighlightMode?: boolean
|
isInHighlightMode?: boolean
|
||||||
|
// 连接线拖拽模式:'predecessor' | 'successor' | null
|
||||||
|
dragLinkMode?: 'predecessor' | 'successor' | null
|
||||||
|
// 是否是连接线拖拽的起始任务
|
||||||
|
isLinkDragSource?: boolean
|
||||||
|
// 是否是有效的连接目标
|
||||||
|
isValidLinkTarget?: boolean
|
||||||
|
// 是否是无效的连接目标
|
||||||
|
isInvalidLinkTarget?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskStatus {
|
interface TaskStatus {
|
||||||
@@ -73,6 +82,9 @@ const emit = defineEmits([
|
|||||||
'delete',
|
'delete',
|
||||||
'context-menu',
|
'context-menu',
|
||||||
'long-press',
|
'long-press',
|
||||||
|
'link-drag-start',
|
||||||
|
'link-drag-move',
|
||||||
|
'link-drag-end',
|
||||||
])
|
])
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
@@ -198,6 +210,9 @@ const longPressTimer = ref<number | null>(null)
|
|||||||
const longPressTriggered = ref(false)
|
const longPressTriggered = ref(false)
|
||||||
const LONG_PRESS_DURATION = 1000 // 1秒(缩短了)
|
const LONG_PRESS_DURATION = 1000 // 1秒(缩短了)
|
||||||
|
|
||||||
|
// TaskBar 悬停状态(用于显示 LinkAnchor)
|
||||||
|
const isTaskBarHovered = ref(false)
|
||||||
|
|
||||||
// 防误触配置 - 使用配置项或默认值
|
// 防误触配置 - 使用配置项或默认值
|
||||||
const dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5)
|
const dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5)
|
||||||
const isDragThresholdMet = ref(false) // 是否达到拖拽阈值
|
const isDragThresholdMet = ref(false) // 是否达到拖拽阈值
|
||||||
@@ -2227,7 +2242,48 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
|||||||
closeContextMenu()
|
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(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||||
})
|
})
|
||||||
@@ -2246,6 +2302,7 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
ref="barRef"
|
ref="barRef"
|
||||||
class="task-bar"
|
class="task-bar"
|
||||||
|
:data-task-id="task.id"
|
||||||
:style="{
|
:style="{
|
||||||
...taskBarStyle,
|
...taskBarStyle,
|
||||||
backgroundColor: taskStatus.bgColor,
|
backgroundColor: taskStatus.bgColor,
|
||||||
@@ -2270,6 +2327,8 @@ onUnmounted(() => {
|
|||||||
@click="handleTaskBarClick"
|
@click="handleTaskBarClick"
|
||||||
@contextmenu="handleContextMenu"
|
@contextmenu="handleContextMenu"
|
||||||
@dblclick="handleTaskBarDoubleClick"
|
@dblclick="handleTaskBarDoubleClick"
|
||||||
|
@mouseenter="isTaskBarHovered = true"
|
||||||
|
@mouseleave="isTaskBarHovered = false"
|
||||||
>
|
>
|
||||||
<!-- 父级任务的标签 -->
|
<!-- 父级任务的标签 -->
|
||||||
<div v-if="isParent" class="parent-label">
|
<div v-if="isParent" class="parent-label">
|
||||||
@@ -2382,6 +2441,36 @@ onUnmounted(() => {
|
|||||||
@mousedown="e => handleMouseDown(e, 'resize-right')"
|
@mousedown="e => handleMouseDown(e, 'resize-right')"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
<!-- 连接线触点 - 只在非高亮模式且非父级任务时显示 -->
|
||||||
|
<!-- 前置任务触点(左侧) -->
|
||||||
|
<LinkAnchor
|
||||||
|
v-if="!isParent && !isInHighlightMode"
|
||||||
|
type="predecessor"
|
||||||
|
:task-id="task.id"
|
||||||
|
:visible="isTaskBarHovered"
|
||||||
|
:is-drag-source="isLinkDragSource && dragLinkMode === 'predecessor'"
|
||||||
|
:is-drag-target="isValidLinkTarget || isInvalidLinkTarget"
|
||||||
|
:is-valid-target="isValidLinkTarget"
|
||||||
|
:global-dragging="!!dragLinkMode"
|
||||||
|
@drag-start="handleAnchorDragStart"
|
||||||
|
@drag-move="handleAnchorDragMove"
|
||||||
|
@drag-end="handleAnchorDragEnd"
|
||||||
|
/>
|
||||||
|
<!-- 后置任务触点(右侧) -->
|
||||||
|
<LinkAnchor
|
||||||
|
v-if="!isParent && !isInHighlightMode"
|
||||||
|
type="successor"
|
||||||
|
:task-id="task.id"
|
||||||
|
:visible="isTaskBarHovered"
|
||||||
|
:is-drag-source="isLinkDragSource && dragLinkMode === 'successor'"
|
||||||
|
:is-drag-target="isValidLinkTarget || isInvalidLinkTarget"
|
||||||
|
:is-valid-target="isValidLinkTarget"
|
||||||
|
:global-dragging="!!dragLinkMode"
|
||||||
|
@drag-start="handleAnchorDragStart"
|
||||||
|
@drag-move="handleAnchorDragMove"
|
||||||
|
@drag-end="handleAnchorDragEnd"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 半圆气泡指示器 - 只在 TaskBar 完全消失时显示 -->
|
<!-- 半圆气泡指示器 - 只在 TaskBar 完全消失时显示 -->
|
||||||
<div
|
<div
|
||||||
v-if="bubbleIndicator.show && !isParent"
|
v-if="bubbleIndicator.show && !isParent"
|
||||||
|
|||||||
+433
-6
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef } fr
|
|||||||
import TaskBar from './TaskBar.vue'
|
import TaskBar from './TaskBar.vue'
|
||||||
import MilestonePoint from './MilestonePoint.vue'
|
import MilestonePoint from './MilestonePoint.vue'
|
||||||
import GanttLinks from './GanttLinks.vue'
|
import GanttLinks from './GanttLinks.vue'
|
||||||
|
import LinkDragGuide from './LinkDragGuide.vue'
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { useI18n } from '../composables/useI18n'
|
import { useI18n } from '../composables/useI18n'
|
||||||
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
|
||||||
@@ -646,6 +647,409 @@ const setHighlightTask = (taskId: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 连接线拖拽状态管理 ====================
|
||||||
|
const dragLinkMode = ref<'predecessor' | 'successor' | null>(null) // 当前拖拽模式
|
||||||
|
const linkDragSourceTask = ref<Task | null>(null) // 拖拽起始任务
|
||||||
|
const linkDragCurrentX = ref(0) // 当前鼠标X坐标
|
||||||
|
const linkDragCurrentY = ref(0) // 当前鼠标Y坐标
|
||||||
|
const linkDragTargetTask = ref<Task | null>(null) // 当前悬停的目标任务
|
||||||
|
const isValidLinkTarget = ref(false) // 是否是有效的连接目标
|
||||||
|
const linkAutoScrollInterval = ref<number | null>(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<number>()
|
||||||
|
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)
|
const isSplitterDragging = ref(false)
|
||||||
|
|
||||||
@@ -1997,6 +2401,7 @@ function updateSvgSize() {
|
|||||||
// 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取
|
// 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取
|
||||||
// 因为 handleTimelineScroll 已经实时更新了这个值
|
// 因为 handleTimelineScroll 已经实时更新了这个值
|
||||||
const scrollLeft = timelineScrollLeft.value
|
const scrollLeft = timelineScrollLeft.value
|
||||||
|
const scrollTop = timelineBodyScrollTop.value
|
||||||
|
|
||||||
// 虚拟渲染策略(统一模式):
|
// 虚拟渲染策略(统一模式):
|
||||||
// Canvas 始终使用固定安全宽度,通过 offsetLeft 动态定位
|
// Canvas 始终使用固定安全宽度,通过 offsetLeft 动态定位
|
||||||
@@ -2024,7 +2429,6 @@ function updateSvgSize() {
|
|||||||
svgWidth.value = canvasWidth.value
|
svgWidth.value = canvasWidth.value
|
||||||
svgHeight.value = clampedHeight
|
svgHeight.value = clampedHeight
|
||||||
|
|
||||||
const scrollTop = timelineBodyScrollTop.value
|
|
||||||
const bufferTop = clampedHeight / 3
|
const bufferTop = clampedHeight / 3
|
||||||
let idealOffsetTop = Math.max(0, scrollTop - bufferTop)
|
let idealOffsetTop = Math.max(0, scrollTop - bufferTop)
|
||||||
|
|
||||||
@@ -2037,9 +2441,7 @@ function updateSvgSize() {
|
|||||||
|
|
||||||
canvasOffsetTop.value = idealOffsetTop
|
canvasOffsetTop.value = idealOffsetTop
|
||||||
}
|
}
|
||||||
}
|
}function handleBarMounted(payload: {
|
||||||
|
|
||||||
function handleBarMounted(payload: {
|
|
||||||
id: number
|
id: number
|
||||||
left: number
|
left: number
|
||||||
top: number
|
top: number
|
||||||
@@ -2597,6 +2999,9 @@ onUnmounted(() => {
|
|||||||
// 停止自动滚动
|
// 停止自动滚动
|
||||||
stopAutoScroll()
|
stopAutoScroll()
|
||||||
|
|
||||||
|
// 清理连接线拖拽状态
|
||||||
|
cleanupLinkDrag()
|
||||||
|
|
||||||
// 清理事件监听器
|
// 清理事件监听器
|
||||||
window.removeEventListener('task-row-double-click', handleTaskListDoubleClick as EventListener)
|
window.removeEventListener('task-row-double-click', handleTaskListDoubleClick as EventListener)
|
||||||
window.removeEventListener('task-list-hover', handleTaskListHover as EventListener)
|
window.removeEventListener('task-list-hover', handleTaskListHover as EventListener)
|
||||||
@@ -3411,6 +3816,21 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
|
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 连接线拖拽引导线 -->
|
||||||
|
<LinkDragGuide
|
||||||
|
v-if="dragLinkMode && linkDragSourceTask"
|
||||||
|
:active="true"
|
||||||
|
:start-x="getLinkDragStartX()"
|
||||||
|
:start-y="getLinkDragStartY()"
|
||||||
|
:end-x="linkDragCurrentX"
|
||||||
|
:end-y="linkDragCurrentY"
|
||||||
|
:width="canvasWidth"
|
||||||
|
:height="canvasHeight"
|
||||||
|
:offset-left="canvasOffsetLeft"
|
||||||
|
:offset-top="canvasOffsetTop"
|
||||||
|
:is-valid-target="isValidLinkTarget"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 年度视图今日标记线 -->
|
<!-- 年度视图今日标记线 -->
|
||||||
<div
|
<div
|
||||||
v-if="isTodayVisibleInYearView && getTodayLinePositionInYearView >= 0"
|
v-if="isTodayVisibleInYearView && getTodayLinePositionInYearView >= 0"
|
||||||
@@ -3693,6 +4113,10 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
:is-highlighted="highlightedTaskIds.has(task.id)"
|
:is-highlighted="highlightedTaskIds.has(task.id)"
|
||||||
:is-primary-highlight="highlightedTaskId === task.id"
|
:is-primary-highlight="highlightedTaskId === task.id"
|
||||||
:is-in-highlight-mode="isInHighlightMode"
|
: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"
|
@update:task="updateTask"
|
||||||
@bar-mounted="handleBarMounted"
|
@bar-mounted="handleBarMounted"
|
||||||
@click="handleTaskBarClick(task, $event)"
|
@click="handleTaskBarClick(task, $event)"
|
||||||
@@ -3707,6 +4131,9 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
@add-successor="handleAddSuccessor"
|
@add-successor="handleAddSuccessor"
|
||||||
@delete="handleTaskDelete"
|
@delete="handleTaskDelete"
|
||||||
@long-press="setHighlightTask"
|
@long-press="setHighlightTask"
|
||||||
|
@link-drag-start="handleLinkDragStart"
|
||||||
|
@link-drag-move="handleLinkDragMove"
|
||||||
|
@link-drag-end="handleLinkDragEnd"
|
||||||
>
|
>
|
||||||
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
|
||||||
<slot name="custom-task-content" v-bind="barScope" />
|
<slot name="custom-task-content" v-bind="barScope" />
|
||||||
@@ -3729,13 +4156,13 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--gantt-bg-primary, #ffffff);
|
background: var(--gantt-bg-primary, #ffffff);
|
||||||
overflow-x: auto; /* 横向滚动,显示滚动条 */
|
overflow-x: auto; /* 横向滚动,显示滚动条 */
|
||||||
overflow-y: hidden; /* 纵向滚动,但不显示滚动条 */
|
overflow-y: auto; /* 纵向滚动,显示滚动条 */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
position: relative; /* 为覆盖层定位 */
|
position: relative; /* 为覆盖层定位 */
|
||||||
|
|
||||||
/* Webkit浏览器滚动条样式 - 只显示横向滚动条 */
|
/* Webkit浏览器滚动条样式 */
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user