手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(下)

前期回顾

  • 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
  • 在上一期文章已经展示了部分功能,这一期我们继续讲述接下来的功能
  • 这一期的功能主要以 任务之间的联动、任务序号重排等逻辑 来讲述,具体功能有以下几个
    • 新建、拖拽后重排任务并更新任务序号
    • 拖拽父级同时移动子任务
      • 拖拽更新任务的开始日期、结束日期、进度
    • 限制任务更新的日期范围
      • 拖拽更新父任务时,不能小于子任务的日期
      • 拖拽更新子任务时,不能超过父级的日期
    • 任务之间链接(前置任务)
  • demo 体验地址3h6qyr.csb.app/
  • 源码地址codesandbox.io/s/gantt-dem...
  • 本文档建议搭配 源码一起食用,因为有些代码过长的地方,没有贴到文档里。源码地址在文章最后

处理数据

  • 因为这一期需要对任务进行重排,所以我们在 formatTask 那个方法里将我们的数据处理成我们之后需要的集合

  • 首先需要改造一下 treeMap,上一期只需要实现渲染甘特图的渲染,而这一期我们需要在重排的时候能拿到 treeMap 中存储的任务原始数据

    • 需要将 startDateendDate 从字符串转换成 Date 对象,以及根据 进度生成 status 状态
    • 最后需要 cloneDeep 深拷贝一下,因为这样才不会被 dhtmlx-gantt 的更改任务方法污染原始数据
    js 复制代码
    const 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 存取的格式

    js 复制代码
    function 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 存储作为来源的任务 与 链接的映射
    js 复制代码
    if (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 这个方法里加入了移动后触发回调触发该方法的配置,即让任务进行重新排序

    • 在进行重排前,我们需要确定任务需要插入的位置

      • tindex0 时,即为插入这个层级的开头
      • 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 相加
        • 比如 前一个任务的 code0.1后一个任务的 code0.2
        • 根据 前一个任务的 code 确定精度为 1,增加数值为 0.1,相加得出的新 code0.2,但是等于后一个任务的 code
        • 所以精度 + 1,增加数值变为 0.01,重新加出来的 code0.11,保证了 code 的唯一,从而保证排序的时候顺序正确
        • 这个唯一性只需要在同级保持就行,因为其子级的 code 即使与自己的 code 一样,排序也排不到一起
      • 上面说的是插入两个任务之间的逻辑
      • 如果是插入到最后一位,直接将前一个任务的 code 加上其精度该加的数值
        • 比如 0.12 + 0.01 = 0.13
      • 而插入到第一位时,直接将其后一个任务的 code 的精度 + 1 后该加的数值作为 code
        • 比如后一个任务的 code0.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 这个方法里更新所有任务的序号

      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 获取该任务的全部子级

    • 使用 gantt.calculateDuration 计算两个日期之间的持续时间

    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 方法中,主要是以 父任务为主视角的,所以我们在刚刚的方法里,增加一段限制父级任务更新日期不能小于子任务 的逻辑
    *

    js 复制代码
    else 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 方法,我们以 子任务为主视角

      • 首先根据拖拽类型 确定要对 子任务使用的限制方法 ,即方法中的 limitMoveLeftlimitMoveRightlimitResizeLeftlimitResizeRight,分别对应 moveresize 模式的限制方法,具体方法可以到源码中查看并复制

        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
      • 如有超出,调用上面确定的 限制方法来保证 子任务的日期不超出父任务的范围

    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;
    });

限制开始日期不为周末

  • 在任务做了限制以后,我们还需要看其限制完的开始日期是否在周末

    • 如果在周末,我们需要使用 delayStartDatedelayChildStartDate 方法延迟到下一个工作日

      • 这个两个方法在以后有改进的空间,现在只能跳过周末,但是以后可以通过判断跳过节假日
      • 两个方法的区别是 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 方法时,也需要用到这个方法

      • 如果开始日期变动了,我们需要重新计算

        js 复制代码
        if (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:这个之后能做成节假日不能选,因为会有调休后的周末也得上班的情况
      js 复制代码
      function 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;
      }
  • 在上期我们预告了在模态框保存任务中增加限制任务的逻辑

    js 复制代码
    gantt.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 的链接,没有必要,纯多余,所以不允许
      • 以上这几个场景都为公司要求的,我觉得除了重复链接是必要的限制场景外,其他的场景可以根据需求进行取舍
    • 当链接可以被添加时,与在 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);
            }
          });
    • 完成了配置逻辑后,在图表中就可以直接拖拽链接生成了,不过还需要在模态框保存的时候,对链接进行增删

      js 复制代码
      if (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 实现甘特图先暂告一段落。
  • 文章中可能会有错误与遗漏,也欢迎大家指正与讨论。
相关推荐
小牛itbull1 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress
FinGet13 小时前
那总结下来,react就是落后了
前端·react.js
王解16 小时前
Jest项目实战(2): 项目开发与测试
前端·javascript·react.js·arcgis·typescript·单元测试
AIoT科技物语1 天前
免费,基于React + ECharts 国产开源 IoT 物联网 Web 可视化数据大屏
前端·物联网·react.js·开源·echarts
初遇你时动了情1 天前
react 18 react-router-dom V6 路由传参的几种方式
react.js·typescript·react-router
番茄小酱0011 天前
ReactNative中实现图片保存到手机相册
react native·react.js·智能手机
王解1 天前
Jest进阶知识:深入测试 React Hooks-确保自定义逻辑的可靠性
前端·javascript·react.js·typescript·单元测试·前端框架
小牛itbull1 天前
ReactPress—基于React的免费开源博客&CMS内容管理系统
前端·react.js·开源·reactpress
~甲壳虫2 天前
react中得类组件和函数组件有啥区别,怎么理解这两个函数
前端·react.js·前端框架
用户8185216881172 天前
react项目搭建create-router-dom,redux详细解说
react.js