右键删除Task链接功能
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user