右键删除Task链接功能

This commit is contained in:
qiuchengw
2025-12-03 14:30:28 +08:00
parent c98c6a12f0
commit d51a8a10f5
5 changed files with 291 additions and 0 deletions
+35
View File
@@ -64,6 +64,7 @@ const emit = defineEmits([
'task-added',
'task-updated',
'task-collapse-change', // 任务折叠状态变化事件
'link-deleted', // 链接删除事件
// 工具栏事件
'add-task',
'add-milestone',
@@ -2156,6 +2157,39 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
emit('task-deleted', { task })
}
// 处理链接删除事件
function handleLinkDeleted(event: {
sourceTaskId: number
targetTaskId: number
updatedTask: Task
}) {
if (props.tasks) {
// 在 tasks 树中找到目标任务并更新
const updateTaskInTree = (taskList: Task[]): boolean => {
for (let i = 0; i < taskList.length; i++) {
if (taskList[i].id === event.targetTaskId) {
// 使用新对象替换,确保响应式更新
taskList[i] = { ...taskList[i], predecessor: event.updatedTask.predecessor }
return true
}
if (taskList[i].children && taskList[i].children!.length > 0) {
if (updateTaskInTree(taskList[i].children!)) {
return true
}
}
}
return false
}
updateTaskInTree(props.tasks)
}
// 触发任务更新事件
emit('task-updated', { task: event.updatedTask })
// 向外部发送链接删除事件
emit('link-deleted', event)
}
// 处理里程碑双击事件
function handleMilestoneDoubleClick(milestone: Milestone) {
// 先触发外部事件,让外部可以自定义处理
@@ -2291,6 +2325,7 @@ function handleMilestoneDialogDelete(milestoneId: number) {
@predecessor-added="handlePredecessorAdded"
@successor-added="handleSuccessorAdded"
@delete="handleTaskDelete"
@link-deleted="handleLinkDeleted"
>
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" />
+11
View File
@@ -44,6 +44,8 @@ interface Props {
isValidLinkTarget?: boolean
// 是否是无效的连接目标
isInvalidLinkTarget?: boolean
// 所有任务列表(用于右键菜单删除链接功能)
allTasks?: Task[]
}
interface TaskStatus {
@@ -80,6 +82,7 @@ const emit = defineEmits([
'add-predecessor',
'add-successor',
'delete',
'delete-link',
'context-menu',
'long-press',
'link-drag-start',
@@ -2242,6 +2245,12 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
closeContextMenu()
}
// 处理删除链接事件
const handleDeleteLink = (event: { sourceTaskId: number; targetTaskId: number }) => {
emit('delete-link', event)
closeContextMenu()
}
// 连接线触点事件处理
const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'successor'; mouseEvent: MouseEvent }) => {
emit('link-drag-start', event)
@@ -2494,12 +2503,14 @@ onUnmounted(() => {
:visible="contextMenuVisible"
:task="contextMenuTask"
:position="contextMenuPosition"
:all-tasks="allTasks"
@close="closeContextMenu"
@start-timer="$emit('start-timer', props.task)"
@stop-timer="$emit('stop-timer', props.task)"
@add-predecessor="$emit('add-predecessor', props.task)"
@add-successor="$emit('add-successor', props.task)"
@delete="handleTaskDelete"
@delete-link="handleDeleteLink"
/>
</div>
+206
View File
@@ -4,6 +4,7 @@ import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
import ConfirmTimerDialog from './ConfirmTimerDialog.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
import { getPredecessorIds } from '../utils/predecessorUtils'
// 定义Props接口
interface Props {
@@ -13,6 +14,7 @@ interface Props {
y: number
}
task: Task | null
allTasks?: Task[] // 所有任务列表,用于查找链接的任务名称
}
const props = defineProps<Props>()
@@ -23,6 +25,7 @@ const emit = defineEmits([
'add-predecessor',
'add-successor',
'delete', // 新增delete事件
'delete-link', // 删除链接事件
'close',
])
@@ -134,6 +137,51 @@ const handleDeleteNo = () => {
// 新增:是否显示计时菜单项(非story类型才显示)
const showTimerMenu = computed(() => props.task?.type !== 'story')
// 子菜单状态管理
const showDeleteLinksSubmenu = ref(false)
let submenuHideTimer: number | null = null
// 计算当前任务的所有链接(前置和后置)
const taskLinks = computed(() => {
const links: Array<{ type: 'predecessor' | 'successor'; taskId: number; taskName: string }> = []
if (!props.task || !props.allTasks) return links
// 获取前置任务链接
if (props.task.predecessor) {
const predecessorIds = getPredecessorIds(props.task.predecessor)
for (const id of predecessorIds) {
const task = props.allTasks.find(t => t.id === id)
if (task) {
links.push({
type: 'predecessor',
taskId: id,
taskName: task.name || `任务 ${id}`,
})
}
}
}
// 获取后置任务链接(当前任务作为其他任务的前置)
for (const task of props.allTasks) {
if (task.predecessor && task.id !== props.task.id) {
const predecessorIds = getPredecessorIds(task.predecessor)
if (predecessorIds.includes(props.task.id)) {
links.push({
type: 'successor',
taskId: task.id,
taskName: task.name || `任务 ${task.id}`,
})
}
}
}
return links
})
// 是否有链接可删除
const hasLinks = computed(() => taskLinks.value.length > 0)
// 监听菜单可见状态和位置变化
watch(
[() => props.visible, () => props.position],
@@ -243,6 +291,42 @@ const handleAddSuccessor = () => {
}
}
// 处理删除链接点击
const handleDeleteLink = (link: { type: 'predecessor' | 'successor'; taskId: number }) => {
// 清理定时器
if (submenuHideTimer) {
clearTimeout(submenuHideTimer)
submenuHideTimer = null
}
// 立即隐藏子菜单
showDeleteLinksSubmenu.value = false
// 发送删除事件
emit('delete-link', {
sourceTaskId: link.type === 'predecessor' ? link.taskId : props.task!.id,
targetTaskId: link.type === 'predecessor' ? props.task!.id : link.taskId,
})
// 关闭整个菜单
emit('close')
}
// 处理子菜单鼠标进入/离开
const handleSubmenuMouseEnter = () => {
if (submenuHideTimer) {
clearTimeout(submenuHideTimer)
submenuHideTimer = null
}
showDeleteLinksSubmenu.value = true
}
const handleSubmenuMouseLeave = () => {
submenuHideTimer = window.setTimeout(() => {
showDeleteLinksSubmenu.value = false
}, 200)
}
// 处理点击其他地方关闭菜单
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
@@ -267,6 +351,10 @@ onUnmounted(() => {
// 移除事件监听
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
// 清理子菜单定时器
if (submenuHideTimer) {
clearTimeout(submenuHideTimer)
}
})
</script>
@@ -311,6 +399,43 @@ onUnmounted(() => {
</div>
{{ t.addSuccessor }}
</div>
<!-- 删除链接菜单带子菜单 -->
<div
v-if="hasLinks"
class="menu-item menu-item-with-submenu"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="handleSubmenuMouseLeave"
>
<div class="icon-wrapper">
<i class="menu-icon link-delete-icon"></i>
</div>
{{ t.deleteLinks }}
<i class="submenu-arrow"></i>
<!-- 子菜单 -->
<div
v-if="showDeleteLinksSubmenu"
class="submenu"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="handleSubmenuMouseLeave"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-for="link in taskLinks"
:key="`${link.type}-${link.taskId}`"
class="menu-item submenu-item"
@click.stop="handleDeleteLink(link)"
>
<span
v-html="
link.type === 'predecessor'
? t.predecessorLink.replace('{name}', link.taskName)
: t.successorLink.replace('{name}', link.taskName)
"
></span>
</div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
<div class="menu-divider"></div>
<div class="menu-item menu-item-danger" @click="handleDeleteClick">
<div class="icon-wrapper">
@@ -588,4 +713,85 @@ onUnmounted(() => {
width: 92%;
margin-left: 4%;
}
/* 带子菜单的菜单项 */
.menu-item-with-submenu {
position: relative;
padding-right: 30px;
}
.submenu-arrow {
position: absolute;
right: 12px;
font-style: normal;
font-size: 16px;
color: #999;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
margin-left: 4px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 200px;
z-index: 10001;
border: 1px solid #e4e7ed;
animation: fadeIn 0.15s ease-out;
}
.submenu-item {
padding: 8px 16px;
font-size: 13px;
white-space: nowrap;
}
.submenu-item:hover {
background-color: #fef0f0;
color: #f56c6c;
}
/* 链接删除图标 */
.link-delete-icon {
width: 20px;
height: 20px;
position: relative;
}
.link-delete-icon::before {
content: '';
position: absolute;
width: 14px;
height: 2px;
background-color: currentColor;
top: 50%;
left: 3px;
transform: translateY(-50%) rotate(45deg);
}
.link-delete-icon::after {
content: '';
position: absolute;
width: 14px;
height: 2px;
background-color: currentColor;
top: 50%;
left: 3px;
transform: translateY(-50%) rotate(-45deg);
}
/* 暗色主题下的子菜单 */
:global(html[data-theme='dark']) .submenu {
background-color: #2c2c2c;
border-color: #444444;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
:global(html[data-theme='dark']) .submenu-item:hover {
background-color: #3a1f1f;
color: #f56c6c;
}
</style>
+31
View File
@@ -70,6 +70,7 @@ const emit = defineEmits<{
'predecessor-added': [{ targetTask: Task; newTask: Task }] //
'successor-added': [{ targetTask: Task; newTask: Task }] //
delete: [task: Task, deleteChildren?: boolean]
'link-deleted': [{ sourceTaskId: number; targetTaskId: number; updatedTask: Task }] //
}>()
//
@@ -3228,6 +3229,34 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
emit('delete', task, deleteChildren)
}
//
const handleDeleteLink = (event: { sourceTaskId: number; targetTaskId: number }) => {
const targetTask = tasks.value.find(t => t.id === event.targetTaskId)
if (!targetTask || !targetTask.predecessor) return
const predecessorIds = getPredecessorIds(targetTask.predecessor)
const newIds = predecessorIds.filter(id => id !== event.sourceTaskId)
//
const updatedTask = {
...targetTask,
predecessor: newIds.length > 0 ? (newIds as any) : undefined,
}
// GanttChart tasks
emit('link-deleted', {
sourceTaskId: event.sourceTaskId,
targetTaskId: event.targetTaskId,
updatedTask,
})
//
updateTask(updatedTask)
//
clearTimelineCache()
}
//
const groupMonthsByYear = computed(() => {
if (currentTimeScale.value !== TimelineScale.MONTH) {
@@ -4307,6 +4336,7 @@ const handleAddSuccessor = (task: Task) => {
:is-invalid-link-target="
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
"
:all-tasks="tasks"
@update:task="updateTask"
@bar-mounted="handleBarMounted"
@click="handleTaskBarClick(task, $event)"
@@ -4320,6 +4350,7 @@ const handleAddSuccessor = (task: Task) => {
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
@delete-link="handleDeleteLink"
@long-press="setHighlightTask"
@link-drag-start="handleLinkDragStart"
@link-drag-move="handleLinkDragMove"
+8
View File
@@ -163,6 +163,10 @@ const messages = {
predecessorPlaceholder: '请选择前置任务',
selectPredecessor: '选择前置任务',
removePredecessor: '移除前置任务',
deleteLinks: '删除链接',
noLinks: '无链接',
predecessorLink: '前置: {name}',
successorLink: '后置: {name}',
operationFailed: '操作失败,请重试',
taskUpdateSuccess: '任务更新成功',
taskCreateSuccess: '任务创建成功',
@@ -421,6 +425,10 @@ const messages = {
predecessorPlaceholder: 'Select predecessor',
selectPredecessor: 'Select Predecessor',
removePredecessor: 'Remove Predecessor',
deleteLinks: 'Delete Links',
noLinks: 'No Links',
predecessorLink: 'Predecessor: {name}',
successorLink: 'Successor: {name}',
operationFailed: 'Operation failed, please try again',
taskUpdateSuccess: 'Task updated successfully',
taskCreateSuccess: 'Task created successfully',