v1.4.5 - add Task drag and drop ender TaskList

This commit is contained in:
LINING-PC\lining
2025-12-06 20:34:33 +08:00
parent 04b9e5fb75
commit 7d81f44778
16 changed files with 1471 additions and 86 deletions
+33
View File
@@ -47,6 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
taskBarConfig: undefined,
autoSortByStartDate: false,
allowDragAndResize: true,
enableTaskRowMove: false,
})
const emit = defineEmits([
@@ -70,6 +71,8 @@ const emit = defineEmits([
'milestone-saved',
'milestone-deleted',
'milestone-icon-changed',
// TaskRow拖拽事件
'task-row-moved',
])
const { showMessage } = useMessage()
@@ -123,6 +126,8 @@ interface Props {
autoSortByStartDate?: boolean
// 是否允许拖拽和拉伸(默认为 true)
allowDragAndResize?: boolean
// 是否启用TaskRow拖拽移动功能(默认为 false)
enableTaskRowMove?: boolean
}
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
@@ -280,6 +285,16 @@ const timelineContainerWidth = ref<number>(0)
// 任务拖拽/拉伸触发器(用于触发timeline范围重新计算)
const updateTaskTrigger = ref<number>(0)
// 监听props.tasks变化,自动触发Timeline更新
// 这对于TaskRow移动等操作很重要,因为外部更新tasks后需要通知Timeline重新渲染
watch(
() => props.tasks,
() => {
updateTaskTrigger.value++
},
{ deep: true },
)
// 时间刻度状态
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
@@ -589,6 +604,22 @@ const handleExpandAll = () => {
}
}
// 处理TaskRow移动事件
const handleTaskRowMoved = (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
oldParent: Task | null
newParent: Task | null
}) => {
// TaskList已经通过对象引用直接修改了props.tasks的内容
// 但computed不会因为对象内部变化而重新计算,需要手动触发
updateTaskTrigger.value++
// 向外传递事件,让外部组件进行额外处理(完全可选)
emit('task-row-moved', payload)
}
// 全部折叠任务
const handleCollapseAll = () => {
if (props.onCollapseAll && typeof props.onCollapseAll === 'function') {
@@ -2197,12 +2228,14 @@ function handleMilestoneDialogDelete(milestoneId: number) {
:tasks="tasksForTaskList"
:use-default-drawer="props.useDefaultDrawer"
:task-list-config="props.taskListConfig"
:enable-task-row-move="props.enableTaskRowMove"
@task-collapse-change="handleTaskCollapseChange"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
@task-row-moved="handleTaskRowMoved"
>
<template v-if="$slots['custom-task-content']" #custom-task-content="rowScope">
<slot name="custom-task-content" v-bind="rowScope" />
+94 -44
View File
@@ -1,15 +1,18 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, useSlots, computed } from 'vue'
import { ref, onMounted, onUnmounted, useSlots, computed, nextTick } from 'vue'
import TaskRow from './TaskRow.vue'
import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
import type { TaskListConfig } from '../models/configs/TaskListConfig'
import { DEFAULT_TASK_LIST_COLUMNS } from '../models/configs/TaskListConfig'
import { useTaskRowDrag } from '../composables/useTaskRowDrag'
import { moveTask } from '../utils/taskTreeUtils'
interface Props {
tasks?: Task[]
useDefaultDrawer?: boolean
taskListConfig?: TaskListConfig
enableTaskRowMove?: boolean
}
const props = defineProps<Props>()
@@ -26,6 +29,15 @@ const emit = defineEmits<{
'add-predecessor': [task: Task] // 新增:添加前置任务事件
'add-successor': [task: Task] // 新增:添加后置任务事件
delete: [task: Task, deleteChildren?: boolean]
'task-row-moved': [
payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child' // 'after': 放在目标任务之后(同级), 'child': 作为目标任务的子任务
oldParent: Task | null
newParent: Task | null
},
]
}>()
const slots = useSlots()
const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
@@ -86,8 +98,9 @@ const visibleColumns = computed(() => {
return columns.filter(col => col.visible !== false)
})
// 内部响应式任务列表
const localTasks = ref<Task[]>([])
// 使用 props.tasks 的引用,不创建副本
// 这样可以直接修改对象,触发响应式更新,不需要外部监听事件
const localTasks = computed(() => props.tasks || [])
// 悬停状态管理
const hoveredTaskId = ref<number | null>(null)
@@ -206,31 +219,26 @@ const calculateParentTaskData = (
// 更新所有父级任务的进度和日期范围
const updateParentTasksData = () => {
const updateParentTask = (taskList: Task[]): Task[] => {
return taskList.map(task => {
if (!props.tasks) return
const updateParentTask = (taskList: Task[]): void => {
taskList.forEach(task => {
if (task.children && task.children.length > 0) {
// 先更新子任务
const updatedChildren = updateParentTask(task.children)
updateParentTask(task.children)
// 计算父级任务的进度和日期范围
const parentData = calculateParentTaskData({
...task,
children: updatedChildren,
})
const parentData = calculateParentTaskData(task)
return {
...task,
progress: parentData.progress,
startDate: parentData.startDate,
endDate: parentData.endDate,
children: updatedChildren,
}
// 直接修改对象属性
task.progress = parentData.progress
task.startDate = parentData.startDate
task.endDate = parentData.endDate
}
return task
})
}
localTasks.value = updateParentTask(localTasks.value)
updateParentTask(props.tasks)
}
// 获取所有任务的扁平化列表(包括子任务)
@@ -305,16 +313,7 @@ const endSpacerHeight = computed(() => {
return Math.max(0, totalContentHeight.value - startSpacerHeight.value - visibleHeight)
})
// 监听外部传入的 tasks 数据变化
watch(
() => props.tasks,
newTasks => {
localTasks.value = newTasks || []
// 更新父级任务数据
updateParentTasksData()
},
{ immediate: true, deep: true },
)
// 不再需要 watch,直接使用 props.tasks 的引用
function toggleCollapse(task: Task) {
task.collapsed = !task.collapsed
@@ -325,26 +324,28 @@ function toggleCollapse(task: Task) {
// 更新任务数据
const updateTaskData = (updatedTask: Task) => {
const updateTaskInTree = (taskList: Task[]): Task[] => {
return taskList.map(task => {
if (!props.tasks) return
const updateTaskInTree = (taskList: Task[]): boolean => {
for (const task of taskList) {
if (task.id === updatedTask.id) {
return {
...task,
...updatedTask, // 完整更新所有字段
children: task.children, // 保留子任务结构
// 直接修改对象属性
Object.assign(task, updatedTask)
// 保留子任务结构
if (updatedTask.children === undefined) {
// 如果 updatedTask 没有 children,保持原来的
// task.children 保持不变
}
return true
}
if (task.children) {
return {
...task,
children: updateTaskInTree(task.children),
}
if (task.children && updateTaskInTree(task.children)) {
return true
}
return task
})
}
return false
}
localTasks.value = updateTaskInTree(localTasks.value)
updateTaskInTree(props.tasks)
// 更新父级任务的进度和日期范围
updateParentTasksData()
@@ -359,7 +360,9 @@ const handleTaskUpdated = (event: CustomEvent) => {
// 监听Timeline的任务新增事件
const handleTaskAdded = (event: CustomEvent) => {
const newTask = event.detail
localTasks.value.push(newTask)
if (props.tasks) {
props.tasks.push(newTask)
}
// 更新父级任务数据
updateParentTasksData()
@@ -470,6 +473,50 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
emit('delete', task, deleteChildren)
}
// 处理TaskRow拖拽移动事件(核心逻辑在组件内部)
const handleTaskRowMoved = (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
}) => {
const { draggedTask, targetTask, position } = payload
if (!props.tasks) return
// 执行移动操作(直接修改 props.tasks 对象,保持引用不变)
const result = moveTask(props.tasks, draggedTask.id, targetTask.id, position)
if (!result) {
return
}
// moveTask 已直接修改了 props.tasks,无需手动 splice
// 强制触发下一次更新,确保视图刷新
nextTick(() => {
// 更新父级任务数据(重新计算进度和日期)
updateParentTasksData()
// 触发事件,通知外部组件(可选)
// 外部可以监听此事件进行额外处理,如调用API保存、显示提示等
// 但不需要更新 tasks,组件已自动完成
emit('task-row-moved', {
draggedTask: result.movedTask,
targetTask,
position,
oldParent: result.oldParent,
newParent: result.newParent,
})
})
}
// 全局拖拽管理器(在TaskList层面,所有TaskRow共享)
const { startDrag, handleDragOver } = useTaskRowDrag({
enabled: props.enableTaskRowMove ?? false,
onDrop: (draggedTask, targetTask, position) => {
handleTaskRowMoved({ draggedTask, targetTask, position })
},
})
onMounted(async () => {
// 初始化 ResizeObserver 监听容器宽度变化
if (taskListRef.value) {
@@ -574,6 +621,9 @@ onUnmounted(() => {
:columns="visibleColumns"
:get-column-width-style="getColumnWidthStyle"
:disable-children-render="true"
:enable-drag="props.enableTaskRowMove"
:drag-start="startDrag"
:drag-over="handleDragOver"
@toggle="toggleCollapse"
@dblclick="handleTaskRowDoubleClick"
@contextmenu="handleTaskRowContextMenu"
+73
View File
@@ -38,6 +38,9 @@ interface Props {
columns: TaskListColumnConfig[]
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
disableChildrenRender?: boolean
enableDrag?: boolean
dragStart?: (task: Task, element: HTMLElement, event: MouseEvent) => void
dragOver?: (task: Task, element: HTMLElement, event: MouseEvent) => void
}
const props = defineProps<Props>()
const emit = defineEmits([
@@ -275,17 +278,56 @@ const slotPayload = computed(() => ({
progressClass: progressClass.value,
}))
// TaskRow拖拽功能
const taskRowRef = ref<HTMLElement | null>(null)
const handleMouseDown = (event: MouseEvent) => {
// 如果未启用拖拽,直接返回
if (!props.enableDrag) return
// 如果点击的是按钮、输入框等交互元素,不触发拖拽
const target = event.target as HTMLElement
if (
target.tagName === 'BUTTON' ||
target.tagName === 'INPUT' ||
target.tagName === 'SELECT' ||
target.tagName === 'TEXTAREA' ||
target.closest('.collapse-btn') ||
target.closest('.task-context-menu')
) {
return
}
if (taskRowRef.value && props.dragStart) {
props.dragStart(props.task, taskRowRef.value, event)
}
}
// 处理全局拖拽悬停事件
const handleTaskRowDragOver = (event: CustomEvent) => {
if (!props.enableDrag || !taskRowRef.value || !props.dragOver) return
const { taskId, event: mouseEvent } = event.detail
if (taskId === props.task.id) {
// eslint-disable-next-line no-console
console.log('[TaskRow] 接收到drag-over事件,任务:', props.task.name)
props.dragOver(props.task, taskRowRef.value, mouseEvent)
}
}
// 生命周期钩子 - 注册事件监听器
onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
window.addEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
})
onUnmounted(() => {
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
window.removeEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
if (timerInterval.value) {
clearInterval(timerInterval.value)
}
@@ -295,7 +337,9 @@ onUnmounted(() => {
<template>
<div>
<div
ref="taskRowRef"
class="task-row"
:data-task-id="props.task.id"
:class="{
'task-row-hovered': isHovered,
'parent-task': isParentTask,
@@ -306,6 +350,7 @@ onUnmounted(() => {
}"
@click="handleRowClick"
@dblclick="handleTaskRowDoubleClick"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@contextmenu="handleContextMenu"
@@ -482,6 +527,9 @@ onUnmounted(() => {
:columns="props.columns"
:get-column-width-style="props.getColumnWidthStyle"
:disable-children-render="props.disableChildrenRender"
:enable-drag="props.enableDrag"
:drag-start="props.dragStart"
:drag-over="props.dragOver"
@toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $event)"
@start-timer="emit('start-timer', $event)"
@@ -937,4 +985,29 @@ onUnmounted(() => {
:global(html[data-theme='dark']) .timer-dot {
background-color: #85ce61; /* 暗色主题下的绿色 */
}
/* TaskRow拖拽样式 */
.task-row-dragging {
opacity: 0.6 !important;
cursor: move !important;
}
/* 拖拽目标高亮样式 */
.task-row-drop-target.drop-after {
border-bottom: 3px solid var(--gantt-primary, #409eff) !important;
background-color: rgba(64, 158, 255, 0.05) !important;
}
.task-row-drop-target.drop-child {
border: 2px solid var(--gantt-primary, #409eff) !important;
background-color: rgba(64, 158, 255, 0.05) !important;
}
:global(html[data-theme='dark']) .task-row-drop-target.drop-after {
background-color: rgba(125, 180, 240, 0.1) !important;
}
:global(html[data-theme='dark']) .task-row-drop-target.drop-child {
background-color: rgba(125, 180, 240, 0.1) !important;
}
</style>