likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 9

背景

  • 本文章建议搭配 源码一起食用,因为有些代码过长的地方,没有贴到文档里。

实现

初始化甘特图实例

  • 用一个 Div 初始化甘特图实例

    • import React, { useEffect, useRef } from 'react';
      import { gantt } from 'dhtmlx-gantt';
      import "dhtmlx-gantt/codebase/skins/dhtmlxgantt_material.css";
      
      export default function App() {
          const ganttContainer = useRef();
      
          useEffect(() => {
              registerLightBox();  // 自定义模态框
              setGanttConfig();
              setDateMarker();
              setColumns();
              setZooms();
      
              formatData();	   // 将数据处理成我们需要的集合
              gantt.init(ganttContainer.current);
              gantt.parse(mockData);
          }, []);
      
          return (
              <div
                  ref={ (ref) => { ganttContainer.current = ref; } }
                  style={ { width: '100%', height: '100%' } }
                  />
          );
      }
      
  • 将数据处理成我们之后需要的集合

    • treeMap 为记录树的父级包含的子级顺序的映射,之后在新增同级与子级需要使用
    •   // 将数据处理成我们需要的集合
        function formatData() {
          const taskTree = {};
        
          mockData.data.forEach((item) => {
            if (!taskTree[item.parent]) {
              taskTree[item.parent] = [];
            }
            taskTree[item.parent].push(item);
          });
        
          _treeMap.current = taskTree;
        }
      

配置 甘特图 config

  • 对 甘特图 进行配置
    • 配置 一些基础样式,比如 行高、自适应、本地化等等

    • 如果要配置任务可进行拖拽排序,那么 gantt.config.order_branch 一定得有值

    • function setGanttConfig() {
          gantt.config.row_height = 32;	                        // 行高
          gantt.config.date_format = "%Y-%m-%d %H:%i";		// 日期转换格式
          gantt.i18n.setLocale("cn");				// 语言
          gantt.config.autosize = "y";			        // 甘特图是否自适应
          gantt.config.work_time = true;			// 工作日模式
          gantt.locale.labels.new_task = '新任务';		// 新建任务时的默认label(不过如果是自定义的新增模态框的话,就用不到这个)					
      
          gantt.config.order_branch = "marker";			// 允许在同一级别内重排任务,只要是需要重排,order_branch 一定得给 true 或 ‘marker’
          gantt.config.order_branch_free = true;		// 允许在甘特图全部范围内重排任务,如果只给 order_branch_free,而不给 order_branch,排序也是不会生效的。
      
          gantt.plugins({					// 导入插件
            marker: true					// 日期标识插件,如果不装,之后在配置日期标识线会报错
          });
          
          gantt.config.layout = {...}				// 布局(代码较多,可去文档中或源码中查看以及复制)
          
      	... //(下面继续)
      }
      
    • 设置 甘特图 时间列的 class 类名,用于配置禁用日期的样式

      • 使用 gantt.templates.timeline_cell_class 这个 api
      • 具体 api 文档:docs.dhtmlx.com/gantt/api__…
      • 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
      •   gantt.templates.timeline_cell_class = (task, date) => {
              // 判断当前视图是否不为 日视图
              const disableHighlight = ["month", "year", "quarter"].includes(
                  _curZoom.current
              );
        
              // 如果当前视图不为 日视图 且 该日期为周末 则给禁用样式
              // TODO: 之后不止要判断周末,要对所有法定节假日进行判断,需要获取到当年的所有节假日配置进 gantt 中
              if (!disableHighlight && !gantt.isWorkTime(date)) return "week_end";
              return "";
           };
        
        
    • 设置 任务的 class 类名,用于配置 任务完成时的 样式

      • 使用 gantt.templates.task_class 这个 api
      • 具体 api 文档:docs.dhtmlx.com/gantt/api__…
      • 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
      • // 设置 任务的 class 类名,用于配置 任务完成时的 样式
        gantt.templates.task_class = (start, end, task) => {
            if (task.progress === 1)
              return "completed_task";
            return "";
        };
        
    • 如果 在甘特图的表格中需要渲染 React 组件时

      • 使用 gantt.config.external_render 这个 api

      • 具体 api 文档: docs.dhtmlx.com/gantt/api__…

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

      • 主要是为了下面会讲的自定义新增,因为自定义新增需要使用到 antd 的下拉组件

      • // 配置 可以让表格渲染 用 react 组件
        gantt.config.external_render = {
            // checks the element is a React element
            isElement: (element) => {
              return React.isValidElement(element);
            },
          // renders the React element into the DOM
            renderElement: (element, container) => {
              ReactDOM.render(element, container);
            },
        };
        

切换时间视图

  • 在 config 文件中配置好 视图

    • 配置视图 最重要的属性为 scales,这个是配置时间刻度的属性

      • 具体文档:docs.dhtmlx.com/gantt/api__…
      • scales 是一个数组,一个元素即为一行时间刻度
        • 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
      • unit 为时间单位,比如 日、周、月
      • step 为时间刻度的长度,默认为 1,如果 unit 为 日的话,step 为 1 时,即为 1日
      • format 为对时间刻度进行格式化
        • 可以像下面例子一样,自己用 html 标签格式化
        • 也可以使用 dhtmlx 自己定义的 格式化规则,具体规则文档:docs.dhtmlx.com/gantt/deskt…
    • export const zoomLevels = [
        {
          name: "day",									// 视图代码
          label: "日",									   // 视图名称
          min_column_width: 30,							// 列的最小宽度
          scale_height: 70,							    // 时间刻度的行高
          scales: [										// 时间刻度行配置
            { unit: "month", format: "%Y年 %F" },
            {
              unit: "day",
              step: 1,
              format: (date) => {
                const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
                const day = new Date(date).getDate();
                const weekDay = new Date(date).getDay();
                return `<div class='scale-formate-date'>
                <span class='formate-date'>${day}</span>
                <span class='formate-weekDay'>${weekDays[weekDay]}</span>
                </div>`;
              }
            }
          ]
        },
        ... 	// 代码较多,这里只展示时间单位为日的,剩下的可以去文档或源码进行查看以及复制
      ];
      
  • 渲染用于切换时间视图的按钮

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

    • function renderZoomButton() {
          return (
              <div>
                  {zoomLevels.map((item) => {
                      return (
                          <Button
                              type="primary"
                              disabled={item.name === curZoom}
                              onClick={() => {
                                  handleChangeZoom(item.name);
                              }}
                              style={{ marginRight: 6 }}
                              >
                              {item.label}
                          </Button>
                      );
                  })}
              </div>
          );
      }
      
  • 点击按钮的时候,触发更改甘特图视图的方法

    • function handleChangeZoom(zoom) {
          setCurZoom(zoom);
          _curZoom.current = zoom;
          gantt.ext.zoom.setLevel(zoom);
      }
      

设置当前日期标识

  • 在前面的配置中,设置了 gantt.plugins({ marker: true })
    • 在这里就可以对当前日期进行表示配置

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

    • function setDateMarker() {
          const dateToStr = gantt.date.date_to_str(gantt.config.task_date);
          const today = new Date(new Date().setHours(0, 0, 0, 0)); // 获取当天零点的时间
          gantt.addMarker({
              start_date: today,
              css: "today",
              text: "今日",
              title: `Today: ${dateToStr(today)}`,
          });
      }
      

甘特图表格增加展示列

  • 甘特图有三个原始属性,即不能更改其属性code
    • text:任务名称
    • start_time:开始日期
    • duration:持续时间
    • 这三列是 dhtmlx-gantt 组件在实现计算任务所必须的,所以这三个属性必须存在
      • [
            {
                type: 'input',				// 类型
                name: "showCode",				// 属性代码
                label: "任务序号",				// 属性名称
                tree: true,				// 以树状格式展示该列,并展示伸展树形结构的箭头
                width: 100,
                min_width: 100,
            },
            {
                type: 'input',
                name: "text",
                originField: 'text',			// 原始属性标识(用于之后做属性code替换)
                label: "任务名称",
                width: '*',				// 当 width 给 * 时,代表自适应宽度
                min_width: 160,
            },
            {
                type: "date",
                name: "start_date",
                originField: "start_date",		// 原始属性标识(用于之后做属性code替换)
                label: "开始日期",
                align: "center",				// 列内容的布局
                width: 80,
                min_width: 80
            },
            {
                type: "number",
                name: "duration",
                originField: "duration",		       // 原始属性标识(用于之后做属性code替换)
                label: "持续时间",
                align: "center",
                min: 1,
                formatType: "date"
            },
            ...	   // 剩下的 column 过多,可以去源码中查看与复制
        ]
        
  • 将列设置进 dhtmlx-gantt 组件中
    • 如果某一列有重渲染的需求,给列增加一个 onrender 的属性方法,在方法里配置好需要渲染的样式

    • // 设置网格列
      function setColumns() {
          const tempColumns = columns.map(item => {
              const { name, originField, options, type: colType } = item;
              const temp = { ...item };
      
              // 如果是 select,在表格中展示 label 文字
              if (colType === 'select') {
                  temp.onrender = (task, node) => {
                      if (task[name]) {
                          node.innerText = options.filter(cur => cur.value === task[name])[0].label;
                      }
                  };
              } else if (colType === 'slider') {	// 如果是 进度,将其转为百分比格式
                  temp.onrender = (task, node) => {
                      node.innerText = `${Math.round(task[name] * 100)}%`;
                  };
              } else if (colType === 'date' && originField === 'end_date') {    // 如果是 结束日期,显示需要减一天
                  temp.onrender = (task, node) => {
                      if (task[name]) {
                          const date = moment(task[name]).subtract(1, 'days');
                          node.innerText = date.format('YYYY-MM-DD');
                      }
                  };
              }
      
              gantt.config.columns = tempColumns;
        }
      
    • 我们之前做了可以在表格中渲染 react 组件的配置,那我们这里可以将 dhtmlx-gantt 自带的新增,重新渲染成我们需要的自定义新增的样式

      • 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
      • 下面代码 放进 setColumns 方法中即可
      •       // 当 列是 增加列时, 渲染 下拉 menu 组件
              if (name === 'add') {
                temp.onrender = (task) => {
          
                  return (
                    <Dropdown
                      overlay={(
                        <Menu
                          className='operation-menu-wrapper'
                          onClick={(cur) => { menuClick(cur, task) }}
                        >
                          {
                            operationMenu.map(cur => {
                              if (cur.key === 'delete') {
                                return (
                                  <>
                                    <Divider type='horizontal' />
                                    <Menu.Item
                                      key={cur.key}
                                      className={cur.key}
                                    >
                                      {cur.label}
                                    </Menu.Item>
                                  </>
                                )
                              } else {
                                return (
                                  <Menu.Item
                                    key={cur.key}
                                    className={cur.key}
                                  >
                                    {cur.label}
                                  </Menu.Item>
                                )
                              }
                            })
                          }
                        </Menu>
                      )}
                    >
                      <div className='add-icon'> + </div>
                    </Dropdown>
                  )
                }
              }
          
              return temp;
          })
        
    • 如果有需求,像低代码这边一样,需要根据用户配置替换属性 code 时,就需要一个原始属性的标识

      • 我给的标识属性是 originField,然后将 新codeoriginField 做映射,用 fieldMap 一个集合进行存储
      • 并且数据中需要同时拥有 新codeoriginField 属性对应的值,新code 的值给到后端,originField 的值给到 dhtmlx-gantt 组件进行计算
      • 下面代码 放进 setColumns 方法中即可
      • // 如果有 原始字段,将 动态更新后的字段还原回 原始字段
        // 并将其映射关系 记录在 fieldMap 中
        if (originField) {
            fieldMap.current[originField] = name;
            temp.name = temp.originField;
            
        }
        
      • 当然,如果只是单纯的使用这套干净的组件,这段代码是完全不需要的

自定义新增(新增同级与子级)

  • 点击新增同级与新增子级时,会触发 menuClick 方法
    • 主要使用 gantt.createTask 这个api 方法,具体文档为:docs.dhtmlx.com/gantt/api__…
      • 3个参数分别是 新建任务、父级任务、以及 新增任务在父级任务子级中要在的位置的 index
      • 新建任务 其实只需要一个 id,因为之后其他属性会在 模态框中添加
      • 如果不给 index,会默认作为最后一个子级
    •   // 下拉 menu 的 点击事件
        function menuClick(item, task) {
          const { key } = item;
        
          // 新增任务的 专属 id
          const id = _uuid.current + 1;
          const tempTask = {
            id
          };
          _uuid.current = id;
        
          // 获取当前新增任务 应该在的 index
          const index = _treeMap.current[task.parent].findIndex(
            (cur) => cur.id === task.id
          );
        
          // 点击 新建本级时
          if (key === "add-bro") {
            // 创建任务,在当前任务的下一个位置
            gantt.createTask(
              tempTask,
              task.parent !== "0" ? task.parent : undefined,
              index + 1
            );
            setBroIndex(index + 1);
          } else if (key === "add-child") {
            // 点击 新建子级时
            gantt.createTask(tempTask, task.id);
          } else if (key === "delete") {
            // 点击删除时,弹出提示框
            showDeleteConfirm(task);
          }
        }
      

自定义模态框

  • gantt.createTask 这个方法会直接打开模态框,如果我们需要自定义模态框去新增任务时,需要重新注册自定义的模态框

  • 首先引入模态框组件

    •     <Modal
              visible={visible}
              onCancel={handleModalCancel}
              footer={renderFooter()}
              destroyOnClose
              title="新建/编辑任务"
              className="edit-task-modal"
              >
              <Form
                  initialValues={curTask}
                  onValuesChange={handleFormChange}
                  onFinish={handleModalSave}
                  ref={formRef}
                  >
                  {renderFormList()}		// 渲染form表单控件(详细代码可以看源码)
              </Form>
          </Modal>
      
  • 然后我们写一个 注册新模态框进入 dhtmlx-gantt 中的方法,然后放到 useEffect 里去执行

    • 其实最主要的是在 gantt.showLightBox 中将模态框的 visible 改为 true,其他代码主要是对数据做处理
    •   // 注册自定义 任务弹出框
        function registerLightBox() {
          // 打开 弹出框事件
          gantt.showLightbox = () => {
            ...   // 代码过多,可以从源码中查看与复制
        
            setVisible(true);
            gantt.resetLayout(); // 重置表格 布局,即新建任务的时候,可以看到新建的任务
          };
        
          // 关闭 弹出框事件
          gantt.hideLightbox = () => {
            setVisible(false);
            setMaxCount();
          };
        }
      
  • 注册好后我们触发 gantt.createTask 方法时,就会打开新的模态框了

  • 接下来完善一下 form 表单控件的 onChange 方法

    • 主要是完善一些控件之间的联动,比如 开始日期持续时间和结束日期 以及 进度和状态
    •   // 任务模态框 表单值更新
        function handleFormChange(value, allValue) {
          // 如果 开始日期 或 持续时间 的值变动了,需要更新 结束日期
          if (value.start_date || value.duration) {
            const { start_date } = allValue;
            let { duration } = allValue;
        
            // 如果这次更新的时 start_date, 需要重新计算 duration 的上限
            if (value.start_date) {
              const durationLimit = handleCalcMax(allValue);
        
              // 当 duration 上限存在 并且 duration 大于上限时, duration 等于上线
              if (durationLimit && duration > durationLimit) {
                duration = durationLimit;
              }
        
              // 更新 duration
              formRef.current.setFieldsValue({
                duration
              });
            }
        
            const endDate = gantt.calculateEndDate(new Date(start_date), duration);
            endDate.setDate(endDate.getDate() - 1); // 联动更新完 结束日期要减一
        
            formRef.current.setFieldsValue({
              end_date: endDate
            });
          } else if (value.progress) {
            // 进度和状态更改了,都要去修改另一项
            const status = value.progress === 1 ? "finish" : "continue";
            formRef.current.setFieldsValue({
              task_status: status
            });
          } else if (value.task_status) {
            const progress = value.task_status === "finish" ? 1 : 0;
            formRef.current.setFieldsValue({
              progress
            });
          }
        }
      
  • 最后完成 模态框的保存方法

    • 因为 antd 表单控件的选择日期组件,返回的是 moment 对象,而 dhtmlx-gantt 需要的日期是 date 对象,我们需要在保存任务前转一下
    •     // 新增 修改 任务保存
          function handleModalSave(formValue) {
            const isNewFlag = curTask.isNew || curTask.$new;
        
            const newTask = {
              ...curTask,
              ...formValue,
              isNew: isNewFlag,
              isEdit: !isNewFlag
            };
        
            // 当有 moment 对象时 转为 date 对象
            Object.keys(newTask).forEach((key) => {
              if (moment.isMoment(newTask[key])) {
                newTask[key] = newTask[key].toDate();
              }
            });
        
            // 因为打开模态框之前,将父级转换为对象了,但这里只需要 对象的 value 做判断
            const originParent = curTask.parent;
            const { parent } = newTask;
        
            // 计算 tindex 如果为新增本级,那么就是之前存的 broIndex, 如果是添加子级,直接用子级长度作为 index
            const parentLength = _treeMap.current[parent]?.length;
            const tindex = parentLength
              ? addType === "bro"
                ? broIndex
                : parentLength
              : 0;
        
            const endDate = new Date(newTask.end_date);
            endDate.setDate(endDate.getDate() + 1); // 确认任务时 结束日期加一天
            newTask.end_date = endDate;
        
            // 如果存在 $new 则代表是新建的
            if (newTask.$new) {
              delete newTask.$new;
              // 先添加任务,在重排
              gantt.addTask(newTask, parent, tindex);
              newUpdateSortCode(newTask.id, parent, tindex, newTask);
            } else {
              if (originParent !== parent) {
                newUpdateSortCode(newTask.id, parent, tindex, undefined, newTask);
              } else {
                gantt.updateTask(newTask.id, newTask);
                updateTreeMapItem(newTask.parent, newTask.id, newTask);
            }
            }
        
            setVisible(false);
            setMaxCount();
            setAddType("");
            setBroIndex(0);
            gantt.resetLayout(); // 重置表格 布局,即新建任务的时候,可以看到新建的任务
          }
      
    • 这里有几个方法
      • newUpdateSortCode 重新排序任务
      • updateTreeMapItem 更新 treeMap 里的数据
      • 这两个都是一些稍微复杂、与重排有关的逻辑,我们留到下一期详细讲
    • 顺便预告一下,在这个模态框保存方法中,下一期会增加任务被父级时间限制的处理 以及 对前置任务的处理

删除任务

  • 现在已经可以新增任务了,完善一下删除任务的逻辑
  • 首先在点击 下拉框中的 删除任务时,先弹出个确认框,让用户决定是否删除,防止误触
    •   // 显示 删除任务时的 确定提示
        function showDeleteConfirm(task) {
          Modal.confirm({
            title: '确定删除该任务吗?',
            content: '该任务的子任务将一并被删除,请确认',
            okText: '删除',
            okType: 'danger',
            cancelText: '取消',
            onOk() {
              handleModalDelete(task);
            },
          });
        }
      
  • 点击确认后,调用 gantt.deleteTask 方法即可删除任务以及其子级
    • deleteTask 方法需要任务的 id,具体 api 文档:docs.dhtmlx.com/gantt/api__…
    • 如果需要将删除的任务数据传给后端,需要用 deleteList 数组去存取一下,其实最好的是放到 onAfterTaskDelete 这个回调函数去做的,但是会存在一个问题,就是回调函数会触发两次,这个在其他回调函数也会出现这个情况。
      • 所以为了回避上述这个情况,我们就在这个方法里记录一下需要删除的数据
    • // 删除 任务
      function handleModalDelete(task) {
        const tempTreeMap = _treeMap.current;
      
        const tempTask = task || curTask;
        // 如果是 新建的任务
        if (tempTask.$new || tempTask.isNew) {
          gantt.deleteTask(tempTask.id);
        } else {
          // 将 任务记录到需要给后端
          deleteList.current.push(tempTask);
      
          // 如果存在子级, 子级也一起进入删除队列
          if (tempTreeMap[tempTask.id]) {
            deleteChildren(tempTreeMap[tempTask.id]);
          }
          gantt.deleteTask(tempTask.id);
        }
      
        // 找到 该任务的位置,并删除 treeMap 中的数据
        let originIndex = 0;
        tempTreeMap[tempTask.parent].forEach((item, index) => {
          if (item.id === tempTask.id) {
            originIndex = index;
          }
        });
        tempTreeMap[tempTask.parent].splice(originIndex, 1);
        _treeMap.current = tempTreeMap;
      
        // 更新所有任务 以及 生成新的 codeMap
        updateCodeMapAndTask(tempTreeMap);
      
        setVisible(false);
        setMaxCount();
        setCurTask({});
      }
      

注意日期的加减

  • 在代码中经常能看到 +1 天和 -1 天
    • 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
    • 这是因为甘特图组件用的日期格式是国外的,比如 开始日期是 8/17,结束日期为 8/17,这样刚好算 1天,如果结束日期是 8/18,就是两天了
    • 而我们国内,或者说我们公司要求的日期格式是 开始日期 8/17,结束日期 8/18 这样算1天
    • 所以计算的日期要与 甘特图组件的日期格式保持一致
      • 渲染的数据要进入模态框时,需要 +1 天,符合国内的日期展示格式
      • 保存任务后回到赶图的渲染就需要 -1 天,符合 dhtmlx-gantt 底层需要的日期格式
      • 手把手教你 在 React 使用 dhtmlx - gantt 实现 甘特图(上)
    • 当然如果不需要纠结这种日期格式,只需要和 dhtmlx-gantt 保持一致时,这些日期加减的代码全都不需要

总结

转载自:https://juejin.cn/post/7273096710826967092
评论
请登录