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

背景

  • 公司最近在弄低代码,要求我在短时间内赶出一个甘特图组件供低代码使用,所以从掘金和 github 上搜索好用的组件,最后发现 dhtmlx-gantt 非常自由,可拓展性强,而且 api 完善,就决定使用 dhtmlx-gantt 来实现。
  • 甘特图主要具备的功能
    • 对表格进行自定义配置
      • 给特殊日期以及特殊任务特定样式(在配置中调用 api 对日期、任务进行判断后赋予 class 类名)
      • 表格渲染使用 react 组件(主要用于下面的自定义新增)
    • 切换时间视图(日、周、月、年)
    • 设置当前日期标识
    • 甘特图表格增加展示列
      • 自定义新增渲染(下拉框选择)
    • 自定义新增(新增同级与子级)
    • 自定义模态框
    • --------------下面这些功能下期讲--------------
    • 新增时子级任务的开始日期、持续日期、结束日期不能超过父任务的
    • 同时拖拽父级和子级任务
    • 新建、拖拽后重排任务并更新任务序号
    • 拖拽任务更改开始时间以及持续时间
      • 拖拽子任务不能超过父级的日期
    • 任务之间链接(前置任务)
  • 因为功能比较多,我会分期进行讲述,这一期先以甘特图能正常进行增删改任务来讲述
  • demo 体验地址3h6qyr.csb.app/
  • 源码地址codesandbox.io/s/gantt-dem...
  • 本文章建议搭配 源码一起食用,因为有些代码过长的地方,没有贴到文档里。

实现

初始化甘特图实例

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

    jsx 复制代码
    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 为记录树的父级包含的子级顺序的映射,之后在新增同级与子级需要使用
    js 复制代码
      // 将数据处理成我们需要的集合
      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 一定得有值

    jsx 复制代码
    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 类名,用于配置禁用日期的样式

      js 复制代码
        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 类名,用于配置 任务完成时的 样式

      js 复制代码
      // 设置 任务的 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__...

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

      js 复制代码
      // 配置 可以让表格渲染 用 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 是一个数组,一个元素即为一行时间刻度
      • unit 为时间单位,比如 日、周、月
      • step 为时间刻度的长度,默认为 1,如果 unit 为 日的话,step 为 1 时,即为 1日
      • format 为对时间刻度进行格式化
        • 可以像下面例子一样,自己用 html 标签格式化
        • 也可以使用 dhtmlx 自己定义的 格式化规则,具体规则文档:docs.dhtmlx.com/gantt/deskt...
    js 复制代码
    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>`;
            }
          }
        ]
      },
      ... 	// 代码较多,这里只展示时间单位为日的,剩下的可以去文档或源码进行查看以及复制
    ];
  • 渲染用于切换时间视图的按钮

    jsx 复制代码
    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>
        );
    }
  • 点击按钮的时候,触发更改甘特图视图的方法

    jsx 复制代码
    function handleChangeZoom(zoom) {
        setCurZoom(zoom);
        _curZoom.current = zoom;
        gantt.ext.zoom.setLevel(zoom);
    }

设置当前日期标识

  • 在前面的配置中,设置了 gantt.plugins({ marker: true })

    • 在这里就可以对当前日期进行表示配置

    js 复制代码
    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 组件在实现计算任务所必须的,所以这三个属性必须存在
      *

      js 复制代码
      [
          {
              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 的属性方法,在方法里配置好需要渲染的样式

    js 复制代码
    // 设置网格列
    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 自带的新增,重新渲染成我们需要的自定义新增的样式

      • 下面代码 放进 setColumns 方法中即可
      jsx 复制代码
            // 当 列是 增加列时, 渲染 下拉 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 方法中即可
      js 复制代码
      // 如果有 原始字段,将 动态更新后的字段还原回 原始字段
      // 并将其映射关系 记录在 fieldMap 中
      if (originField) {
          fieldMap.current[originField] = name;
          temp.name = temp.originField;
          
      }
      • 当然,如果只是单纯的使用这套干净的组件,这段代码是完全不需要的

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

  • 点击新增同级与新增子级时,会触发 menuClick 方法

    • 主要使用 gantt.createTask 这个api 方法,具体文档为:docs.dhtmlx.com/gantt/api__...
      • 3个参数分别是 新建任务、父级任务、以及 新增任务在父级任务子级中要在的位置的 index
      • 新建任务 其实只需要一个 id,因为之后其他属性会在 模态框中添加
      • 如果不给 index,会默认作为最后一个子级
    js 复制代码
      // 下拉 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 这个方法会直接打开模态框,如果我们需要自定义模态框去新增任务时,需要重新注册自定义的模态框

  • 首先引入模态框组件

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

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

    • 主要是完善一些控件之间的联动,比如 开始日期持续时间和结束日期 以及 进度和状态
    js 复制代码
      // 任务模态框 表单值更新
      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 对象,我们需要在保存任务前转一下
    js 复制代码
        // 新增 修改 任务保存
        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 里的数据
      • 这两个都是一些稍微复杂、与重排有关的逻辑,我们留到下一期详细讲
    • 顺便预告一下,在这个模态框保存方法中,下一期会增加任务被父级时间限制的处理 以及 对前置任务的处理

删除任务

  • 现在已经可以新增任务了,完善一下删除任务的逻辑

  • 首先在点击 下拉框中的 删除任务时,先弹出个确认框,让用户决定是否删除,防止误触
    *

    js 复制代码
      // 显示 删除任务时的 确定提示
      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 这个回调函数去做的,但是会存在一个问题,就是回调函数会触发两次,这个在其他回调函数也会出现这个情况。
      • 所以为了回避上述这个情况,我们就在这个方法里记录一下需要删除的数据
    js 复制代码
    // 删除 任务
    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 天
    • 这是因为甘特图组件用的日期格式是国外的,比如 开始日期是 8/17,结束日期为 8/17,这样刚好算 1天,如果结束日期是 8/18,就是两天了
    • 而我们国内,或者说我们公司要求的日期格式是 开始日期 8/17,结束日期 8/18 这样算1天
    • 所以计算的日期要与 甘特图组件的日期格式保持一致
      • 渲染的数据要进入模态框时,需要 +1 天,符合国内的日期展示格式
      • 保存任务后回到赶图的渲染就需要 -1 天,符合 dhtmlx-gantt 底层需要的日期格式
    • 当然如果不需要纠结这种日期格式,只需要和 dhtmlx-gantt 保持一致时,这些日期加减的代码全都不需要

总结

相关推荐
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
小牛itbull3 小时前
ReactPress:构建高效、灵活、可扩展的开源发布平台
react.js·开源·reactpress
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
小牛itbull8 小时前
ReactPress – An Open-Source Publishing Platform Built with React
前端·react.js·前端框架
贵州晓智信息科技11 小时前
深入理解 React 架构从概览到核心机制
前端·react.js·架构
September_ning13 小时前
JavaScript的展开运算符在React中的应用
javascript·react.js
前端小王hs13 小时前
react-markdown标题样式不生效问题
前端·javascript·react.js·前端框架·前端小王hs
键盘上的蚂蚁-13 小时前
duxapp放弃了redux,在duxapp状态实现方案
前端·javascript·react.js
前端小王hs13 小时前
react-markdown内容宽度溢出和换行不生效问题
前端·javascript·react.js·前端框架·前端小王hs