前期回顾
- 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
- 在上一期文章已经展示了部分功能,这一期我们继续讲述接下来的功能
- 这一期的功能主要以 任务之间的联动、任务序号重排等逻辑 来讲述,具体功能有以下几个
- 新建、拖拽后重排任务并更新任务序号
- 拖拽父级同时移动子任务
- 拖拽更新任务的开始日期、结束日期、进度
- 限制任务更新的日期范围
- 拖拽更新父任务时,不能小于子任务的日期
- 拖拽更新子任务时,不能超过父级的日期
- 任务之间链接(前置任务)
- demo 体验地址 :3h6qyr.csb.app/
- 源码地址 :codesandbox.io/s/gantt-dem...
- 本文档建议搭配 源码一起食用,因为有些代码过长的地方,没有贴到文档里。源码地址在文章最后
处理数据
-
因为这一期需要对任务进行重排,所以我们在
formatTask
那个方法里将我们的数据处理成我们之后需要的集合 -
首先需要改造一下
treeMap
,上一期只需要实现渲染甘特图的渲染,而这一期我们需要在重排的时候能拿到treeMap
中存储的任务原始数据- 需要将
startDate
和endDate
从字符串转换成Date
对象,以及根据 进度生成status
状态 - 最后需要
cloneDeep
深拷贝一下,因为这样才不会被dhtmlx-gantt
的更改任务方法污染原始数据
jsconst tempData = mockData; tempData.data.forEach((item) => { // 如果 外部的开始日期和结束日期都有值,更新成 0 点即可 const startDate = new Date(`${item.start_date} 00:00:00`); const endDate = new Date(`${item.end_date} 00:00:00`); endDate.setDate(endDate.getDate() + 1); // 在渲染页面的时候 结束日期 + 1天 item.start_date = startDate; item.end_date = endDate; const status = item.progress === 1 ? "finish" : "continue"; item.task_status = status; if (!taskTree[item.parent]) { taskTree[item.parent] = []; } taskTree[item.parent].push(item); }); _treeMap.current = cloneDeep(taskTree);
- 需要将
-
codeMap
为 记录任务序号 以及 该任务有多少子任务 的集合- 因为 没有 0 这个父级,即 根任务的父级,所以这里虚拟一个 0 任务的映射,以便记录 根任务的个数
js// 设置 codeMap // 因为在 treeMap 中没有最外层的数据,所以这里初始化一个最外层的对象 const tempCodeMap = { "0": { code: null, count: taskTree["0"]?.length } }; const newList = []; formatCodeMap(taskTree["0"], null, taskTree, tempCodeMap, newList); codeMap.current = { ...tempCodeMap }; tempData.data = newList return tempData;
-
formatCodeMap
将任务data
处理成codeMap
存取的格式jsfunction formatCodeMap( items, parentCode, tree, tempCodeMap, newList, level = 0 ) { if (!items) return; // 将 子代任务进行排序 items.sort((a, b) => { return a.code - b.code; }); // 遍历排序好的 子代 items.forEach((item, index) => { const { id } = item; // 如果有 父级code,生成新的 code 为 父级code.父级子任务数量 const code = parentCode ? `${parentCode}.${index + 1}` : String(index + 1); item.showCode = code; // 增加这三行 带$的属性 是为了 让甘特图新增完任务重排的时候顺序不乱 item.$index = index; item.$level = level; if (broIndex) item.$rendered_parent = item.parent; // 将更新了 code 的 item 传出去更新 newList.push(item); // 如果 tree[item.id] 存在,即为 该任务有子代,继续遍历 if (tree[item.id]) { tempCodeMap[id] = { count: tree[item.id].length, code }; formatCodeMap( tree[item.id], code, tree, tempCodeMap, newList, level + 1 ); } else { tempCodeMap[id] = { count: 0, code }; } }); }
-
我们还需要在
formatTask
这个方法里去根据有前置任务的任务处理出任务之间的链接- 建立
targetMap
存储作为目标的任务 与 链接的映射 - 建立
sourceMap
存储作为来源的任务 与 链接的映射
jsif (item.pre_task) { const source = item.pre_task; const target = item.id; const link = { id: `${source}-${target}`, type: "0", source, target }; tempLinks.push(link); _targetMap.current[target] = link; _sourceMap.current[source] = link; }
- 建立
添加新配置
-
这一期我们会讲 任务的移动,这里先在 setGanttConfig 将任务移动需要的配置加上
-
任务移动后的回调事件
- 在任务在移动后触发 所有任务重新排序的方法
js// 任务移动后的回调事件 gantt.attachEvent("onAfterTaskMove", (id, parent, tindex) => { newUpdateSortCode(id, parent, tindex); });
重排任务并更新任务序号
-
在上期,我们在新建任务的时候使用到了
newUpdateSortCode
这个方法,刚好我们刚刚也在setGanttConfig
这个方法里加入了移动后触发回调触发该方法的配置,即让任务进行重新排序-
在进行重排前,我们需要确定任务需要插入的位置
- 当
tindex
为0
时,即为插入这个层级的开头 - 当
tindex
等于这个层级任务的数量
时,即为插入这个层级的最后 - 其次我们要确定是否为同级拖拽 ,而且是否把自己拖到同级往后的位置
- 如果上面两个条件有一个没满足,就属于正常的拖入,
tindex
代表当前插入的位置。- 通过
tindex - 1
获取前一个任务的数据- 通过
tindex
获取原本在这个位置的任务,即插入后的后一个任务
- 通过
- 通过
- 如果上面两个条件都满足了,即为同级拖拽,且将自身拖拽到后面的位置,那么因为在插入的时候,原本自己还占着位子,所以
tindex + 1
才是当前要插入的位置。- 现在就要通过
tindex
获取前一个任务的数据 - 通过
tindex + 1
获取原本在这个位置的任务,即插入后的后一个任务
- 现在就要通过
- 当
-
这个方法用了 两个code,一个是用来展示的
showCode
,一个是用来真正排序的隐式 code
隐式code
是不进行重排计算的,因为他在同级中是唯一的showCode
会根据隐式code
排序好的顺序重新生成
-
那么如何将
隐式code
保持到在同级中唯一?- 首先获取插入位置的前一个任务,获取其
code
小数点后的精度 - 通过精度确定要在
前一个任务的 code
基础上增加的数值,精度为 1 时为0.1
,精度为 2 时为0.01
- 加完以后的
code
值不能等于插入位置后一个任务的 code
,如果相等,精度+ 1
后重新与前一个任务的 code
相加- 比如
前一个任务的 code
为0.1
,后一个任务的 code
为0.2
- 根据
前一个任务的 code
确定精度为1
,增加数值为0.1
,相加得出的新 code
为0.2
,但是等于后一个任务的 code
- 所以精度
+ 1
,增加数值变为0.01
,重新加出来的code
为0.11
,保证了code
的唯一,从而保证排序的时候顺序正确 - 这个唯一性只需要在同级保持就行,因为其
子级的 code
即使与自己的 code
一样,排序也排不到一起
- 比如
- 上面说的是插入两个任务之间的逻辑
- 如果是插入到最后一位,直接将
前一个任务的 code
加上其精度该加的数值
- 比如
0.12 + 0.01 = 0.13
- 比如
- 而插入到第一位时,直接将其
后一个任务的 code
的精度+ 1
后该加的数值作为code
- 比如
后一个任务的 code
为0.1
,精度为1
。+ 1
后精度为2
,该加的数值为0.01
,那么0.01
即为新 code
- 比如
- 首先获取插入位置的前一个任务,获取其
js// 新版 重排 任务用于排序的 code(隐式code 不重排,确保同级 code 唯一,然后显示code 只在前端渲染,给后端只传更改的数据) function newUpdateSortCode(id, parent, tindex, newTask, editTask) { ... // 获取其兄弟数组、原始父级、原始兄弟数组 等逻辑,代码过多,建议从源码中查看与复制 // 判断 拖拽任务 拖拽前的父级 是否与 拖拽后的父级一样,并且 originIndex 是否小于 当前index const indexFlag = originParent === parent && originIndex < tindex; // 如果 indexFlag 为 true 的话 tindex 要比往常多 1,因为是同级拖拽,前面的数据一道后面时,index 不比平常多 1的话,会导致数据取的不对 const beforeIndex = indexFlag ? tindex : tindex - 1; const afterIndex = indexFlag ? tindex + 1 : tindex; // 如果 拖拽到最后一个位置 if ( tindex > 0 && tindex === (originParent === parent ? broList.length - 1 : broList.length) ) { ... // 计算精度的代码,可以在源码中查看与复制 // 让 beforeCode 与 preNum 相加 即为 移动任务新的 code const moveCode = Number( BigNumber(beforeTask.code).plus(preNum).toString() ); moveTask.code = moveCode; } else if (tindex > 0) { // 如果不是在最后插入,并且 tindex > 0,即为在两个值之间插入了 ... // 计算精度的代码,可以在源码中查看与复制 // 如果 beforeCode + preNum === afterCode 时,需要提升精度 1 级精度 if ( BigNumber(preNum).plus(beforeTask.code).toString() === `${afterTask.code}` ) { precision += 1; preNum = generateNumber(precision); } // 让 beforeCode 与 preNum 相加 即为 移动任务新的 code moveCode = Number(BigNumber(preNum).plus(beforeTask.code).toString()); moveTask.code = moveCode; } else { // 以上两个都不满足的话,即为插入到第一个的位置 ... // 计算精度的代码,可以在源码中查看与复制 // 根据 code 小数点后的数量确定 小数精度 const precision = codeArr[1]?.length || 0; // 根据小数精度,确定需要增加的 Num 量 const preNum = generateNumber(precision + 1); const moveCode = Number(preNum.toFixed(precision + 1)); moveTask.code = moveCode; // 如果之前没有 broList,需要新建一个,并且更新到 tempTreeMap 中,用于之后添加 if (!broList.length) { tempTreeMap[parent] = []; broList = tempTreeMap[parent]; } } // 修改 移动任务的 parent 为 当前插入的 parent,并且编辑标识改为 true moveTask.parent = parent; // 将该任务 从原本存在的数组中 删除 if (tempTreeMap[originParent]) tempTreeMap[originParent].splice(originIndex, 1); // 在 要插入的数组中添加 broList.splice(tindex, 0, moveTask); _treeMap.current = tempTreeMap; // 更新所有任务 以及 生成新的 codeMap updateCodeMapAndTask(tempTreeMap); }
-
刚刚在上个方法将任务都重排完了,接下来在
updateCodeMapAndTask
这个方法里更新所有任务的序号- 使用
gantt.updateTask
更新任务 - 使用
gantt.batchUpdate
批量更新,保证更新的性能- api 文档: docs.dhtmlx.com/gantt/api__...
- 更新完以后,使用
gantt.resetLayout
重置下甘特图即可- api 文档: docs.dhtmlx.com/gantt/api__...
js// 更新 codeMap 以及 重排任务的显示序号 function updateCodeMapAndTask(treeMap) { const newList = []; // 因为在 treeMap 中没有最外层的数据,所以这里初始化一个最外层的对象 const tempCodeMap = { "0": { code: null, count: treeMap["0"]?.length } }; // 处理 任务成 codeMap, 并获得 更新过 code 的任务 formatCodeMap(treeMap["0"], null, treeMap, tempCodeMap, newList); codeMap.current = tempCodeMap; // 批量更新任务 gantt.batchUpdate(() => { newList.forEach((item) => { gantt.updateTask(item.id, item); }); }); gantt.resetLayout(); }
- 使用
-
拖拽父级同时移动子任务
-
首先,我们要确定 子级任务的开始日期 与 父级任务开始日期的 差值
- 因为父级与子级要一起拖拽,那么他们拖拽的时候唯一不会变的就是他们
开始日期之间的差值
js// 设置 父子任务一起拖拽 以及 拖拽范围 function setTaskDrag() { // 在拖拽前 获取 其与其子级开始日期的差值 gantt.attachEvent("onBeforeTaskDrag", calcOffsetDuration); }
- 因为父级与子级要一起拖拽,那么他们拖拽的时候唯一不会变的就是他们
-
在
calcOffsetDuration
方法中计算父级与其所有子级的开始日期差值-
使用
gantt.eachTask
获取该任务的全部子级- 第一个参数为 获取到子级后的回调函数
- 第二个参数为 该任务的 id
- api 文档为:docs.dhtmlx.com/gantt/api__...
-
使用
gantt.calculateDuration
计算两个日期之间的持续时间- api文档为: docs.dhtmlx.com/gantt/api__...
js// 给其任务的子级 计算开始日期之间的工作日天数的差值 function calcOffsetDuration(id) { const task = gantt.getTask(id); gantt.eachTask((child) => { const offsetDur = gantt.calculateDuration( task.start_date, child.start_date ); child.offsetDur = offsetDur; }, id); return true; }
-
-
当知道父级与子级开始日期的差值后,就可以实现拖拽逻辑了
-
拖拽的时候,会有几种模式
move
:任务移动,持续时间不变,开始日期和结束日期改变resize
:任务持续时间修改,开始日期和结束日期会有一个不变progress
:任务进度改变,其余不变
-
那么现在是同时移动父级和子级,那即为
move
模式js// 这一个方法 是用来实现 父子一起拖动的 gantt.attachEvent("onTaskDrag", (id, mode, task) => { const modes = gantt.config.drag_mode; const children = gantt.getChildren(id); // 当父级移动时 if (mode === modes.move) { // 遍历所有子级 gantt.eachTask((child) => { const { offsetDur, duration } = child; // 子级的开始日期为 父级开始日期 + 与父级开始日期的偏移量 const startDate = new Date(+task.start_date + offsetDur * 86400000); // 子级的结束日期为 开始日期 + 持续时间,这里不重算休息日 是因为最后会重算 const endDate = new Date(+startDate + duration * 86400000); // 设置子级数据并更新 child.start_date = startDate; child.end_date = endDate; gantt.refreshTask(child.id, true); }, id); } return true; });
-
拖拽更新任务,使用的是
resize
模式,涉及到resize
的时候多半需要限制任务更新的日期范围,所以我们放到下一节详细讲 -
如果拖拽的是任务的进度,使用的就是
progress
模式-
代码很简单,只需要在拖拽完成后,更新下任务,因为在任务拖拽进度后,
task
上的progress
属性已经更改了
js// 如果 拖拽的是 进度 if (mode === modes.progress) { gantt.updateTask(task.id, task); }
-
限制任务更新的日期范围
拖拽任务时限制
-
在刚刚上面的
onTaskDrag
方法中,主要是以 父任务为主视角的,所以我们在刚刚的方法里,增加一段限制父级任务更新日期不能小于子任务 的逻辑
*jselse if (mode === modes.resize) { // 当父级修改范围时 // 去获取子级的范围,父级无法缩小过子级的范围 for (let i = 0; i < children.length; i += 1) { const child = gantt.getTask(children[i]); if (+task.end_date < +child.end_date) { limitResizeLeft(task, child); } else if (+task.start_date > +child.start_date) { limitResizeRight(task, child); } } }
-
接下来要实现子任务拖拽移动不能超出父任务的日期范围
-
因为
dhtmlx-gantt
的底层做了eventListener
类似的处理,所以在使用gantt.attachEvent
时,可以像用原生的addEventListener
一样,写多个方法,会在底层合并到一起 -
在上一个
onTaskDrag
方法中,我们以 父任务为主视角 -
那么这一个
onTaskDrag
方法,我们以 子任务为主视角-
首先根据拖拽类型 确定要对 子任务使用的限制方法 ,即方法中的
limitMoveLeft
、limitMoveRight
、limitResizeLeft
、limitResizeRight
,分别对应move
和resize
模式的限制方法,具体方法可以到源码中查看并复制js// 拖拽 到父级开始节点的限制 function limitMoveLeft(task, limit) { ... // 具体代码可以到源码中查看并复制 } // 拖拽 到父级结束节点的限制 function limitMoveRight(task, limit) { ... // 具体代码可以到源码中查看并复制 } // 更改 子任务的开始时间 不能超过父任务的限制 function limitResizeLeft(task, limit) { task.end_date = new Date(limit.end_date); } // 更改 子任务的结束时间 不能超过父任务的限制 function limitResizeRight(task, limit) { task.start_date = new Date(limit.start_date); }
-
确定了限制方法后,我们需要将子任务的日期 与 父级的任务日期进行比较
- Tips :在比较的时候,将 Date 对象通过 +Date 的形式快速转换为时间戳会更好比较,比如
+parent.end_date
- Tips :在比较的时候,将 Date 对象通过 +Date 的形式快速转换为时间戳会更好比较,比如
-
如有超出,调用上面确定的 限制方法来保证 子任务的日期不超出父任务的范围
-
js// 这一个 方法是 限制拖拽范围,因为他底层 应该是做了 类似 eventListener 之类的操作,所以可以写两个方法,差分开来显得清晰 gantt.attachEvent("onTaskDrag", (id, mode, task) => { ... // 代码过多,可以到源码中查看与复制 // 根据 Mode 设置限制范围的方法 if (mode === modes.move) { limitLeft = limitMoveLeft; limitRight = limitMoveRight; const startDate = new Date(task.start_date); const endDate = gantt.calculateEndDate(startDate, task.duration); task.start_date = startDate; task.end_date = endDate; } else if (mode === modes.resize) { limitLeft = limitResizeLeft; limitRight = limitResizeRight; } // 将 parent 与 自己做判断 // +Date 为快速转换为 时间戳的方式 if (parent && +parent.end_date < +task.end_date) { limitLeft(task, parent); } if (parent && +parent.start_date > +task.start_date) { limitRight(task, parent); } return true; });
限制开始日期不为周末
-
在任务做了限制以后,我们还需要看其限制完的开始日期是否在周末
-
如果在周末,我们需要使用
delayStartDate
或delayChildStartDate
方法延迟到下一个工作日- 这个两个方法在以后有改进的空间,现在只能跳过周末,但是以后可以通过判断跳过节假日
- 两个方法的区别是
delayStartDate
给父级任务用,delayChildStartDate
给子级任务用,会多一个对工作日的遍历
js// 延迟 开始日期,如果是 周末则跳到周一,TODO: 之后支持跳过节假日,跳到节假日后的第一个工作日 export function delayStartDate(startDate) { const tempDate = new Date(startDate); while (tempDate.getDay() === 0 || tempDate.getDay() === 6) { tempDate.setDate(tempDate.getDate() + 1); } return tempDate; } // 延迟 子代的开始日期,duration 为与父级开始日期的 工作日天数差值 export function delayChildStartDate(parentStart, duration) { // 设置一个 新变量,让其不污染 parentStart const tempDate = new Date(parentStart); // 循环遍历工作日 for (let i = 0; i < duration; i += 1) { // 增加一天并跳过周末 tempDate.setDate(tempDate.getDate() + 1); // 如果当前日期是周末,则跳过 while (tempDate.getDay() === 0 || tempDate.getDay() === 6) { tempDate.setDate(tempDate.getDate() + 1); } } return tempDate; }
-
延迟完开始日期后,就可以更新任务到视图上了
- 利用
gantt.updateTask
更新任务 - 再调用
updateTreeMapItem
方法更新treeMap
,利于之后做其他逻辑之后,拖拽任务限制时间的逻辑就大功告成了
- 利用
js// 拖拽完成后的 回调事件 gantt.attachEvent("onAfterTaskDrag", (id, mode) => { const modes = gantt.config.drag_mode; // 获取 任务 和 父级 const task = gantt.getTask(id); const parent = task.parent && task.parent !== 0 ? gantt.getTask(task.parent) : null; if (mode === modes.move) { // 如果 开始时间到节假日 延迟到下一个工作日 const newStartDate = delayStartDate(task.start_date); const newEndDate = gantt.calculateEndDate(newStartDate, task.duration); task.start_date = newStartDate; task.end_date = newEndDate; // 如果父级存在,那么在拖动完后,进行重算时,不能超过父级的范围 if (parent && +parent.start_date > +task.start_date) { limitMoveRight(task, parent); } if (parent && +parent.end_date < +task.end_date) { limitMoveLeft(task, parent); } // 遍历所有子级 gantt.eachTask((child) => { // 限制 任务子级的 开始日期和结束日期 controlChildLimit(child, task, newStartDate); }, id); } else if (mode === modes.resize) { const newStartDate = delayStartDate(task.start_date); const newEndDate = gantt.calculateEndDate(newStartDate, task.duration); task.start_date = newStartDate; task.end_date = newEndDate; } // 当任务拖拽更改后 只要不是新增的 就增加 edit 标识 if (!task.isNew) { task.isEdit = true; } gantt.updateTask(task.id, task); updateTreeMapItem(task.parent, task.id, task); return true; });
-
模态框更新任务时限制
-
但是除了拖拽更新的时候需要限制日期范围,在使用模态框更新也同样需要限制日期范围
-
那么首先 我们在打开模态框的时候 ,需要使用
handleCalcMax
方法来计算 该任务持续时间的最大值。获取父级的结束日期,减去自己的开始日期,即为自己能增加到的最大持续时间
*js// 计算 持续时间最大值 function handleCalcMax(task) { const record = { ...(task || curTask) }; // 如果该任务为根任务,则不需要有最大值的限制 if (!record.parent) { setMaxCount(); return undefined; } // 获取父任务的结束日期 const parentTask = gantt.getTask(record.parent); const parentEndDate = new Date(parentTask.end_date); // 计算出 过滤了周末的 持续时间,即为 持续时间的最大值 const startDate = new Date(record.start_date); const diffDay = gantt.calculateDuration(startDate, parentEndDate); setMaxCount(Number(diffDay)); return Number(diffDay); }
-
其次在上期我们在模态框的
handleFormChange
方法时,也需要用到这个方法-
如果开始日期变动了,我们需要重新计算
jsif (value.start_date) { const durationLimit = handleCalcMax(allValue); // 当 duration 上限存在 并且 duration 大于上限时, duration 等于上线 if (durationLimit && duration > durationLimit) { duration = durationLimit; } // 更新 duration formRef.current.setFieldsValue({ duration }); }
-
如果父任务变动了,也需要重新计算,但不止要重新计算任务持续时间,还需要看任务是否在新任务的时间范围内,如果不在还需要修改开始日期
js// 如果 任务 不在父任务的范围内 if ( !( allValue.end_date <= parentEndDate && allValue.start_date >= parentTask.start_date ) ) { // 如果 任务原本的持续时间 大于 父任务的持续时间,任务的持续时间改为与父任务相等 if (tempTask.duration > parentTask.duration) { tempTask.duration = parentTask.duration; } // 获取父级的 startDate 并计算 任务修改到父任务日期范围内后的 endDate const startDate = parentTask.start_date; const endDate = gantt.calculateEndDate(startDate, tempTask.duration); endDate.setDate(endDate.getDate() - 1); // 重新更新 开始和结束日期 tempTask.start_date = startDate; tempTask.end_date = endDate; formRef.current.setFieldsValue(tempTask); } handleCalcMax(tempTask);
-
-
-
当然我们不止要在任务更新以后去限制持续时间,我们也要在选择开始日期前,限制能选择到的日期
-
在
date-picker
组件里,给一个handleDisabledDate
方法- 将父级的任务日期作为可选择日期
- 过于久远的日期不能选
- 周末不能选
- TODO:这个之后能做成节假日不能选,因为会有调休后的周末也得上班的情况
jsfunction handleDisabledDate(cur) { const formValue = formRef.current.getFieldsValue(); let parentLimit = false; // 如果任务存在父级,将父级的任务日期作为可选择日期 if (![null, 0, "0"].includes(formValue.parent)) { const parentTask = gantt.getTask(formValue.parent); const endDate = parentTask.end_date; endDate.setDate(endDate.getDate() - 1); parentLimit = cur.isBefore(dayjs(parentTask.start_date)) || cur.isAfter(dayjs(endDate)); } // 过于久远的日期也不能选择 const lowerLimit = cur.isBefore(dayjs("1970-01-01 00:00:00")) || cur.isAfter(dayjs("2038-01-01 00:00:01")); if (parentLimit) return true; if (lowerLimit) return true; // 周末也不能选 // TODO: 这里之后可以搞成节假日,因为有些周末调休后也得上班 if ([0, 6].includes(cur.day())) return true; }
-
在上期我们预告了在模态框保存任务中增加限制任务的逻辑
jsgantt.eachTask((child) => { // 限制 任务子级的 开始日期和结束日期 controlChildLimit(child, newTask, newTask.start_date); }, newTask.id);
-
在任务保存的时候,限制所有子级的开始日期和结束日期
-
和拖拽移动一样,与 父级任务开始日期的 差值是不会变的
-
因为差值不变,我们就可以根据父级的开始日期,计算子级新的开始日期,如果新的开始日期在周末就可以用到之前说的
delayChildStartDate
方法去延迟子级的开始日期
js// 限制 任务子级的 开始日期和结束日期 function controlChildLimit(child, task, parentStart, noChangeFlag) { // 如果子级的开始时间到 节假日了,也需要往后延迟到工作日 // 除此之外 还要和父级保持 相等的工作日天数差值 const childStartDate = delayChildStartDate(parentStart, child.offsetDur); // 更新 子级任务的数据 child.start_date = childStartDate; child.end_date = gantt.calculateEndDate(childStartDate, child.duration); // 更新任务 child.isEdit = true; // 如果传了 这个参数 就不去实时更新 // 主要是在 模态框里确定后使用的,在那里如果提前更新的话 会导致最后更新数据出现错行的问题 if (!noChangeFlag) { gantt.updateTask(child.id, child); updateTreeMapItem(child.parent, child.id, child); } }
-
-
任务之间链接(前置任务)
-
任务之间存在指向关系,比如任务A 是 任务B 的前置任务,任务A 则会指向任务 B
-
首先我们需要设置链接变动后的回调函数
-
使用
onAfterLinkAdd
回调方法,我们对链接添加后的逻辑进行处理 -
有几个不能添加上链接的场景
- 任务之间不能循环引用 ,比如 任务A 是 任务B 的前置任务,这时候原本存在一个 A 指向 B 的链接。再添加一个 B 指向 A 的链接就会导致任务之间指向循环,所以不允许
- 任务不允许有多个前置任务 ,比如 任务A 是任务 B 的前置任务,这时候 任务C 也要成为任务 B 的前置任务时就会报错,因为 任务B 已经有 任务 A 这个前置任务了。
- 不允许有重复链接 ,比如 任务A 是任务 B 的前置任务,这时候再添加一个 任务A 指向 任务 B 的链接,没有必要,纯多余,所以不允许
- 以上这几个场景都为公司要求的,我觉得除了重复链接是必要的限制场景外,其他的场景可以根据需求进行取舍
- 任务之间不能循环引用 ,比如 任务A 是 任务B 的前置任务,这时候原本存在一个 A 指向 B 的链接。再添加一个 B 指向 A 的链接就会导致任务之间指向循环,所以不允许
-
当链接可以被添加时,与在 formatTask 方法里差不多,将新链接分别存入与 来源任务 和 目标任务 映射的集合中,最后更新目标任务即可
js// 设置 link 链接变动的时候的 回调函数 function setLinkChange() { // 链接添加后的回调事件 gantt.attachEvent("onAfterLinkAdd", (id, link) => { const { target, source } = link; const newId = `${source}-${target}`; // 查找 targetMap 看该任务 存不存在 已有的链接 const targetLink = _targetMap.current[target]; const sourceLink = _targetMap.current[source]; const nowLink = gantt.getLink(id); // 查找 来源节点的 link,如果 来源link的 source 等于 当前 target 时,代表任务循环引用了 if (sourceLink && sourceLink?.source === target) { // 不一致且存在链接的时候,不允许他拖拽上 if (nowLink) { gantt.deleteLink(id); message.warning( "任务之间不能循环引用,该任务的前置任务不能是其后置任务!" ); return true; } } else if (targetLink) { // 看一下是否和当前的 来源是否不一致 或 链接的来源为 这次的目标,这即为循环引用,不允许 if (targetLink.source !== source) { // 不一致且存在链接的时候,不允许他拖拽上 if (nowLink) { gantt.deleteLink(id); message.warning( "该任务已有前置任务,如需关联,请先删除该任务的关联关系!" ); } } else if (id !== newId) { // 如果来源一致,即有可能重复链接了 if (nowLink) { gantt.deleteLink(id); message.warning("该任务已链接此前置任务,无需再关联一次!"); } } } else { // 如果不存在 // 更新 新增好的 Link 的 id const newLink = { ...link, id: newId }; _targetMap.current[target] = newLink; _sourceMap.current[source] = newLink; gantt.changeLinkId(id, newId); // 然后更新 目标组件 const targetTask = gantt.getTask(target); targetTask.pre_task = String(source); targetTask.isEdit = true; gantt.updateTask(targetTask.id, targetTask); updateTreeMapItem(targetTask.parent, targetTask.id, targetTask); } return true; }); }
-
链接添加了以后,就得有链接删除的逻辑
- 在 onAfterLinkDelete 方法中,进行判断 当链接的前置任务 与 映射集合中的相等,并且 id 相同,即允许删除
- 配置完后,双击链接弹出提示确定即可删除
js// 链接删除后的回调函数 gantt.attachEvent("onAfterLinkDelete", (id, item) => { const { target, source } = item; const newId = `${source}-${target}`; const preLink = _targetMap.current[target]; // 如果 targetMap 中存在这个 link,并且 这个 id 是我们拼接好的 id,不是组件自己生成的 id 时 才去删掉 if (preLink?.source === source && id === newId) { // 将其删掉 delete _targetMap.current[target]; delete _sourceMap.current[source]; // 找到 link 指到的目标任务 // 将该任务的 前置任务清空 const targetTask = gantt.getTask(target); targetTask.pre_task = undefined; targetTask.isEdit = true; gantt.updateTask(targetTask.id, targetTask); updateTreeMapItem(targetTask.parent, targetTask.id, targetTask); } });
-
完成了配置逻辑后,在图表中就可以直接拖拽链接生成了,不过还需要在模态框保存的时候,对链接进行增删
jsif (newTask.pre_task) { const { id, pre_task: preTask } = newTask; // 设置 link const tempLink = { id: `${preTask}-${id}`, source: preTask, target: id, type: "0" }; // 如果 targetMap 中不存在,直接 添加 link if (!_targetMap.current[id]) { _targetMap.current[id] = tempLink; _sourceMap.current[preTask] = tempLink; gantt.addLink(tempLink); } else { // 如果 targetMap 中存在 const preLink = _targetMap.current[id]; // 看一下存的 source 是否和 当前前置任务一致,不一致的时候 if (preLink.source !== preTask) { gantt.deleteLink(preLink.id); _targetMap.current[id] = tempLink; _sourceMap.current[preTask] = tempLink; gantt.addLink(tempLink); newTask.pre_task = preTask; // setDynFieldValue(newTask, 'pre_task', preTask); } } } else { // 如果保存的任务 没有配置前置任务 const { id, pre_task: preTask } = newTask; const preLink = _targetMap.current[id]; // 查看是否存在于 targetMap 中,如果存在,即这次为清空前置任务,需要删掉 link if (_targetMap.current[id]) { gantt.deleteLink(preLink.id); delete _targetMap.current[id]; delete _sourceMap.current[preTask]; } }
-
总结
- demo 体验地址 :3h6qyr.csb.app/
- 源码地址 :codesandbox.io/s/gantt-dem...
- dhtml-gantt 案例地址
- dhtml-gantt API 文档地址
- dhtml-gantt React 中引用使用文档
- 这一期结束后,基本甘特图的拖拽移动、拖拽更新、拖拽新增链接、限制任务时间的功能应该都能实现了。当然 dhtmlx-gantt 还不止这些功能,还有很多功能大家可以去尝试使用。
- 这篇文章里也有很多还可以继续优化的,比如延迟开始时间应该跳过节假日的等等,因为本人在公司只需要用到上面这些功能,我就单独整理出了这篇文章。希望借我抛出的砖,可以引出大家的玉。
- 之后如果有时间进行优化也会出一个番外篇来,欢迎大家积极发表意见。那么手把手教你在 react 使用 dhtmlx-gantt 实现甘特图先暂告一段落。
- 文章中可能会有错误与遗漏,也欢迎大家指正与讨论。