一、使用须知

1. 目前在flow-product-web中使用的实施策略

自定义Table + DHTMLX的gantt视图部分,手动同步两部分的数据与视图。

工作重难点:需要掌握DHTMLX甘特图的API,实现联动的交互逻辑与视图。

2. 基本概念

效果图

%E7%94%98%E7%89%B9%E5%9B%BEv0.2%E7%A4%BA%E4%BE%8B.png

  • 支持基线等行内多条线的显示;
  • 支持mark line的绘制;
  • 支持4个种类的link line的绘制;
  • 内置Tooltip插件;
  • 周末高亮、自定义UI;
  • ……

基础使用

我们从一个简单的Demo入手来了解gantt, scale, marker, link等概念:

<!DOCTYPE html>

<head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8">
    <title>Demo</title>
    <script src="../../codebase/dhtmlxgantt.js?v=8.0.6"></script>
    <link rel="stylesheet" href="../../codebase/dhtmlxgantt.css?v=8.0.6">
    <link rel="stylesheet" href="../common/controls_styles.css?v=8.0.6">
    <script src="../common/data_baselines.js?v=8.0.6"></script>
</head>

<body>
    <div class="gantt_control">
        <input type='button' value='展开' onclick='openAllGrid()'>
        <input type='button' value='折叠' onclick='closeAllGrid()'>
        <input type="radio" id="scale1" class="gantt_radio" name="scale" value="day" checked>
        <label for="scale1">Day scale</label>

        <input type="radio" id="scale2" class="gantt_radio" name="scale" value="week">
        <label for="scale2">Week scale</label>

        <input type="radio" id="scale3" class="gantt_radio" name="scale" value="month">
        <label for="scale3">Month scale</label>

        <input type="radio" id="scale4" class="gantt_radio" name="scale" value="quarter">
        <label for="scale4">Quarter scale</label>

        <input type="radio" id="scale5" class="gantt_radio" name="scale" value="year">
        <label for="scale5">Year scale</label>
    </div>
    <div id="gantt_here" style='width:100%; height:100%;'></div>
    <script>
        // 注册用到的插件
        gantt.plugins({
            marker: true,
            tooltip: true
        });

        gantt.config.date_format = "%Y-%m-%d %H:%i:%s";
        // 设置甘特图任务条(task bar)的高度
        gantt.config.bar_height = 26;
          // 设置甘特图的行高
        gantt.config.row_height = 50;

        // 全部展开
        function openAllGrid() {
            gantt.batchUpdate(function () {
                var tasks = gantt.getTaskByTime();
                for (var i = 0; i < tasks.length; i++) {
                    var task = tasks[i];
                    gantt.open(task.id);
                    gantt.updateTask(task.id);
                }
            });
        }
        // 全部折叠
        function closeAllGrid() {
            gantt.batchUpdate(function () {
                var tasks = gantt.getTaskByTime();
                for (var i = 0; i < tasks.length; i++) {
                    var task = tasks[i];
                    gantt.close(task.id);
                    gantt.updateTask(task.id);
                }
            });
        }

                // 配置不同的视图模式(不同的scales)
        var zoomConfig = {
            levels: [
                {
                    name: "day",
                    scale_height: 50,   // 顶部日期Header的高度
                    min_column_width: 80,
                    scales: [
                        { unit: "day", step: 1, format: "%d %M" }
                    ]
                },
                {
                    name: "week",
                    scale_height: 50,
                    min_column_width: 50,
                    scales: [
                        {
                            unit: "week", step: 1, format: function (date) {
                                var dateToStr = gantt.date.date_to_str("%d %M");
                                var endDate = gantt.date.add(date, -6, "day");
                                var weekNum = gantt.date.date_to_str("%W")(date);
                                return "#" + weekNum + ", " + dateToStr(date) + " - " + dateToStr(endDate);
                            }
                        },
                        { unit: "day", step: 1, format: "%j %D" }
                    ]
                },
                {
                    name: "month",
                    scale_height: 50,
                    min_column_width: 120,
                    scales: [
                        { unit: "month", format: "%F, %Y" },
                        { unit: "week", format: "Week #%W" }
                    ]
                },
                {
                    name: "quarter",
                    height: 50,
                    min_column_width: 90,
                    scales: [
                        { unit: "month", step: 1, format: "%M" },
                        {
                            unit: "quarter", step: 1, format: function (date) {
                                var dateToStr = gantt.date.date_to_str("%M");
                                var endDate = gantt.date.add(gantt.date.add(date, 3, "month"), -1, "day");
                                return dateToStr(date) + " - " + dateToStr(endDate);
                            }
                        }
                    ]
                },
                {
                    name: "year",
                    scale_height: 50,
                    min_column_width: 30,
                    scales: [
                        { unit: "year", step: 1, format: "%Y" }
                    ]
                }
            ]
        };
        gantt.ext.zoom.init(zoomConfig);
        gantt.ext.zoom.setLevel("day");   // 设置当前视图模式
          // 有许多内部事件可以监听
        gantt.ext.zoom.attachEvent("onAfterZoom", function (level, config) {
            document.querySelector(".gantt_radio[value='" + config.name + "']").checked = true;
        })
        function zoomIn() {
            gantt.ext.zoom.zoomIn();
        }
        function zoomOut() {
            gantt.ext.zoom.zoomOut()
        }
        var radios = document.getElementsByName("scale");
        for (var i = 0; i < radios.length; i++) {
            radios[i].onclick = function (event) {
                gantt.ext.zoom.setLevel(event.target.value);
            };
        }

        // [Pro] adding baseline display
        gantt.addTaskLayer({
            renderer: {
                render: function draw_planned(task) {
                    if (task.planned_start && task.planned_end) {
                        var sizes = gantt.getTaskPosition(task, task.planned_start, task.planned_end);
                        var el = document.createElement('div');
                        el.className = 'baseline';
                        el.style.left = sizes.left + 'px';
                        el.style.width = sizes.width + 'px';
                        el.style.top = sizes.top + gantt.config.bar_height + 13 + 'px';
                        el.dataset.planned_start = task.planned_start;
                        el.dataset.planned_end = task.planned_end;
                        return el;
                    }
                    return false;
                },
                // define getRectangle in order to hook layer with the smart rendering
                getRectangle: function (task, view) {
                    if (task.planned_start && task.planned_end) {
                        return gantt.getTaskPosition(task, task.planned_start, task.planned_end);
                    }
                    return null;
                }
            }
        });
                // 给任务条(task bar)添加自定义class
        gantt.templates.task_class = function (start, end, task) {
            if (task.planned_end) {
                var classes = ['has-baseline'];
                if (end.getTime() > task.planned_end.getTime()) {
                    classes.push('overdue');
                }
                return classes.join(' ');
            }
        };
                // 给任务条(task bar)添加右侧文本信息
        gantt.templates.rightside_text = function (start, end, task) {
            if (task.planned_end) {
                if (end.getTime() > task.planned_end.getTime()) {
                    var overdue = Math.ceil(Math.abs((end.getTime() - task.planned_end.getTime()) / (24 * 60 * 60 * 1000)));
                    var text = "<b>Overdue: " + overdue + " days</b>";
                    return text;
                }
            }
        };

        // Add markers
        var dateToStr = gantt.date.date_to_str('%Y年 %m月 %d日');    // return a function that formats the date
        var today = new Date('2023-11-01 00:00');
        gantt.addMarker({
            start_date: today,
            css: "today",
            text: "Today",
        });
        var start = new Date('2023-11-02 00:00');
        gantt.addMarker({
            start_date: start,
            css: "status_line",
            text: "Start"
        });

        // Add tooltip
        gantt.templates.tooltip_date_format = gantt.date.date_to_str('%Y年 %m月 %d日'); // return a function that formats the date
        gantt.attachEvent("onGanttReady", function () {
            var tooltips = gantt.ext.tooltips;

            // Add custom tooltip for baseline
            tooltips.tooltipFor({
                selector: ".baseline",
                html: function (event, node) {
                    return [
                        "<span style='font-size: 18px'>基准线</span>",
                        `<b>planned_start: ${dateToStr(new Date(node.dataset.planned_start))}</b>`,
                        `<b>planned_end: ${dateToStr(new Date(node.dataset.planned_end))}</b>`
                    ].join("<br>");
                }
            });

            // Add custom tooltip for today marker
            tooltips.tooltipFor({
                selector: ".today",
                html: function (event, node) {
                    return [
                        "<span style='font-size: 18px'>今天</span>",
                        `<b>当前日期: ${dateToStr(new Date())}</b>`,
                    ].join("<br>");
                }
            });

            // Add custom tooltip for start marker
            tooltips.tooltipFor({
                selector: ".status_line",
                html: function (event, node) {
                    return [
                        "<span style='font-size: 18px'>开始</span>",
                        `<b>当前日期: ${dateToStr(new Date('2018-04-02'))}</b>`,
                    ].join("<br>");
                }
            });
        });

        // format planned date
        gantt.attachEvent("onTaskLoading", function (task) {
            // [gantt.date.parseDate(date, format)]:
              // - converts a string of the specified format to a Date object
            task.planned_start = gantt.date.parseDate(task.planned_start, "xml_date");
            task.planned_end = gantt.date.parseDate(task.planned_end, "xml_date");
            return true;
        });

                // 将 gantt 组件挂载到 id 为 gantt_here 的标签上
        gantt.init("gantt_here");
        // 初始化数据
        gantt.parse(taskData);

    </script>
</body>

数据结构

var taskData = {
  "data": [
    {
      "id": "1",    // task 的唯一索引
      "start_date": "2018-04-01 00:00:00",  // task 的开始日期
      "duration": "5",  // task 持续多少天
      "text": "Project #1", // task bar 上显示的文字
      "progress": "0.8",    // 内部进度百分比
      "parent": "0",    // 父节点 id
      "deadline": "2018-04-09 00:00:00",
      "planned_start": "2018-04-01 00:00:00",   // 基线(baseline)的起始日期
      "planned_end": "2018-04-07 00:00:00", // 基线(baseline)的结束日期
      "open": 1   // 控制初始时父节点是否展开
    },
    {
      "id": "2",
      "start_date": "2018-04-06 00:00:00",
      "duration": "4",
      "text": "Task #1",
      "progress": "0.5",
      "parent": "1",
      "deadline": "2018-04-11 00:00:00",
      "planned_start": "2018-04-06 00:00:00",
      "planned_end": "2018-04-10 00:00:00",
      "open": 1
    }
  ],
  "collections": {
    "links": [
      {
        "id": "1",  // link 的唯一索引
        "source": "1",  // link 从 id 为 source 的 task 开始
        "target": "2",  // 到 id 为 target 的 task 结束
        "type": "0" // link 的 类型, 0|1|2|3
      },
      {
        "id": "2",
        "source": "1",
        "target": "3",
        "type": "0"
      }
    ]
  }
}

3. 常用操作

PS: 这些操作需要遵循如下顺序!

组件初始化

  1. 先注册使用到的插件:gantt.plugins({ ... });
  2. 然后进行一系列的配置,如设置scale、添加marker、添加baseline等额外视图(要在添加tooltip之前,否则tooltip会失效)
  3. 添加tooltip(要在添加baseline等额外视图之后,否则tooltip会失效)
  4. 最后进行组件挂载gantt.init("gantt_here");与数据初始化gantt.parse(taskData);

更新组件(全部数据)

  1. 清除已有数据if (gantt.$container) gantt.clearAll();
  2. 注册使用到的插件:gantt.plugins({ ... });
  3. 重新加载数据gantt.parse(taskData);
  4. 重新添加markers
  5. 进行addTaskLayer操作(要在添加tooltip之后,否则tooltip会失效)
  6. 重新添加自定义tooltip

二、存在的问题

1. (已解决)添加基线等内置功能需要付费解锁

  • 问题描述

    添加基线 或 在同一行中添加自定义的多条bar 需要使用addTaskLayer方法来将bar所对应的标签挂载到相应视图layer中。

    addTaskLayer方法在GPL社区版中没有,需要付费使用Pro版。

  • 解决方法

    先用getTask方法获取task对象,然后将task对象传给getTaskPosition方法获取task的位置信息后,手动添加baseline的div到DOM树的指定节点处(手动实现addTaskLayer的逻辑)——官方添加baseline的示例

    PS:更新数据与切换视图模式(日/月/年等)时需要手动从DOM中移除baseline相关的DOM节点。(已实现)

    PPS:展开/折叠后需要移除并重新添加一次(因为位置已经发生变化)(已实现)

    ![截屏2023-11-02 15.12.20](/Users/baizihan/Documents/截屏/截屏2023-11-02 15.12.20.png)

2. (已解决)滚动位置需要手动同步

  • 解决方法:
  // 点击 Task 触发 Table 滚动
    gantt.attachEvent('onTaskClick', function (id, e) {
      tableRef.value.$refs.scrollBarRef.setScrollTop(gantt.getScrollState().y);
      return true;
    });

  // 点击 row 时将 gantt 视图跳转至相应位置
  function handleRowClick(row) {
    const toX = gantt.posFromDate(new Date(row.preStartDate));
    const Y = getTableY();
    gantt.scrollTo(toX - X, Y);
    gantt.selectTask(row.id);
  }

  function getTableY() {
    const el = document.querySelector('div.guwave-table-gantt-wrapper div.el-scrollbar__wrap');
    return el.scrollTop;
  }

三、使用时的注意事项

  1. 每个节点必须有start_date和duration,否则gantt中不会显示对应的bar(塌陷),会导致Table和gantt无法对齐。
  2. gantt组件的节点之间以id属性作为key来区分。
  3. data中的open属性控制默认的(初始的)「展开(1)」和「收起(0)」状态。
  4. 每次gantt.clearAll()会清除所有数据,包括视图中的各种标记(markers),所以addMarker等操作需要在gantt.parse(data)之后重新执行一次。
  5. gantt.clearAll()之后,需要重新注册plugins,比如:
if (gantt.$container) gantt.clearAll();

gantt.plugins({
    marker: true,
    tooltip: true
});
  1. 【dhtmlx/gantt组件的bug】要给容器div设置一个固定的width,否则flex-grow: 1的动态调整宽度在某些情况下会失效。

  2. element-plus的Table组件实现受控折叠时,row-key的key要统一为string类型,不能是number,否则表现异常。另外,expandRowKeys数组只控制哪些行展开,只把expandRowKeys数组置空不能使所有行折叠,须通过toggleRowExpansion方法以及expand-change事件的处理方法来实现控制逻辑。

  3. 添加tooltip操作要在添加baseline等额外视图之后,否则tooltip会失效;另外添加tooltip的操作不要放在onGanttRender等事件的回调函数中,容易意外失效。

  4. 修改task的持续时间时,应修改end_date而不是duration

  5. dhtmlx/gantt组件会修改你parse的响应式数据,并且会添加以下额外属性($开头的)以供使用:

   {
       "id": 10456,
       "parent": 0,
       "text": "123",
       "duration": 1,
       "start_date": "2023-10-23T16:00:00.000Z",
       "planned_start": "2023-10-19 19:05:58",
       "planned_end": "2023-10-20 19:05:58",
       "open": 1,
       "end_date": "2023-10-24T16:00:00.000Z",
       "progress": 0,
       "$no_start": false,
       "$no_end": false,
       "$rendered_type": "task",
       "$calculate_duration": true,
       "$effective_calendar": "global",
       "$source": [
           "2"
       ],
       "$target": [
           "1"
       ],
       "$rendered_parent": 0,
       "$level": 0,
       "$local_index": 1,
       "$open": false,
       "$expanded_branch": true,
       "$resourceAssignments": [],
       "$index": 1
   }

四、常用方法与相关文档

1. 安装及引入

https://docs.dhtmlx.com/gantt/desktop__install_with_bower.html

# install module
npm install dhtmlx-gantt
// 引入依赖
import { gantt } from 'dhtmlx-gantt';
// 引入样式
import '@/assets/styles/dhtmlx/dhtmlxgantt.css';

2. 控制数据

更新任务 updateTask

https://docs.dhtmlx.com/gantt/api__gantt_updatetask.html

批量更新 batchUpdate

https://docs.dhtmlx.com/gantt/api__gantt_batchupdate.html

改变task的id changeTaskId

https://docs.dhtmlx.com/gantt/api__gantt_changetaskid.html

3. 控制视图

展开/折叠 open/close

https://docs.dhtmlx.com/gantt/api__gantt_open.html

获取task对象 getTask

https://docs.dhtmlx.com/gantt/api__gantt_gettask.html

获取指定task的位置信息 getTaskPosition

https://docs.dhtmlx.com/gantt/api__gantt_gettaskposition.html

获取指定日期的位置 posFromDate

https://docs.dhtmlx.com/gantt/api__gantt_posfromdate.html

获取当前滚动位置 getScrollState

https://docs.dhtmlx.com/gantt/api__gantt_getscrollstate.html

到达指定位置 scrollTo

https://docs.dhtmlx.com/gantt/api__gantt_scrollto.html

选中task(触发行高亮) selectTask

https://docs.dhtmlx.com/gantt/api__gantt_selecttask.html

Task点击事件 onTaskClick

https://docs.dhtmlx.com/gantt/api__gantt_ontaskclick_event.html

是否允许拖拽修改时间 drag_timeline

https://docs.dhtmlx.com/gantt/api__gantt_drag_timeline_config.html

自定义tooltip相关文档

https://docs.dhtmlx.com/gantt/desktop__tooltips_ext.html

添加自定义任务类型

https://docs.dhtmlx.com/gantt/samples/?sample=%2704_customization/12_custom_task_type.html%27&filter=%27custom%27

4. 相关链接

官方Demo

https://docs.dhtmlx.com/gantt/samples/?sample=%2701_initialization/01_basic_init.html%27&filter=%27%27

官方指引

https://docs.dhtmlx.com/gantt/desktop__guides.html

Standard vs Pro

https://docs.dhtmlx.com/gantt/desktop__editions_comparison.html

API文档

https://docs.dhtmlx.com/gantt/api__refs__gantt.html


A Student on the way to full stack of Web3.