Files
jordium-gantt-vue3/src/components/LinkAnchor.vue
T
LINING-PC\lining b45c0c0ddc Merge tag 'v1.8.1' into develop
v1.8.1

# Conflicts:
#	.gitignore
#	package.json
#	src/components/LinkAnchor.vue
#	src/components/TaskList/taskRow/TaskRow.vue
#	src/components/Timeline.vue
2026-02-05 16:29:50 +08:00

270 lines
6.4 KiB
Vue

<script setup lang="ts">
import { computed, ref, onUnmounted, inject } 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' }]
}>()
// 注入视图模式,在资源视图下禁用LinkAnchor
const viewMode = inject<any>('gantt-view-mode', { value: 'task' })
// 触点自身悬停状态
const isHoveredAnchor = ref(false)
// 优化的显示逻辑
const shouldShow = computed(() => {
// 在资源视图下禁用LinkAnchor
if (viewMode.value === 'resource') {
return false
}
// 在以下情况显示触点:
// 1. TaskBar 被悬停
// 2. 全局有拖拽操作进行中(其他任务正在拖拽连接线)
// 3. 当前是拖拽的源任务
// 4. 当前是拖拽的目标任务
// 5. 触点本身被悬停
return (
props.visible || // TaskBar 悬停
props.globalDragging || // 全局拖拽进行中
props.isDragSource || // 是拖拽的源任务
props.isDragTarget || // 是拖拽的目标任务
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(.gantt-root[data-theme='dark']) .link-anchor {
border-color: #1a1a1a;
}
:global(.gantt-root[data-theme='dark']) .link-anchor.valid-target,
:global(.gantt-root[data-theme='dark']) .link-anchor.invalid-target {
border-color: #1a1a1a;
}
:global(.gantt-root[data-theme='dark']) .anchor-tooltip {
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
}
</style>