merge src/master
This commit is contained in:
@@ -47,6 +47,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
taskBarConfig: undefined,
|
||||
autoSortByStartDate: false,
|
||||
allowDragAndResize: true,
|
||||
enableTaskRowMove: false,
|
||||
assigneeOptions: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -75,6 +77,8 @@ const emit = defineEmits([
|
||||
// 任务关系事件
|
||||
'add-predecessor',
|
||||
'add-successor',
|
||||
// TaskRow拖拽事件
|
||||
'task-row-moved',
|
||||
])
|
||||
|
||||
const { showMessage } = useMessage()
|
||||
@@ -128,6 +132,12 @@ interface Props {
|
||||
autoSortByStartDate?: boolean
|
||||
// 是否允许拖拽和拉伸(默认为 true)
|
||||
allowDragAndResize?: boolean
|
||||
// 是否启用TaskRow拖拽移动功能(默认为 false)
|
||||
enableTaskRowMove?: boolean
|
||||
// 指派人员选项列表(用于TaskDrawer中的assignee下拉菜单)
|
||||
// 格式:{ key?: string | number, value: string | number, label: string }
|
||||
// key 为可选项,若不存在则使用 value 作为选项的唯一标识
|
||||
assigneeOptions?: Array<{ key?: string | number; value: string | number; label: string }>
|
||||
}
|
||||
|
||||
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
|
||||
@@ -285,6 +295,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)
|
||||
|
||||
@@ -597,6 +617,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') {
|
||||
@@ -2262,12 +2298,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" />
|
||||
@@ -2363,6 +2401,7 @@ function handleMilestoneDialogDelete(milestoneId: number) {
|
||||
v-model:visible="taskDrawerVisible"
|
||||
:task="taskDrawerTask"
|
||||
:is-edit="taskDrawerEditMode"
|
||||
:assignee-options="props.assigneeOptions"
|
||||
@submit="handleTaskDrawerSubmit"
|
||||
@close="taskDrawerVisible = false"
|
||||
@start-timer="handleStartTimer"
|
||||
|
||||
+48
-15
@@ -261,7 +261,26 @@ const taskBarStyle = computed(() => {
|
||||
const startDate = createLocalDate(currentStartDate)
|
||||
const endDate = createLocalDate(currentEndDate)
|
||||
const baseStart = parsedBaseStartDate.value
|
||||
if (!startDate || !endDate || !baseStart) {
|
||||
|
||||
// 如果startDate和endDate都不存在,返回0宽度(实际不会渲染,由shouldRenderTaskBar控制)
|
||||
if (!startDate && !endDate) {
|
||||
return {
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
height: `${props.rowHeight - 10}px`,
|
||||
top: '4px',
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染时使用有效日期:如果只有startDate或endDate,用它来计算开始和结束位置
|
||||
// 注意:不修改原始的startDate/endDate变量,只用于渲染计算
|
||||
const renderStartDate = startDate || endDate
|
||||
const renderEndDate = endDate || startDate
|
||||
const renderBaseStart = baseStart
|
||||
|
||||
// 安全检查:renderStartDate和renderEndDate必定存在(因为上面已经检查过)
|
||||
// 但baseStart可能不存在,如果不存在则无法计算位置
|
||||
if (!renderStartDate || !renderEndDate || !renderBaseStart) {
|
||||
return {
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
@@ -276,12 +295,12 @@ const taskBarStyle = computed(() => {
|
||||
// 小时视图:按分钟精确计算位置(需要考虑时间部分)
|
||||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// 确保 baseStart 是当天的 00:00:00
|
||||
const baseStartOfDay = new Date(baseStart)
|
||||
const baseStartOfDay = new Date(renderBaseStart)
|
||||
baseStartOfDay.setHours(0, 0, 0, 0)
|
||||
|
||||
// 处理没有时间部分的日期字符串
|
||||
let adjustedStartDate = startDate
|
||||
let adjustedEndDate = endDate
|
||||
let adjustedStartDate = renderStartDate
|
||||
let adjustedEndDate = renderEndDate
|
||||
|
||||
// 检查原始日期字符串是否包含时间部分
|
||||
const originalStartStr = currentStartDate || props.task.startDate
|
||||
@@ -292,13 +311,13 @@ const taskBarStyle = computed(() => {
|
||||
typeof originalStartStr === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(originalStartStr.trim())
|
||||
) {
|
||||
adjustedStartDate = new Date(startDate)
|
||||
adjustedStartDate = new Date(renderStartDate)
|
||||
adjustedStartDate.setHours(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
// 如果endDate没有时间部分(格式为YYYY-MM-DD),设置为次日00:00
|
||||
if (typeof originalEndStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(originalEndStr.trim())) {
|
||||
adjustedEndDate = new Date(endDate)
|
||||
adjustedEndDate = new Date(renderEndDate)
|
||||
adjustedEndDate.setDate(adjustedEndDate.getDate() + 1)
|
||||
adjustedEndDate.setHours(0, 0, 0, 0)
|
||||
}
|
||||
@@ -317,15 +336,19 @@ const taskBarStyle = computed(() => {
|
||||
|
||||
// 将日期标准化为当天的00:00:00,忽略时间部分
|
||||
const startDateOnly = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
renderStartDate.getFullYear(),
|
||||
renderStartDate.getMonth(),
|
||||
renderStartDate.getDate(),
|
||||
)
|
||||
const endDateOnly = new Date(
|
||||
renderEndDate.getFullYear(),
|
||||
renderEndDate.getMonth(),
|
||||
renderEndDate.getDate(),
|
||||
)
|
||||
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
|
||||
const baseStartOnly = new Date(
|
||||
baseStart.getFullYear(),
|
||||
baseStart.getMonth(),
|
||||
baseStart.getDate(),
|
||||
renderBaseStart.getFullYear(),
|
||||
renderBaseStart.getMonth(),
|
||||
renderBaseStart.getDate(),
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -447,6 +470,15 @@ const parsedEndDate = computed(() => createLocalDate(props.task.endDate || ''))
|
||||
// 缓存解析后的基准开始日期
|
||||
const parsedBaseStartDate = computed(() => createLocalDate(props.startDate))
|
||||
|
||||
// 判断是否应该渲染TaskBar:只考虑startDate和endDate,都不存在时不渲染
|
||||
const shouldRenderTaskBar = computed(() => {
|
||||
const currentStartDate = tempTaskData.value?.startDate || props.task.startDate
|
||||
const currentEndDate = tempTaskData.value?.endDate || props.task.endDate
|
||||
|
||||
// 只要startDate或endDate有一个存在就渲染
|
||||
return !!(currentStartDate || currentEndDate)
|
||||
})
|
||||
|
||||
// 计算任务状态和颜色
|
||||
const taskStatus = computed(() => {
|
||||
// 父级任务(Story类型)使用与新建按钮一致的配色
|
||||
@@ -2309,6 +2341,7 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldRenderTaskBar"
|
||||
ref="barRef"
|
||||
class="task-bar"
|
||||
:data-task-id="task.id"
|
||||
@@ -2408,8 +2441,8 @@ onUnmounted(() => {
|
||||
<!-- 图片头像 -->
|
||||
<img v-if="task.avatar" :src="task.avatar" :alt="task.assignee || 'avatar'" />
|
||||
<!-- 文字头像(负责人首字母) -->
|
||||
<span v-else-if="task.assignee" class="avatar-text">
|
||||
{{ task.assignee.charAt(0).toUpperCase() }}
|
||||
<span v-else-if="task.assigneeName" class="avatar-text">
|
||||
{{ task.assigneeName.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<!-- 默认灰色用户图标 -->
|
||||
<svg v-else class="avatar-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -9,11 +9,18 @@ import ConfirmTimerDialog from './ConfirmTimerDialog.vue'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import '../styles/app.css'
|
||||
|
||||
interface AssigneeOption {
|
||||
key?: string | number
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
task?: Task | null
|
||||
isEdit?: boolean
|
||||
onDelete?: (task: Task) => void
|
||||
assigneeOptions?: AssigneeOption[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -21,6 +28,13 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
task: null,
|
||||
isEdit: false,
|
||||
onDelete: undefined,
|
||||
assigneeOptions: () => [
|
||||
{ value: 'zhangsan', label: '张三' },
|
||||
{ value: 'lisi', label: '李四' },
|
||||
{ value: 'wangwu', label: '王五' },
|
||||
{ value: 'zhaoliu', label: '赵六' },
|
||||
{ value: 'qianqi', label: '钱七' },
|
||||
],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -628,6 +642,18 @@ function confirmTimer(desc: string) {
|
||||
// desc 可用于后续业务
|
||||
handleStartTimer(desc)
|
||||
}
|
||||
|
||||
// 处理负责人变更
|
||||
const handleAssigneeChanged = (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
// 通过value过滤props.assigneeOptions获取对应的label
|
||||
const selected = props.assigneeOptions?.find(option => option.value === value)
|
||||
if (selected) {
|
||||
// 这里可以根据需要处理选中的负责人信息
|
||||
// 例如,可以将负责人名称存储在formData中
|
||||
formData.assigneeName = selected.label
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -788,13 +814,15 @@ function confirmTimer(desc: string) {
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-assignee">{{ t.assignee }}</label>
|
||||
<select id="task-assignee" v-model="formData.assignee" class="form-select">
|
||||
<select id="task-assignee" v-model="formData.assignee" class="form-select" @change="handleAssigneeChanged">
|
||||
<option value="">{{ t.selectAssignee }}</option>
|
||||
<option value="张三">张三</option>
|
||||
<option value="李四">李四</option>
|
||||
<option value="王五">王五</option>
|
||||
<option value="赵六">赵六</option>
|
||||
<option value="钱七">钱七</option>
|
||||
<option
|
||||
v-for="assignee in props.assigneeOptions"
|
||||
:key="assignee.key ?? assignee.value"
|
||||
:value="assignee.value"
|
||||
>
|
||||
{{ assignee.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
+94
-44
@@ -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) {
|
||||
@@ -575,6 +622,9 @@ onUnmounted(() => {
|
||||
:get-column-width-style="getColumnWidthStyle"
|
||||
:disable-children-render="true"
|
||||
:show-task-icon="props.taskListConfig?.showTaskIcon"
|
||||
:enable-drag="props.enableTaskRowMove"
|
||||
:drag-start="startDrag"
|
||||
:drag-over="handleDragOver"
|
||||
@toggle="toggleCollapse"
|
||||
@dblclick="handleTaskRowDoubleClick"
|
||||
@contextmenu="handleTaskRowContextMenu"
|
||||
|
||||
@@ -39,6 +39,9 @@ interface Props {
|
||||
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
|
||||
disableChildrenRender?: boolean
|
||||
showTaskIcon?: boolean // 是否显示任务图标,默认true
|
||||
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([
|
||||
@@ -276,17 +279,54 @@ 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) {
|
||||
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)
|
||||
}
|
||||
@@ -296,7 +336,9 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="taskRowRef"
|
||||
class="task-row"
|
||||
:data-task-id="props.task.id"
|
||||
:class="{
|
||||
'task-row-hovered': isHovered,
|
||||
'parent-task': isParentTask,
|
||||
@@ -307,6 +349,7 @@ onUnmounted(() => {
|
||||
}"
|
||||
@click="handleRowClick"
|
||||
@dblclick="handleTaskRowDoubleClick"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@contextmenu="handleContextMenu"
|
||||
@@ -494,6 +537,9 @@ onUnmounted(() => {
|
||||
:get-column-width-style="props.getColumnWidthStyle"
|
||||
:disable-children-render="props.disableChildrenRender"
|
||||
:show-task-icon="props.showTaskIcon"
|
||||
: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)"
|
||||
@@ -956,4 +1002,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>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
|
||||
interface DragState {
|
||||
isDragging: boolean
|
||||
draggedTask: Task | null
|
||||
draggedElement: HTMLElement | null
|
||||
ghostElement: HTMLElement | null
|
||||
dropTargetTask: Task | null
|
||||
dropPosition: 'after' | 'child' | null
|
||||
}
|
||||
|
||||
interface UseDragOptions {
|
||||
enabled: boolean
|
||||
onDragStart?: (task: Task) => void
|
||||
onDragOver?: (task: Task, position: 'after' | 'child') => void
|
||||
onDrop?: (draggedTask: Task, targetTask: Task, position: 'after' | 'child') => void
|
||||
}
|
||||
|
||||
export function useTaskRowDrag(options: UseDragOptions) {
|
||||
const dragState = ref<DragState>({
|
||||
isDragging: false,
|
||||
draggedTask: null,
|
||||
draggedElement: null,
|
||||
ghostElement: null,
|
||||
dropTargetTask: null,
|
||||
dropPosition: null,
|
||||
})
|
||||
|
||||
const startDrag = (task: Task, element: HTMLElement, event: MouseEvent) => {
|
||||
if (!options.enabled) return
|
||||
|
||||
// 防止里程碑分组、里程碑被拖拽
|
||||
if (task.type === 'milestone-group' || task.type === 'milestone') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragState.value.isDragging = true
|
||||
dragState.value.draggedTask = task
|
||||
dragState.value.draggedElement = element
|
||||
|
||||
// 创建拖拽的幽灵元素
|
||||
const ghost = element.cloneNode(true) as HTMLElement
|
||||
ghost.style.position = 'fixed'
|
||||
ghost.style.left = '-9999px'
|
||||
ghost.style.top = '-9999px'
|
||||
ghost.style.width = `${element.offsetWidth}px`
|
||||
ghost.style.height = `${element.offsetHeight}px`
|
||||
ghost.style.opacity = '0.6'
|
||||
ghost.style.pointerEvents = 'none'
|
||||
ghost.style.zIndex = '999999'
|
||||
ghost.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.3)'
|
||||
ghost.classList.add('task-row-dragging')
|
||||
|
||||
// 查找全屏容器或使用body
|
||||
const fullscreenContainer = document.querySelector('.gantt-fullscreen') || document.body
|
||||
fullscreenContainer.appendChild(ghost)
|
||||
dragState.value.ghostElement = ghost
|
||||
|
||||
// 设置原始元素为半透明
|
||||
element.style.opacity = '0.4'
|
||||
|
||||
// 禁用页面选择
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.webkitUserSelect = 'none'
|
||||
document.body.style.cursor = 'move'
|
||||
|
||||
// 广播拖拽开始事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('task-row-drag-start', {
|
||||
detail: { task },
|
||||
}),
|
||||
)
|
||||
|
||||
if (options.onDragStart) {
|
||||
options.onDragStart(task)
|
||||
}
|
||||
|
||||
// 初始定位幽灵元素
|
||||
updateGhostPosition(event)
|
||||
}
|
||||
|
||||
const updateGhostPosition = (event: MouseEvent) => {
|
||||
if (dragState.value.ghostElement) {
|
||||
const ghost = dragState.value.ghostElement
|
||||
ghost.style.left = `${event.clientX + 10}px`
|
||||
ghost.style.top = `${event.clientY - 25}px`
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (task: Task, element: HTMLElement, event: MouseEvent) => {
|
||||
if (!dragState.value.isDragging || !dragState.value.draggedTask) return
|
||||
|
||||
// 不能拖拽到自己身上
|
||||
if (task.id === dragState.value.draggedTask.id) {
|
||||
clearDropTarget()
|
||||
return
|
||||
}
|
||||
|
||||
// 不能拖拽到自己的子任务上
|
||||
if (isDescendant(dragState.value.draggedTask, task)) {
|
||||
clearDropTarget()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// 确定放置位置:根据是否有子任务
|
||||
const hasChildren = task.children && task.children.length > 0
|
||||
const position: 'after' | 'child' = hasChildren ? 'child' : 'after'
|
||||
|
||||
dragState.value.dropTargetTask = task
|
||||
dragState.value.dropPosition = position
|
||||
|
||||
// 移除之前的高亮
|
||||
document.querySelectorAll('.task-row-drop-target').forEach(el => {
|
||||
el.classList.remove('task-row-drop-target', 'drop-after', 'drop-child')
|
||||
})
|
||||
|
||||
// 添加新的高亮
|
||||
element.classList.add('task-row-drop-target')
|
||||
if (position === 'after') {
|
||||
element.classList.add('drop-after')
|
||||
} else {
|
||||
element.classList.add('drop-child')
|
||||
}
|
||||
|
||||
if (options.onDragOver) {
|
||||
options.onDragOver(task, position)
|
||||
}
|
||||
}
|
||||
|
||||
const clearDropTarget = () => {
|
||||
dragState.value.dropTargetTask = null
|
||||
dragState.value.dropPosition = null
|
||||
document.querySelectorAll('.task-row-drop-target').forEach(el => {
|
||||
el.classList.remove('task-row-drop-target', 'drop-after', 'drop-child')
|
||||
})
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (!dragState.value.isDragging) return
|
||||
|
||||
const draggedTask = dragState.value.draggedTask
|
||||
const dropTargetTask = dragState.value.dropTargetTask
|
||||
const dropPosition = dragState.value.dropPosition
|
||||
|
||||
// 恢复原始元素样式
|
||||
if (dragState.value.draggedElement) {
|
||||
dragState.value.draggedElement.style.opacity = ''
|
||||
}
|
||||
|
||||
// 移除幽灵元素
|
||||
if (dragState.value.ghostElement && dragState.value.ghostElement.parentElement) {
|
||||
dragState.value.ghostElement.parentElement.removeChild(dragState.value.ghostElement)
|
||||
}
|
||||
|
||||
// 清除高亮
|
||||
clearDropTarget()
|
||||
|
||||
// 恢复页面选择
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.webkitUserSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
|
||||
// 广播拖拽结束事件
|
||||
window.dispatchEvent(new CustomEvent('task-row-drag-end'))
|
||||
|
||||
// 如果有有效的放置目标,触发drop回调
|
||||
if (draggedTask && dropTargetTask && dropPosition && options.onDrop) {
|
||||
options.onDrop(draggedTask, dropTargetTask, dropPosition)
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
dragState.value = {
|
||||
isDragging: false,
|
||||
draggedTask: null,
|
||||
draggedElement: null,
|
||||
ghostElement: null,
|
||||
dropTargetTask: null,
|
||||
dropPosition: null,
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (dragState.value.isDragging) {
|
||||
updateGhostPosition(event)
|
||||
|
||||
// 临时隐藏幽灵元素以检测下方元素
|
||||
const ghost = dragState.value.ghostElement
|
||||
if (ghost) {
|
||||
ghost.style.display = 'none'
|
||||
}
|
||||
|
||||
// 检测鼠标下的元素
|
||||
const elementUnderMouse = document.elementFromPoint(event.clientX, event.clientY)
|
||||
|
||||
// 恢复幽灵元素显示
|
||||
if (ghost) {
|
||||
ghost.style.display = ''
|
||||
}
|
||||
|
||||
if (elementUnderMouse) {
|
||||
// 查找最近的task-row元素
|
||||
const taskRow = elementUnderMouse.closest('.task-row') as HTMLElement
|
||||
if (taskRow && taskRow.dataset.taskId) {
|
||||
const taskId = Number(taskRow.dataset.taskId)
|
||||
|
||||
// 触发全局事件,让TaskRow组件处理
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('task-row-drag-over', {
|
||||
detail: { taskId, event },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragState.value.isDragging) {
|
||||
endDrag()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查task2是否是task1的后代
|
||||
const isDescendant = (ancestor: Task, descendant: Task): boolean => {
|
||||
if (!ancestor.children) return false
|
||||
|
||||
for (const child of ancestor.children) {
|
||||
if (child.id === descendant.id) return true
|
||||
if (isDescendant(child, descendant)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
|
||||
// 清理可能残留的幽灵元素
|
||||
if (dragState.value.ghostElement && dragState.value.ghostElement.parentElement) {
|
||||
dragState.value.ghostElement.parentElement.removeChild(dragState.value.ghostElement)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
dragState,
|
||||
startDrag,
|
||||
handleDragOver,
|
||||
endDrag,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ export interface Task {
|
||||
id: number
|
||||
name: string
|
||||
predecessor?: number[] // 前置任务ID数组
|
||||
assignee?: string
|
||||
assignee?: string // 记录唯一键值,如用户ID或用户名
|
||||
assigneeName?: string // 任务负责人名称
|
||||
avatar?: string // 任务负责人头像URL
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Task } from '../models/classes/Task'
|
||||
|
||||
/**
|
||||
* 任务树操作工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从任务树中查找任务的父任务
|
||||
*/
|
||||
export function findTaskParent(
|
||||
taskList: Task[],
|
||||
targetId: number,
|
||||
parent: Task | null = null,
|
||||
): Task | null {
|
||||
for (const task of taskList) {
|
||||
if (task.id === targetId) {
|
||||
return parent
|
||||
}
|
||||
if (task.children && task.children.length > 0) {
|
||||
const found = findTaskParent(task.children, targetId, task)
|
||||
if (found !== null) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务树中移除指定任务
|
||||
* @returns 被移除的任务,如果未找到则返回 null
|
||||
*/
|
||||
export function removeTaskFromTree(taskList: Task[], taskId: number): Task | null {
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
if (taskList[i].id === taskId) {
|
||||
return taskList.splice(i, 1)[0]
|
||||
}
|
||||
if (taskList[i].children && taskList[i].children!.length > 0) {
|
||||
const removed = removeTaskFromTree(taskList[i].children!, taskId)
|
||||
if (removed) return removed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任务插入到任务树的指定位置
|
||||
* @param taskList 任务列表
|
||||
* @param targetId 目标任务ID
|
||||
* @param taskToInsert 要插入的任务
|
||||
* @param position 插入位置:'after' 表示在目标任务之后,'child' 表示作为目标任务的子任务
|
||||
* @returns 是否插入成功
|
||||
*/
|
||||
export function insertTaskInTree(
|
||||
taskList: Task[],
|
||||
targetId: number,
|
||||
taskToInsert: Task,
|
||||
position: 'after' | 'child',
|
||||
): boolean {
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
if (taskList[i].id === targetId) {
|
||||
if (position === 'after') {
|
||||
// 算法#1: 放置在目标任务之后(同级)
|
||||
// 更新 parentId 为目标任务的 parentId
|
||||
taskToInsert.parentId = taskList[i].parentId
|
||||
taskList.splice(i + 1, 0, taskToInsert)
|
||||
} else {
|
||||
// 算法#2: 作为子任务插入(第一个子任务位置)
|
||||
// 重要:里程碑组(milestone-group)不能添加子任务,只能在其后插入
|
||||
if (taskList[i].type === 'milestone-group') {
|
||||
// 降级为 'after' 逻辑
|
||||
taskToInsert.parentId = taskList[i].parentId
|
||||
taskList.splice(i + 1, 0, taskToInsert)
|
||||
} else {
|
||||
// 更新 parentId 为目标任务的 id
|
||||
taskToInsert.parentId = targetId
|
||||
if (!taskList[i].children) {
|
||||
taskList[i].children = []
|
||||
}
|
||||
taskList[i].children!.unshift(taskToInsert)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (taskList[i].children && taskList[i].children!.length > 0) {
|
||||
if (insertTaskInTree(taskList[i].children!, targetId, taskToInsert, position)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任务移动操作(直接修改传入的 taskList)
|
||||
* @returns 移动信息,如果移动失败则返回 null
|
||||
*/
|
||||
export function moveTask(
|
||||
taskList: Task[],
|
||||
draggedTaskId: number,
|
||||
targetTaskId: number,
|
||||
position: 'after' | 'child',
|
||||
): {
|
||||
movedTask: Task
|
||||
oldParent: Task | null
|
||||
newParent: Task | null
|
||||
} | null {
|
||||
// 1. 记录原始父任务
|
||||
const oldParent = findTaskParent(taskList, draggedTaskId)
|
||||
|
||||
// 2. 从原位置移除任务
|
||||
const removedTask = removeTaskFromTree(taskList, draggedTaskId)
|
||||
if (!removedTask) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 3. 插入到新位置
|
||||
const inserted = insertTaskInTree(taskList, targetTaskId, removedTask, position)
|
||||
if (!inserted) {
|
||||
// 插入失败,需要恢复任务到原位置
|
||||
// 如果有父任务,恢复到父任务的 children 中
|
||||
if (oldParent && oldParent.children) {
|
||||
oldParent.children.push(removedTask)
|
||||
} else {
|
||||
// 否则恢复到根级别
|
||||
taskList.push(removedTask)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 4. 计算新的父任务
|
||||
const newParent = findTaskParent(taskList, draggedTaskId)
|
||||
|
||||
return {
|
||||
movedTask: removedTask,
|
||||
oldParent,
|
||||
newParent,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user