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

View File

@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.5] - 2025-12-06
### Changed
- 优化:任务列表中任务项的移动拖放功能
- OptimizedTask item move and drag-and-drop functionality in the task list
## [1.4.4] - 2025-12-06
### Added
- 任务列表中任务项的移动拖放功能
- Task item move and drag-and-drop functionality in the task list
## [1.4.3] - 2025-11-28
### Added

View File

@@ -189,6 +189,7 @@ npm run dev
| `useDefaultMilestoneDialog` | `boolean` | `true` | Whether to use the built-in milestone edit dialog (MilestoneDialog) |
| `autoSortByStartDate` | `boolean` | `false` | Whether to automatically sort tasks by start date |
| `allowDragAndResize` | `boolean` | `true` | Whether to allow dragging and resizing tasks/milestones |
| `enableTaskRowMove` | `boolean` | `false` | Whether to alloww dragging and dropping TaskRow
#### Configuration Object Props
@@ -243,6 +244,7 @@ For complete event documentation, see:
| `milestone-deleted` | `{ milestoneId: number }` | Milestone deleted |
| `milestone-icon-changed` | `{ milestoneId, icon }` | Milestone icon changed |
| `milestone-drag-end` | `(milestone: Task)` | Milestone drag ended |
| `task-row-moved` | `payload: { draggedTask: Task, targetTask: Task, position: 'after' \| 'child', oldParent: Task \| null, newParent: Task \| null }` | TaskRow drag ended (optional) |
#### Example 1: Simplest Gantt Chart
@@ -424,6 +426,7 @@ Tasks are the core elements of the Gantt chart. The component provides complete
| `taskBarConfig` | `TaskBarConfig` | `{}` | Task bar style configuration, see [TaskBarConfig Configuration](#taskbarconfig-configuration) |
| `taskListConfig` | `TaskListConfig` | `undefined` | Task list configuration, see [TaskListConfig Configuration](#tasklistconfig-configuration) |
| `autoSortByStartDate` | `boolean` | `false` | Whether to automatically sort tasks by start date |
| `enableTaskRowMove` | `boolean` | `false` | Whether to alloww dragging and dropping TaskRow
**Configuration Notes**:
@@ -449,6 +452,7 @@ Tasks are the core elements of the Gantt chart. The component provides complete
| `successor-added` | `{ targetTask: Task, newTask: Task }` | After adding successor via context menu | `targetTask` is the original task, `newTask` is the newly created successor task (its predecessor already contains targetTask.id) |
| `timer-started` | `(task: Task) => void` | When task timer starts | Start recording task hours |
| `timer-stopped` | `(task: Task) => void` | When task timer stops | Stop recording task hours |
| `task-row-moved` | `payload: { draggedTask: Task, targetTask: Task, position: 'after' \| 'child', oldParent: Task \| null, newParent: Task \| null }` | TaskRow drag ended (optional) | Component has automatically completed data movement and TaskList/Timeline sync via object reference mutation. Listening to this event is completely optional, only for showing messages, calling API, etc. `position`: 'after'=same level, 'child'=as child |
**Data Synchronization Notes**:
@@ -715,6 +719,117 @@ const handleTaskAdded = e => {
> - Hide toolbar + custom buttons + default editor: Custom control bar style while keeping default edit functionality
> - Hide toolbar + custom buttons + custom editor: Fully customize all interaction logic
#### Example 4: Task Row Drag and Drop Sorting
Allow users to adjust task hierarchy and order by dragging TaskRow:
```vue
<template>
<div style="height: 600px;">
<GanttChart
:tasks="tasks"
:enable-task-row-move="true"
@task-row-moved="handleTaskRowMoved"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { GanttChart } from 'jordium-gantt-vue3'
import type { Task } from 'jordium-gantt-vue3'
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
const tasks = ref<Task[]>([
{
id: 1,
name: 'Project Planning',
startDate: '2025-01-01',
endDate: '2025-01-10',
progress: 100,
},
{
id: 2,
name: 'Requirements Analysis',
startDate: '2025-01-11',
endDate: '2025-01-20',
progress: 60,
parentId: 1,
},
{
id: 3,
name: 'System Design',
startDate: '2025-01-21',
endDate: '2025-01-30',
progress: 40,
},
])
// Task row drag completed event (optional)
const handleTaskRowMoved = async (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
oldParent: Task | null
newParent: Task | null
}) => {
const { draggedTask, targetTask, position, oldParent, newParent } = payload
// Component has automatically completed task move, parentId update and TaskList/Timeline sync
// Listening to this event is completely optional, only for:
// 1. Show custom notification message
const oldParentName = oldParent?.name || 'Root'
const newParentName = newParent?.name || 'Root'
const positionText = position === 'after' ? 'after target task' : 'as child of target task'
showMessage(`Task [${draggedTask.name}] moved from [${oldParentName}] to [${newParentName}] (${positionText})`, 'success')
// 2. Call backend API to save new task hierarchy
try {
await api.updateTaskHierarchy({
taskId: draggedTask.id,
targetTaskId: targetTask.id,
position: position,
oldParentId: oldParent?.id,
newParentId: newParent?.id,
})
} catch (error) {
console.error('Save task hierarchy failed:', error)
showMessage('Save failed, please refresh page', 'error')
}
// 3. Trigger other business logic (like updating related data, recording operation logs, etc.)
// ...
}
</script>
```
**Drag and Drop Sorting Notes**:
- **Enable Dragging**: Set `enable-task-row-move="true"` to enable task row dragging (default is `false`)
- **Dragging Algorithms** (automatically executed by component):
- **Algorithm 1 (Place After)**: When target task has no children, dragged task will be placed after target task (same level), `position='after'`
- **Algorithm 2 (As Child)**: When target task has children, dragged task will become first child of target task, `position='child'`
- **Visual Feedback**:
- Semi-transparent following element displayed while dragging
- Blue border hint shown when hovering over valid target tasks
- Tasks without children show blue bottom border
- Tasks with children show blue border on all sides
- **Auto Sync**: Component internally mutates `props.tasks` via object reference, automatically completing task move, `parentId` update, `children` array adjustment, and TaskList/Timeline synchronization
- **Event Listening (Optional)**:
- `task-row-moved` event is completely optional, only used for showing messages, calling API to save, recording logs, etc.
- No need to manually update `tasks.value`, component has automatically completed data synchronization
- **Event Parameters**:
- `draggedTask`: The dragged task
- `targetTask`: The target task
- `position`: Drop position ('after' or 'child')
- `oldParent`: Original parent task (null means root)
- `newParent`: New parent task (null means root)
- **Constraints**:
- Cannot drag onto itself
- Cannot drag onto its own child tasks (avoid circular reference)
- Milestones and milestone groups cannot be dragged
### Milestone Management
### Milestone Management

115
README.md
View File

@@ -187,6 +187,7 @@ npm run dev
| `useDefaultMilestoneDialog` | `boolean` | `true` | 是否使用内置里程碑编辑对话框MilestoneDialog |
| `autoSortByStartDate` | `boolean` | `false` | 是否根据开始时间自动排序任务 |
| `allowDragAndResize` | `boolean` | `true` | 是否允许拖拽和调整任务/里程碑大小 |
| `enableTaskRowMove` | `boolean` | `false` | 是否允许拖拽和摆放TaskRow |
#### 配置对象属性
@@ -241,6 +242,7 @@ npm run dev
| `milestone-deleted` | `{ milestoneId: number }` | 里程碑删除 |
| `milestone-icon-changed` | `{ milestoneId, icon }` | 里程碑图标变更 |
| `milestone-drag-end` | `(milestone: Task)` | 拖拽里程碑结束 |
| `task-row-moved` | `payload: { draggedTask: Task, targetTask: Task, position: 'after' \| 'child', oldParent: Task \| null, newParent: Task \| null }` | 拖拽TaskRow结束可选 |
#### 示例1最简单的甘特图
@@ -422,6 +424,7 @@ const handleMilestoneSaved = milestone => {
| `taskBarConfig` | `TaskBarConfig` | `{}` | 任务条样式配置,详见 [TaskBarConfig 配置](#taskbarconfig-配置) |
| `taskListConfig` | `TaskListConfig` | `undefined` | 任务列表配置,详见 [TaskListConfig 配置](#tasklistconfig-配置) |
| `autoSortByStartDate` | `boolean` | `false` | 是否根据开始时间自动排序任务 |
| `enableTaskRowMove` | `boolean` | `false` | 是否允许拖拽和摆放TaskRow
**配置说明**
@@ -447,6 +450,7 @@ const handleMilestoneSaved = milestone => {
| `successor-added` | `{ targetTask: Task, newTask: Task }` | 通过右键菜单添加后置任务后 | `targetTask` 是原任务,`newTask` 是新创建的后置任务(其 predecessor 已包含 targetTask.id |
| `timer-started` | `(task: Task) => void` | 任务计时器启动时 | 开始记录任务工时 |
| `timer-stopped` | `(task: Task) => void` | 任务计时器停止时 | 停止记录任务工时 |
| `task-row-moved` | `payload: { draggedTask: Task, targetTask: Task, position: 'after' \| 'child', oldParent: Task \| null, newParent: Task \| null }` | 拖拽TaskRow结束可选 | 组件已自动完成数据移动和TaskList/Timeline同步。监听此事件为完全可选仅用于显示提示、调用API保存等。`position`: 'after'=同级放置,'child'=作为子任务 |
**数据同步说明**
@@ -713,6 +717,117 @@ const handleTaskAdded = e => {
> - 隐藏工具栏 + 自定义按钮 + 默认编辑器:自定义控制栏样式,保留默认编辑功能
> - 隐藏工具栏 + 自定义按钮 + 自定义编辑器:完全自定义所有交互逻辑
#### 示例4任务行拖拽排序
允许用户通过拖拽 TaskRow 来调整任务的层级关系和前后顺序:
```vue
<template>
<div style="height: 600px;">
<GanttChart
:tasks="tasks"
:enable-task-row-move="true"
@task-row-moved="handleTaskRowMoved"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { GanttChart } from 'jordium-gantt-vue3'
import type { Task } from 'jordium-gantt-vue3'
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
const tasks = ref<Task[]>([
{
id: 1,
name: '项目规划',
startDate: '2025-01-01',
endDate: '2025-01-10',
progress: 100,
},
{
id: 2,
name: '需求分析',
startDate: '2025-01-11',
endDate: '2025-01-20',
progress: 60,
parentId: 1,
},
{
id: 3,
name: '系统设计',
startDate: '2025-01-21',
endDate: '2025-01-30',
progress: 40,
},
])
// 任务行拖拽完成事件(可选)
const handleTaskRowMoved = async (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
oldParent: Task | null
newParent: Task | null
}) => {
const { draggedTask, targetTask, position, oldParent, newParent } = payload
// 组件已自动完成任务移动、parentId更新和TaskList/Timeline同步
// 监听此事件为完全可选,仅用于:
// 1. 显示自定义提示消息
const oldParentName = oldParent?.name || '根目录'
const newParentName = newParent?.name || '根目录'
const positionText = position === 'after' ? '在目标任务之后' : '作为目标任务的子任务'
showMessage(`任务 [${draggedTask.name}] 已从 [${oldParentName}] 移动到 [${newParentName}] (${positionText})`, 'success')
// 2. 调用后端 API 保存新的任务层级关系
try {
await api.updateTaskHierarchy({
taskId: draggedTask.id,
targetTaskId: targetTask.id,
position: position,
oldParentId: oldParent?.id,
newParentId: newParent?.id,
})
} catch (error) {
console.error('保存任务层级失败:', error)
showMessage('保存失败,请刷新页面', 'error')
}
// 3. 触发其他业务逻辑(如更新关联数据、记录操作日志等)
// ...
}
</script>
```
**拖拽排序说明**
- **启用拖拽**:设置 `enable-task-row-move="true"` 启用任务行拖拽功能(默认为 `false`
- **拖拽算法**(组件内部自动执行):
- **算法1放置在后面**:当目标任务没有子任务时,被拖拽的任务会放置在目标任务之后(同级),`position='after'`
- **算法2作为子任务**:当目标任务有子任务时,被拖拽的任务会成为目标任务的第一个子任务,`position='child'`
- **视觉反馈**
- 拖拽时会显示半透明的跟随元素
- 悬停在有效目标任务上时显示蓝色边框提示
- 无子任务的任务显示蓝色底部边框
- 有子任务的任务显示蓝色四周边框
- **自动同步**:组件内部通过对象引用直接修改 `props.tasks`,自动完成任务移动、`parentId` 更新、`children` 数组调整以及 TaskList/Timeline 同步
- **事件监听(可选)**
- `task-row-moved` 事件为完全可选仅用于显示提示、调用API保存、记录日志等额外处理
- 无需手动更新 `tasks.value`,组件已自动完成数据同步
- **事件参数**
- `draggedTask`: 被拖拽的任务
- `targetTask`: 目标任务
- `position`: 放置位置('after' 或 'child'
- `oldParent`: 原父任务null 表示根目录)
- `newParent`: 新父任务null 表示根目录)
- **限制条件**
- 不能拖拽到自己身上
- 不能拖拽到自己的子任务上(避免循环引用)
- 里程碑和里程碑分组不能被拖拽
### 里程碑管理
里程碑用于标记项目中的重要时间节点,如项目启动、阶段完成、产品发布等。组件提供了灵活的里程碑编辑配置,默认使用内置的 MilestoneDialog也支持完全自定义编辑行为。

View File

@@ -12,6 +12,7 @@ import VersionHistoryDrawer from './VersionHistoryDrawer.vue'
import HtmlContent from './HtmlContent.vue'
import { useMessage } from '../src/composables/useMessage'
import { useI18n } from '../src/composables/useI18n'
import { useDemoLocale } from './useDemoLocale'
import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils'
import type { Task } from '../src/models/Task'
import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig'
@@ -19,6 +20,7 @@ import type { TaskBarConfig } from '../src/models/configs/TaskBarConfig'
const { showMessage } = useMessage()
const { t, formatTranslation } = useI18n()
const { locale: demoLocale, messages: demoMessages, setLocale: setDemoLocale, formatMessage, getTaskTypeName, getParentName } = useDemoLocale()
const tasks = ref<Task[]>([])
const milestones = ref<Task[]>([])
@@ -182,6 +184,9 @@ const taskListConfig = computed<TaskListConfig>(() => ({
// 控制是否允许拖拽和拉伸
const allowDragAndResize = ref(true)
// 控制是否启用TaskRow拖拽移动
const enableTaskRowMove = ref(true)
// TaskBar配置
const taskBarOptions = ref({
showAvatar: true,
@@ -236,6 +241,17 @@ const toggleTaskBarConfig = () => {
const showTaskClickDialog = ref(false)
const clickedTask = ref<Task | null>(null)
// TaskRow Move 相关(已移除确认对话框,直接显示提示消息)
// 同步语言切换
const handleLanguageChange = (lang: 'zh-CN' | 'en-US') => {
setDemoLocale(lang)
const languageText = lang === 'zh-CN' ? '中文' : 'English'
showMessage(formatTranslation('languageSwitchedTo', { language: languageText }), 'info', {
closable: true,
})
}
// 处理任务点击事件
const handleTaskClick = (task: Task) => {
clickedTask.value = task
@@ -306,12 +322,7 @@ const handleAddMilestone = () => {
showMilestoneDialog.value = true
}
const handleLanguageChange = (lang: 'zh' | 'en') => {
const languageText = lang === 'zh' ? '中文' : 'English'
showMessage(formatTranslation('languageSwitchedTo', { language: languageText }), 'info', {
closable: true,
})
}
const handleThemeChange = (isDark: boolean) => {
const themeText = isDark ? t.value.darkModeText : t.value.lightModeText
@@ -544,6 +555,86 @@ function onTimerStopped(task: Task) {
function taskDebug(item: any) {
// Placeholder for debugging
}
// TaskRow拖拽移动事件处理器
const handleTaskRowMoved = async (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child' // 'after': 放在目标任务之后(同级), 'child': 作为目标任务的子任务
oldParent: Task | null
newParent: Task | null
}) => {
const { draggedTask, targetTask, position, oldParent, newParent } = payload
// 【注意】组件内部已自动完成数据移动,通过对象引用修改实现 TaskList 和 Timeline 的自动同步
// 因此监听此事件是完全可选的,仅用于:
// 1. 显示自定义提示消息
// 2. 调用后端API保存任务层级变更
// 3. 记录操作日志
// 4. 触发其他业务逻辑(如更新关联数据)
// 构建提示消息
const oldParentName = getParentName(oldParent)
const newParentName = position === 'after'
? getParentName(newParent)
: getParentName({ type: targetTask.type, name: targetTask.name })
let message = ''
const msgs = demoMessages.value.taskMoveConfirm.messages
if (position === 'after') {
// 算法#1: 放置在目标任务之后
message = formatMessage(msgs.moveAfter, {
draggedTaskType: getTaskTypeName(draggedTask.type),
draggedTaskName: draggedTask.name,
targetTaskType: getTaskTypeName(targetTask.type),
targetTaskName: targetTask.name,
})
} else {
// 算法#2: 作为子任务放置
if (oldParent && oldParent.id !== targetTask.id) {
message = formatMessage(msgs.moveAsChild, {
draggedTaskName: draggedTask.name,
oldParentName,
newParentName,
})
} else if (!oldParent) {
message = formatMessage(msgs.moveAsChildNoOldParent, {
draggedTaskName: draggedTask.name,
newParentName,
})
} else {
message = formatMessage(msgs.moveAsChildSameParent, {
draggedTaskName: draggedTask.name,
newParentName,
})
}
}
// 显示移动成功提示
const successMsg = demoMessages.value.taskMoveConfirm.messages.moveSuccess
showMessage(`${successMsg}: ${message}`, 'success', { closable: true })
// ⚠️ 重要:必须更新 tasks.value 以同步 TaskList 和 Timeline
// TaskList 已更新内部视图,但 Timeline 依赖 props.tasks
// 此赋值会触发 GanttChart 的 watch进而触发 Timeline 重新渲染
tasks.value = updatedTasks
// 调用后端API保存任务层级变更
// try {
// await api.updateTaskHierarchy({
// taskId: draggedTask.id,
// targetTaskId: targetTask.id,
// position: position, // 'after' 或 'child'
// oldParentId: oldParent?.id,
// newParentId: newParent?.id,
// })
// console.log('任务层级已保存到后端')
// } catch (error) {
// console.error('保存任务层级失败:', error)
// showMessage('保存失败,请刷新页面', 'error', { closable: true })
// }
}
</script>
<template>
@@ -907,6 +998,17 @@ function taskDebug(item: any) {
{{ t.taskBarConfig.mistouch.allowDragOnClickHint }}
</span>
</div>
<div class="control-row">
<label class="taskbar-control">
<input v-model="enableTaskRowMove" type="checkbox" />
<span class="taskbar-label">
启用TaskRow拖拽移动
</span>
</label>
<span class="control-hint">
允许通过拖拽TaskRow来调整任务的层级和顺序
</span>
</div>
<div class="control-row">
<label class="control-label">
{{ t.taskBarConfig.mistouch.dragThreshold }}:
@@ -985,6 +1087,7 @@ function taskDebug(item: any) {
:working-hours="workingHoursConfig"
:use-default-milestone-dialog="true"
:allow-drag-and-resize="allowDragAndResize"
:enable-task-row-move="enableTaskRowMove"
:on-export-csv="handleCustomCsvExport"
:on-language-change="handleLanguageChange"
:on-theme-change="handleThemeChange"
@@ -1011,6 +1114,7 @@ function taskDebug(item: any) {
@task-deleted="handleTaskDeleteEvent"
@task-added="handleTaskAddEvent"
@task-updated="handleTaskUpdateEvent"
@task-row-moved="handleTaskRowMoved"
>
<template #custom-task-content="item">
<HtmlContent

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
interface Props {
visible?: boolean
title?: string
message?: string
confirmText?: string
cancelText?: string
}
withDefaults(defineProps<Props>(), {
visible: false,
title: '确认操作',
message: '',
confirmText: '确认',
cancelText: '取消',
})
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
emit('cancel')
}
</script>
<template>
<div v-if="visible" class="confirm-overlay" @click="handleCancel">
<div class="confirm-dialog" @click.stop>
<div class="confirm-header">
<h3>{{ title }}</h3>
<button class="close-btn" @click="handleCancel">×</button>
</div>
<div class="confirm-body">
<div class="confirm-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#409eff" stroke-width="2" />
<path d="M12 8v5M12 16h.01" stroke="#409eff" stroke-width="2" stroke-linecap="round" />
</svg>
</div>
<p class="confirm-message">{{ message }}</p>
</div>
<div class="confirm-footer">
<button class="btn btn-cancel" @click="handleCancel">{{ cancelText }}</button>
<button class="btn btn-confirm" @click="handleConfirm">{{ confirmText }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.confirm-dialog {
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
min-width: 420px;
max-width: 90vw;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.confirm-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e8e8e8;
}
.confirm-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.close-btn {
background: none;
border: none;
font-size: 28px;
color: #909399;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: #f5f7fa;
color: #606266;
}
.confirm-body {
padding: 32px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.confirm-icon {
display: flex;
align-items: center;
justify-content: center;
}
.confirm-message {
font-size: 15px;
color: #606266;
line-height: 1.6;
text-align: center;
margin: 0;
white-space: pre-line;
}
.confirm-footer {
padding: 16px 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid #e8e8e8;
}
.btn {
padding: 10px 24px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s;
min-width: 80px;
}
.btn-cancel {
background: #f5f7fa;
color: #606266;
}
.btn-cancel:hover {
background: #e8eaed;
}
.btn-confirm {
background: #409eff;
color: white;
}
.btn-confirm:hover {
background: #66b1ff;
}
.btn-confirm:active {
background: #3a8ee6;
}
</style>
<style>
/* 暗色主题支持 - 非scoped样式以便访问html元素 */
html[data-theme='dark'] .confirm-dialog {
background: #1d1e1f;
}
html[data-theme='dark'] .confirm-header {
border-bottom-color: #414243;
}
html[data-theme='dark'] .confirm-header h3 {
color: #e5e7eb;
}
html[data-theme='dark'] .confirm-message {
color: #b4b6b9;
}
html[data-theme='dark'] .confirm-footer {
border-top-color: #414243;
}
html[data-theme='dark'] .btn-cancel {
background: #2c2d2e;
color: #b4b6b9;
}
html[data-theme='dark'] .btn-cancel:hover {
background: #363738;
}
html[data-theme='dark'] .close-btn {
color: #909399;
}
html[data-theme='dark'] .close-btn:hover {
background: #2c2d2e;
color: #b4b6b9;
}
</style>

24
demo/locales/en-US.json Normal file
View File

@@ -0,0 +1,24 @@
{
"taskMoveConfirm": {
"title": "Confirm Task Move",
"confirmText": "Confirm",
"cancelText": "Cancel",
"messages": {
"moveAfter": "{draggedTaskType} [{draggedTaskName}] will be placed after {targetTaskType} [{targetTaskName}]. Continue?",
"moveAsChild": "Task [{draggedTaskName}] will be unlinked from {oldParentName} and linked to {newParentName}. Continue?",
"moveAsChildNoOldParent": "Task [{draggedTaskName}] will be linked to {newParentName}. Continue?",
"moveAsChildSameParent": "Task [{draggedTaskName}] will be moved to the first position under {newParentName}. Continue?",
"moveCanceled": "Task move canceled",
"moveSuccess": "Task moved successfully"
},
"taskTypes": {
"story": "Story",
"task": "Task"
},
"parentNames": {
"root": "Root",
"story": "Story [{name}]",
"task": "Task [{name}]"
}
}
}

24
demo/locales/zh-CN.json Normal file
View File

@@ -0,0 +1,24 @@
{
"taskMoveConfirm": {
"title": "确认移动任务",
"confirmText": "确认",
"cancelText": "取消",
"messages": {
"moveAfter": "{draggedTaskType}【{draggedTaskName}】将放置{targetTaskType}【{targetTaskName}】之后,确认是否继续?",
"moveAsChild": "任务【{draggedTaskName}】将解除与{oldParentName}的关系,并设置于{newParentName}的关系,确认是否继续?",
"moveAsChildNoOldParent": "任务【{draggedTaskName}】将设置于{newParentName}的关系,确认是否继续?",
"moveAsChildSameParent": "任务【{draggedTaskName}】将移动到{newParentName}的第一位,确认是否继续?",
"moveCanceled": "任务移动已取消",
"moveSuccess": "任务移动成功"
},
"taskTypes": {
"story": "需求",
"task": "任务"
},
"parentNames": {
"root": "根目录",
"story": "需求【{name}】",
"task": "任务【{name}】"
}
}
}

89
demo/useDemoLocale.ts Normal file
View File

@@ -0,0 +1,89 @@
import { ref, computed } from 'vue'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
export type LocaleKey = 'zh-CN' | 'en-US'
interface LocaleMessages {
taskMoveConfirm: {
title: string
confirmText: string
cancelText: string
messages: {
moveAfter: string
moveAsChild: string
moveAsChildNoOldParent: string
moveAsChildSameParent: string
moveCanceled: string
moveSuccess: string
}
taskTypes: {
story: string
task: string
}
parentNames: {
root: string
story: string
task: string
}
}
}
const localeMessages: Record<LocaleKey, LocaleMessages> = {
'zh-CN': zhCN,
'en-US': enUS,
}
const currentLocale = ref<LocaleKey>('zh-CN')
export function useDemoLocale() {
const locale = computed(() => currentLocale.value)
const messages = computed(() => localeMessages[currentLocale.value])
const setLocale = (newLocale: LocaleKey) => {
currentLocale.value = newLocale
}
/**
* 格式化消息,替换占位符
*/
const formatMessage = (template: string | undefined, params: Record<string, string>) => {
if (!template) return ''
return template.replace(/\{(\w+)\}/g, (match, key) => {
return params[key] || match
})
}
/**
* 获取任务类型名称
*/
const getTaskTypeName = (type: string) => {
return messages.value.taskMoveConfirm.taskTypes[type as 'story' | 'task'] || type
}
/**
* 获取父任务名称
*/
const getParentName = (parent: { type: string; name: string } | null) => {
if (!parent) {
return messages.value.taskMoveConfirm.parentNames.root
}
const template = messages.value.taskMoveConfirm.parentNames[parent.type as 'story' | 'task']
// 如果找不到模板,直接返回任务名称
if (!template) {
return parent.name
}
return formatMessage(template, { name: parent.name })
}
return {
locale,
messages,
setLocale,
formatMessage,
getTaskTypeName,
getParentName,
}
}

View File

@@ -322,5 +322,25 @@
"Added: Large data loading demonstration",
"Fix: jspdf vulnerabilies"
]
},
{
"version": "1.4.4",
"date": "2025-12-06",
"notes": [
"新增:任务列表中任务项的移动拖放功能",
"<span style=\"font-weight: bold; color: #f00;\">特别感谢 jun201607@github的使用及反馈的宝贵意见</span>",
"Added: Task item move and drag-and-drop functionality in the task list",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to jun201607@github for their valuable use and feedback</span>"
]
},
{
"version": "1.4.5",
"date": "2025-12-06",
"notes": [
"优化:任务列表中任务项的移动拖放功能",
"<span style=\"font-weight: bold; color: #f00;\">特别感谢 jun201607@github的使用及反馈的宝贵意见</span>",
"Optimized: Task item move and drag-and-drop functionality in the task list",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to jun201607@github for their valuable use and feedback</span>"
]
}
]

View File

@@ -13,7 +13,7 @@ const tasks = ref([
department: '管理部',
departmentCode: 'D001',
type: 'task',
},
}
])
const milestones = ref([
@@ -22,41 +22,52 @@ const milestones = ref([
name: '项目立项',
startDate: '2025-10-29',
type: 'milestone',
icon: 'diamond',
},
icon: 'diamond'
}
])
const customMessages = {
'zh-CN': {
department: '部门',
departmentCode: '部门编号',
// 其他文本
days: '天111',
hours: '小时111',
overtime: '超时111',
overdue: '逾期111',
},
'en-US': {
department: 'Department',
departmentCode: 'Department Code',
},
// 其他文本
days: 'Day111',
hours: 'Hour111',
overtime: 'OverTime111',
overdue: 'OverDue111',
}
}
// const tasks = ref([])
// const milestones = ref([])
const showAddTaskDrawer = ref(false)
const showAddMilestoneDialog = ref(false)
const showAddTaskDrawer = ref(false);
const showAddMilestoneDialog = ref(false);
// 定义可动态配置的列
const availableColumns = ref<TaskListColumnConfig[]>([
{ key: 'startDate', label: '开始日期', visible: true },
{ key: 'endDate', label: '结束日期', visible: true },
{ key: 'progress', label: '进度', visible: true },
{ key: 'department', label: '部门', visible: true, width: 120 },
{ key: 'department', label: '部门', visible: true, width: 200 },
{ key: 'departmentCode', label: '部门编号', visible: true },
])
// TaskList宽度配置示例
const taskListConfig = {
defaultWidth: 400, // 默认展开宽度400px默认320px
minWidth: 300, // 最小宽度300px默认280px
maxWidth: 1200, // 最大宽度1200px默认1160px
columns: availableColumns.value,
defaultWidth: '50%', // 默认展开宽度50%
minWidth: '300px', // 最小宽度300px默认280px
maxWidth: '1200px', // 最大宽度1200px默认1160px
columns: availableColumns.value
}
// toolbar配置示例
@@ -71,17 +82,18 @@ const toolbarConfig: ToolbarConfig = {
showFullscreen: true, // 显示全屏按钮
showTimeScale: true, // 显示时间刻度按钮组
timeScaleDimensions: [ // 显示所有时间刻度维度
'hour', 'day', 'week', 'month', 'quarter', 'year',
'hour', 'day', 'week', 'month', 'quarter', 'year'
],
defaultTimeScale: 'week', // 默认选中周视图
showExpandCollapse: false, // 显示展开/折叠按钮
showExpandCollapse: false // 显示展开/折叠按钮
}
const newTask = ref({
name: '',
startDate: '',
endDate: '',
})
endDate: ''
});
const addTask = () => {
tasks.value.push({
@@ -90,10 +102,10 @@ const addTask = () => {
startDate: newTask.value.startDate,
endDate: newTask.value.endDate,
progress: 0,
})
newTask.value = { name: '', startDate: '', endDate: '' }
showAddTaskDrawer.value = false
}
});
newTask.value = { name: '', startDate: '', endDate: '' };
showAddTaskDrawer.value = false;
};
const addMilestone = () => {
milestones.value.push({
@@ -102,40 +114,89 @@ const addMilestone = () => {
startDate: newTask.value.startDate,
progress: 0,
type: 'milestone',
icon: 'diamond',
})
icon: 'diamond'
});
console.log('milestones: ', milestones.value)
newTask.value = { name: '', startDate: '', endDate: '' }
showAddMilestoneDialog.value = false
newTask.value = { name: '', startDate: '', endDate: '' };
showAddMilestoneDialog.value = false;
}
const onTaskDblclick = (task) => {
alert(`双击任务: ${task.name}`)
}
const onTaskClick = (task) => {
alert(`单击任务: ${task.name}`)
}
const onMilestoneDblclick = (milestone) => {
alert(`双击里程碑: ${milestone.name}`)
}
// 任务行拖拽完成事件(可选)
const handleTaskRowMoved = async (payload: {
draggedTask: Task
targetTask: Task
position: 'after' | 'child'
oldParent: Task | null
newParent: Task | null
}) => {
const { draggedTask, targetTask, position, oldParent, newParent } = payload
// 组件已自动完成任务移动、parentId更新和TaskList/Timeline同步
// 监听此事件为完全可选,仅用于:
// 1. 显示自定义提示消息
const oldParentName = oldParent?.name || '根目录'
const newParentName = newParent?.name || '根目录'
const positionText = position === 'after' ? '在目标任务之后' : '作为目标任务的子任务'
alert(`任务 [${draggedTask.name}] 已从 [${oldParentName}] 移动到 [${newParentName}] (${positionText})`, 'success')
// 2. 调用后端 API 保存新的任务层级关系
try {
await api.updateTaskHierarchy({
taskId: draggedTask.id,
targetTaskId: targetTask.id,
position: position,
oldParentId: oldParent?.id,
newParentId: newParent?.id,
})
} catch (error) {
console.error('保存任务层级失败:', error)
alert('保存失败,请刷新页面')
}
// 3. 触发其他业务逻辑(如更新关联数据、记录操作日志等)
// ...
}
</script>
<template>
<div>
<div style="height: 600px;">
<GanttChart
:tasks="tasks"
<GanttChart
:tasks="tasks"
:milestones="milestones"
:task-list-config="taskListConfig"
:toolbar-config="toolbarConfig"
:use-default-drawer="false"
:use-default-milestone-dialog="false"
:locale-messages="customMessages"
:allow-drag-and-resize="true"
:enable-task-row-move="true"
@task-row-moved="handleTaskRowMoved"
@add-task="showAddTaskDrawer = true"
@add-milestone="showAddMilestoneDialog = true"
@task-double-click="onTaskDblclick"
@task-click="onTaskClick"
@milestone-double-click="onMilestoneDblclick"
>
</GanttChart>
</div>
<!-- 自定义添加任务按钮 -->
<div>
<button class="btn btn-primary" @click="showAddTaskDrawer = true">添加任务</button>
<button class="btn btn-primary" @click="showAddMilestoneDialog = true">添加里程碑</button>
</div>
<!-- 自定义抽屉组件 (原生HTML替代 el-drawer) -->
<div v-if="showAddTaskDrawer" class="drawer-overlay" @click="showAddTaskDrawer = false">
<div class="drawer-container" @click.stop>
@@ -143,24 +204,24 @@ const onMilestoneDblclick = (milestone) => {
<h3>自定义添加任务组件</h3>
<button class="close-btn" @click="showAddTaskDrawer = false">×</button>
</div>
<div class="drawer-body">
<div class="form-item">
<label>任务名称:</label>
<input v-model="newTask.name" type="text" placeholder="请输入任务名称" />
</div>
<div class="form-item">
<label>开始日期:</label>
<input v-model="newTask.startDate" type="date" />
</div>
<div class="form-item">
<label>结束日期:</label>
<input v-model="newTask.endDate" type="date" />
</div>
</div>
<div class="drawer-footer">
<button class="btn btn-primary" @click="addTask">确定</button>
<button class="btn btn-default" @click="showAddTaskDrawer = false">取消</button>
@@ -169,9 +230,9 @@ const onMilestoneDblclick = (milestone) => {
</div>
<!-- 自定义Dialog组件基于element plus -->
<el-dialog
v-model="showAddMilestoneDialog"
title="自定义添加里程碑组件 - Element Plus"
<el-dialog
title="自定义添加里程碑组件 - Element Plus"
v-model="showAddMilestoneDialog"
width="400px"
@close="newTask = { name: '', startDate: '', endDate: '' }"
>
@@ -191,7 +252,7 @@ const onMilestoneDblclick = (milestone) => {
<el-button @click="addMilestone">确定</el-button>
<el-button @click="showAddMilestoneDialog = false">取消</el-button>
</template>
</el-dialog>
</el-dialog>
</div>
</template>
@@ -342,4 +403,4 @@ const onMilestoneDblclick = (milestone) => {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
</style>
</style>

View File

@@ -1,6 +1,6 @@
{
"name": "jordium-gantt-vue3",
"version": "1.4.3",
"version": "1.4.5",
"type": "module",
"main": "dist/jordium-gantt-vue3.cjs.js",
"module": "dist/jordium-gantt-vue3.es.js",

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" />

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"

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>

View File

@@ -0,0 +1,287 @@
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()
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 开始拖拽任务:', task.name)
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) {
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 跳过:不能拖拽到自己身上')
clearDropTarget()
return
}
// 不能拖拽到自己的子任务上
if (isDescendant(dragState.value.draggedTask, task)) {
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 跳过:不能拖拽到自己的子任务上')
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')
}
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 悬停在任务上:', task.name, 'position:', position)
if (options.onDragOver) {
options.onDragOver(task, position)
}
}
const clearDropTarget = () => {
if (dragState.value.dropTargetTask) {
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 清除放置目标')
}
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
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] endDrag - dropTargetTask:', dropTargetTask?.name, 'dropPosition:', 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) {
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 放置任务:', draggedTask.name, '到', dropTargetTask.name, 'position:', dropPosition)
options.onDrop(draggedTask, dropTargetTask, dropPosition)
} else {
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 结束拖拽,没有有效的放置目标')
}
// 重置状态
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)
// eslint-disable-next-line no-console
console.log('[TaskRowDrag] 检测到taskRow:', 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,
}
}

137
src/utils/taskTreeUtils.ts Normal file
View File

@@ -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,
}
}