甘特图插件 dhtmlx-gantt 的使用

5,099 阅读12分钟

dhtmlx-gantt 用法

  1. 安装 npm i dhtmlx-gantt
  2. 页面创建一个用来展示甘特图的盒子,给盒子设置 宽高 和 id
  3. 调用 gantt.init("ganttId") 初始化甘特图,传递容器id 或 容器dom;
  4. 调用 gantt.parse(tasks) 渲染图形,(传递的 tasks 是一个对象,内部的 data 属性,就是用来渲染甘特图的数据)

简单案例演示

简单渲染甘特图

注:甘特图的任务条数据对象中只要有 开始时间 和 结束时间/持续时间 ( start_dateend_date / duration ),就可以显示出来。

下面的模拟数据中传递了两条

  1. 第一个对象使用 start_date end_date,传递时间是 包含年-月-日 时:分:秒的日期格式对象,甘特图不配置日期格式,默认是日期对象格式;
  2. 第二个对象使用 start_date duration,传递时间中由于只有年月日,new Date() 后,时刻是所传递日期的 08:00:00(所以在上图中展示的任务条,开始不是从25号列左边的边框起始的),duration 字段代表这个任务的持续时间,不做配置的话默认是天。
    <template>
        <div id="ganttContainer" style="width: 1000px; height: 200px;"></div>
    </template>
    <script lang="ts" setup>
        import { ref, onMounted, onUnmounted, onDeactivated, h, onActivated, nextTick, watch } from 'vue';
        import { gantt } from 'dhtmlx-gantt';
        import "dhtmlx-gantt/codebase/dhtmlxgantt.css";

        const initGannt = () => {
            // ...这里写甘特图配置 
            gantt.init("ganttContainer");
        }

        const getRequestData = () => {
            // ...获取接口数据

            // 模拟数据
            const ganttData = [
                {
                    start_date: new Date("2024-12-23 00:00:00"),
                    end_date: new Date("2024-12-24 23:59:59"),
                },
                {
                    start_date: new Date("2024-12-25"),
                    // end_date: new Date("2024-12-26"),
                    duration: 1
                },
            ]
            // 渲染甘特图
            gantt.parse({
                data: ganttData
            })
        }

        onMounted(() => {
            initGannt()
            getRequestData()
        })
    </script>

简单配置项设置

甘特图部分属性配置

上图是对 gantt 的部分属性值以及数据做了修改后的显示效果,代码如下

    const initGannt = () => {
        // ...这里写甘特图配置

        // 语言设置-中文
        gantt.i18n.setLocale("cn");

        // 设置甘特图表格列的最小列宽
        gantt.config.mcolumn_width = 20;

        // 设置表格列,name为数据字段,label为表头显示的名称,width为列宽,align为对齐方式
        gantt.config.columns = [
            { name: "cCode", label: "学号", width: 80, align: "left" },
            { name: "cName", label: "名称", width: 50, align: "center" },
            { name: "cHeight", label: "身高", width: 30, align: "center" },
        ];

        // 设置右侧时间刻度相关属性,上方显示年月日,下方显示小时,每个格子代表12小时
        gantt.config.scales = [
            { unit: "day", format: "%Y-%m-%d" },
            { unit: "hour", step: 12, format: "%H" }
        ];

        // 设置任务条上展示的内容,参数task会返回当前任务的数据
        gantt.templates.task_text = function (start, end, task) {
            return task.cCode + "-" + task.cName + "-" + task.cHeight;
        };

        // 禁用任务条链接拖动功能
        gantt.config.drag_links = false;
        // 禁用任务条左右边缘拉动功能
        gantt.config.drag_resize = false;
        // 禁用任务条长按移动功能
        gantt.config.drag_move = false;
        // 禁用任务条拖动进度条功能
        gantt.config.drag_progress = false;
        // 设置甘特图滚动条的大小为25px
        gantt.config.scroll_size = 25;
        // 开启表格排序功能
        gantt.config.sort = true;
        // 渲染发生错误时不显示默认的错误弹框
        gantt.config.show_errors = false;

        gantt.init("ganttContainer");
    }

    const getRequestData = () => {
        // ...获取接口数据

        // 模拟数据
        const ganttData = [
            {
                start_date: new Date("2024-12-23 00:00:00"),
                end_date: new Date("2024-12-24 23:59:59"),
                cCode: "001",
                cName: "李四",
                cHeight: "180",
                progress: 0.6 // 当前任务进度
            },
            {
                start_date: new Date("2024-12-25"),
                // end_date: new Date("2024-12-26"),
                duration: 1,
                cCode: "002",
                cName: "张三",
                cHeight: "170",
                progress: 0.9
            },
        ]

        // 更新甘特图数据
        gantt.parse({
            data: ganttData
        })
    }

其它配置项

1. 改变颜色

添加类名改变任务条颜色

给甘特图渲染数据上添加自定义字段 cStatus,通过 gantt.templates.task_class 返回的字符串作为类名,添加到甘特图任务条的dom上,然后写对应类名的CSS样式来改变颜色。

    const initsetGannt = () => {
        // ...省略上面所做的配置项
            // 给任务条根据数据设置类名,实现颜色变化 (start:开始日期, end:开始日期, task:数据对象)
        gantt.templates.task_class = function (start, end, task) {
            if (task.cStatus == '已完成') {
                return "ywcTask";
            } else if (task.cStatus == '在执行') {
                return "zzxTask";
            } else {
                return "style0";
            }
        };

    }
    
    const getRequestData = () => {
        // ...获取接口数据

        // 模拟数据
        const ganttData = [
            {
                // ... 省略上面数据
                cStatus: '已完成'
            },
            {
                // ... 省略上面数据
                cStatus: '在执行'
            },
        ]
    }

    // 任务条添加类名改变样式
    <style lang="less" scoped>
    :deep(.gantt_task_line) {
        &.style0 {
            border: 1px solid #d9d9d9;
            background: #d9d9d9;
        }
        &.ywcTask {
            border: 1px solid #606060;
            background: #606060;
        }
        &.zzxTask {
            border: 1px solid #029f08;
            background: #029f08;
        }
    }
    </style>

2. lightBox

双击任务条弹窗lightBox展示转存失败,建议直接上传图片文件

双击任务条时,可以打开弹窗lightBox,下面给任务条渲染数据添加 machine 字段来显示到 lightBox 的机台内容中

        const initsetGannt = () => {
        // ...省略上面所做的配置项

        // lightbox 弹窗相关设置
        // map_to 用于将表单部分的输入值与任务对象的属性进行绑定。当用户在 Lightbox 中编辑表单部分时,输入的值会自动映射到指定的任务属性上。
        // name 显示为表单部分的标题,字段和 gantt.locale.labels.section_XXX 相对应,设置显示标题
        // height 设置表单部分的高度
        // type 设置表单部分的类型,可以是 template、time、time_range、textarea、select、checkbox、radio、color_picker、duration、time_picker、time_range、recurring、custom_table、customer_time_picker
        gantt.config.lightbox.sections = [
            { name: "orderNumber", height: 15, map_to: "orderNumber", type: "template" },
            { name: "machine", height: 15, map_to: "machine", type: "template" },
            { name: "matnrPlan", height: 30, map_to: "auto", type: "custom_table" },
            { name: "nPlanWgt", height: 15, map_to: "nPlanWgt", type: "template" },
            { name: "nCompWgt", height: 15, map_to: "nCompWgt", type: "template" },
            { name: "plan_date", height: 15, map_to: "auto", type: "customer_time_picker" }
        ];

        // 设置标题文字
        gantt.locale.labels.section_orderNumber = "销售订单号";
        gantt.locale.labels.section_machine = "机台";
        gantt.locale.labels.section_matnrPlan = "物料详情";
        gantt.locale.labels.section_nPlanWgt = "计划量";
        gantt.locale.labels.section_nCompWgt = "完成量";
        gantt.locale.labels.section_plan_date = "计划时间";


        // 底部按钮
        gantt.config.buttons_left = ["gantt_cancel_btn"];
        gantt.config.buttons_right = ["gantt_save_btn"];

        // gantt.form_blocks[XXX] 自定义表单部分组件
        gantt.form_blocks["customer_time_picker"] = {
            render: function (sns) {
                return "<div class='dhx_cal_ltext' style='height:30px; margin-left:10px;'>" +
                            "<input id='start_time' class='time_start' type='datetime-local' disabled></input>" +
                                "-" +
                            "<input id='end_time' class='time_end' type='datetime-local' disabled></input>" +
                        "</div>"
            },
            set_value: function (node, value, task) {
                function convertLocalTime(date: Date) {
                    let yyyy = date.getFullYear();
                    let MM = (date.getMonth() + 1) < 10 ? ("0" + (date.getMonth() + 1)) : (date.getMonth() + 1);
                    let dd = date.getDate() < 10 ? ("0" + date.getDate()) : date.getDate();
                    let HH = date.getHours() < 10 ? ("0" + date.getHours()) : date.getHours();
                    let mm = date.getMinutes() < 10 ? ("0" + date.getMinutes()) : date.getMinutes();

                    let curDay = yyyy + '-' + MM + '-' + dd + 'T' + HH + ':' + mm;
                    return curDay;
                }
                node.querySelector(".time_start").value = convertLocalTime(task.start_date);
                node.querySelector(".time_end").value = convertLocalTime(task.end_date);
            },
            get_value: function (node, task) {
                task.start_date = new Date(node.querySelector(".time_start").value);
                task.end_date = new Date(node.querySelector(".time_end").value);
                return task;
            },
            focus: function (node) {

            }
        };
        gantt.form_blocks["custom_template"] = {
            // render 用于渲染自定义表单块的 HTML 内容
            render: function (sns, task, section) {
                return "<div class='dhx_cal_ltext' style='height:24px; margin-left:10px;'>" +
                    "<input type='text' class='cpltAmt' style='width:100%; border:none; background-color:transparent;' readonly />" +
                    "</div>";
            },

            // set_value 用于设置表单块的值
            set_value: function (node, value, task) {
                var input = node.querySelector(".cpltAmt");
                if (input) {
                    // 如果值为 undefined 或 null,则将其设置为0
                    var displayValue = value == undefined || value == null ? "0" : value;
                    input.value = displayValue;
                } else {
                    task.cpltAmt = value;
                }
            },
            // get_value 用于获取表单块的值
            get_value: function (node, task) {
                // 无需实现此方法,因为输入框是只读的
                return task;
            },
            // 用于在 Lightbox 打开时将焦点设置到特定的表单块上
            focus: function (node) {
                // 无需实现此方法,因为输入框是只读的
            }
        };

        gantt.form_blocks["custom_select"] = {
            render: function (sns) {
                return "<div class='dhx_cal_ltext' style='height:20px; margin-left:10px;'>" +
                    "<select class='plnumStatus'></select>" +
                    "</div>";
            },
            set_value: function (node, value, task) {
                var select = node.querySelector(".plnumStatus");
                // 如果下拉框存在,则设置下拉框选项
                if (select) {
                    // 获取当前任务的状态值
                    var status = task.plnumStatus;
                    // 如果状态为"完成"或"执行中",将下拉框选项设置为对应的选项
                    if (status === "完成" || status === "执行中") {
                        select.innerHTML = "<option value='执行中'>执行中</option><option value='完成'>完成</option>";
                        // 设置下拉框的选中值
                        select.value = status;
                        // 设置下拉框的样式为可见
                        select.style.display = "block";
                        // 移除节点中的状态值
                        var span = node.querySelector("span");
                        if (span) {
                            span.remove();
                        }
                    } else {
                        // 如果状态为其他值,将下拉框选项置为空
                        select.innerHTML = "";
                        // 设置下拉框的选中值为当前任务的状态值
                        select.value = status;
                        // 设置下拉框的样式为不可见
                        select.style.display = "none";
                        // 在节点中添加当前任务的状态值
                        var span = node.querySelector("span");
                        if (span) {
                            span.innerHTML = status;
                        } else {
                            node.innerHTML += "<span>" + status + "</span>";
                        }
                    }
                } else {
                    // 如果下拉框不存在,将任务状态设置为节点的内容
                    task.plnumStatus = node.innerHTML;
                }
            },
            get_value: function (node, task) {
                var select = node.querySelector(".plnumStatus");
                // 如果下拉框存在,获取下拉框的选中值
                if (select) {
                    var value = select.value;
                    // 如果选中值为"完成"或"执行中",将任务状态设置为对应的值
                    if (value === "完成" || value === "执行中") {
                        task.plnumStatus = value;
                    }
                } else {
                    // 如果下拉框不存在,将任务状态设置为节点的内容
                    var span = node.querySelector("span");
                    if (span) {
                        task.plnumStatus = span.innerHTML;
                    }
                }
                return task;
            },
            focus: function (node) {
                var select = node.querySelector(".plnumStatus");
                if (select) {
                    select.focus();
                }
            }
        };
        gantt.form_blocks["custom_table"] = {
            render: function (sns) {
                // 注:这里不能使用反引号,否则会报错
                return "<div class='dhx_cal_ltext' style='height:100px; margin-left:5px;'>" +
                            "<table class='wuliaoTable' style='border: 1px solid black; border-collapse: collapse; width:100%;'>" +
                                "<thead>" +
                                    "<tr>" +
                                        "<th style='padding-left: 5px; border-right: 1px solid black;'>编号</th>" +
                                        "<th style='padding: 8px; padding-left: 8px; border-right: 1px solid black;'>名称</th>" +
                                        "<th style='padding: 8px; padding-left: 8px; border-right: 1px solid black;'>计划量</th>" +
                                        "<th style='padding: 8px; padding-left: 8px; border-right: 1px solid black;'>库存量</th>" +
                                        "<th style='padding: 8px; padding-left: 8px;'>缺量</th>" +
                                    "</tr>" +
                                "</thead>" +
                                "<tbody>"+
                                "</tbody>" +
                            "</table>" +
                        "</div>";
            },
            set_value: async function (node, value, task) {
                console.log('获取自定义表格node', node)
                var table = node.querySelector(".wuliaoTable");
                if (table) {
                    table.querySelector("tbody").innerHTML = "";
                    const WuliaoList = [1, 2, 3]
                    WuliaoList.forEach(item => {
                        var rowElement = document.createElement('tr');
                        var rowData = "<td style='padding-left: 5px; border-right: 1px solid black;'>" + '' + "</td>" +
                            "<td style='padding-left: 10px; border-right: 1px solid black;'>" + '' + "</td>" +
                            "<td style='padding-left: 10px; border-right: 1px solid black;'>" + '' + "</td>" +
                            "<td style='padding-left: 10px; border-right: 1px solid black;'>" + '' + "</td>" +
                            "<td style='padding-left: 10px; border-right: 1px solid black;'>" + '' + "</td>";
                        rowElement.innerHTML = rowData;
                        table.querySelector("tbody").appendChild(rowElement);
                    });
                }
            },
            get_value: function (node, task) {
                var table = node.querySelector(".wuliaoTable");
                if (table) {
                    var rows = table.querySelectorAll("tbody tr");
                    var data = [];
                    rows.forEach(row => {
                        var rowData = {
                            c_MTRL_NO: row.cells[0].textContent,
                            c_MATNRTEXT: row.cells[1].textContent,
                            c_PLAN_WGT: parseFloat(row.cells[2].textContent),
                            c_ERPLOCK_WGT: row.cells[3].textContent,
                            n_PICKING_WGT: row.cells[4].textContent,
                        };
                        data.push(rowData);
                    });
                    return data;
                }
                return task;
            },
            focus: function (node) {

            }
        };
    }

    const getRequestData = () => {
    // ...获取接口数据

    // 模拟数据
    const ganttData = [
        {
            // ... 省略上面数据
            machine: '机台1',
        },
        {
            // ... 省略上面数据
            machine: '机台2',
        },
    ]
}

3.事件监听

    const initsetGannt = () => {
    // ...省略上面所做的配置项

    // 任务条双击事件
    gantt.attachEvent("onTaskDblClick", (id, e) => {
        console.log('双击')
        return false; // 阻止默认行为
    })

    // 任务条单击事件
    gantt.attachEvent("onTaskClick", (id, e) => {
        console.log('单击')
        return false
    })

    // 任务条移动事件
    gantt.attachEvent("onMouseMove", function(id, e: MouseEvent) {
        // 移动到任务条上,id存在
        if (id) {

        }
        // 移动到甘特图其它区域,id不存在
        else {
            
        }
    });

    // 配置tooltip插件功能
    gantt.plugins({
        tooltip: true,
    });
    // 设置tooltip显示内容
    gantt.templates.tooltip_text = function(start, end, task) {
        return `
            <div>
                <strong>完成量:</strong> ${task.nCompWgt}<br>
                <strong>计划量:</strong> ${task.nPlanWgt}
            </div>
        `;
    };

    // 设置甘特图行的类名,可以改变样式
    gantt.templates.grid_row_class = gantt.templates.task_row_class = function (start, end, task) {
        if (task.$index % 2 == 0) {
            return "custom_row";
        }
    };

}
    

4. 在一行显示多个任务条

要在一行显示多个任务条,需要在甘特图的数据中建立父子关系

每一个,任务条数据添加3个字段 id parent render

  1. id 属性不设置默认甘特图插件会自动生成,每条数据的id唯一;
  2. parent 用来指定当前数据的父级id,parent等于某个id时,就代表这条数据是这个对应parent的子级;
  3. render 设置为 split,表示任务条可以分段显示,内部容纳的子级任务条,它们平铺显示,不会挤到一块,此值设置给父级;

一行显示多个任务条

  • 数据如下,设置了两个父级。第一个父级id为 f1,有两个子级;第二个父级id为 f2,有一个子级。并且父级没有设置时间,所以不会显示出来;

  • 父级的数据会作为左侧表格的数据,子级的数据作为任务条的数据。这里在模拟数据中将子级的 cName 字段值添加了 '-子级' 区分,如图子级的该值展示到图形的任务条中,父级该值展示到左侧表格中


        // 模拟数据
        const getRequestData = () => {
        // ...获取接口数据

        const ganttData = [
            // 第一行
            {
                cCode: "001",
                cName: "李四",
                cHeight: "180",
                cStatus: '已完成',
                id: 'f1',
                parent: null,
                render: 'split'
            },
            {
                cCode: "001",
                cName: "李四-子级",
                cHeight: "180",
                start_date: new Date("2024-12-25"),
                duration: 1,
                cStatus: '在执行',
                id: 'c1',
                parent: 'f1',
                render: null
            },
            {
                cCode: "001",
                cName: "李四-子级",
                cHeight: "180",
                start_date: new Date("2024-12-26"),
                duration: 1,
                cStatus: '已完成',
                id: 'c2',
                parent: 'f1',
                render: null
            },
            // 第二行
            {
                cCode: "002",
                cName: "张三",
                cHeight: "170",
                cStatus: '已完成',
                id: 'f2',
                parent: null,
                render: 'split'
            },
            {
                cCode: "002",
                cName: "张三-子级",
                cHeight: "170",
                start_date: new Date("2024-12-28"),
                duration: 1,
                cStatus: '在执行',
                id: 'cc1',
                parent: 'f2',
                render: null
            },
        ]
    }

补充

时间轴的日期和小时对不齐的问题

展示效果及代码如下,设置每个格子为4小时,一天被分隔成6个格子。但是发现,当时间轴的日期结束时,下面的小时却没有结束,天和小时对不齐。

时间轴的日期和小时对不齐的问题转存失败,建议直接上传图片文件

    const initsetGannt = () => {
        gantt.config.date_format = "%Y-%m-%d %H:%I:%S";
        gantt.config.scales = [
            { unit: "day", format: "%Y-%m-%d" },
            { unit: "hour", step: 4, format: "%H" }
        ];

        const tasks = {
            data: [
                {
                    start_date: "2025-03-15 11:30:40", 
                    end_date: '2025-03-16 06:50:00' 
                },
            ],
        }
        gantt.init("gantt_here");
        gantt.parse(tasks);
    }

解决方法

  1. 这是因为时间轴的计算,会根据任务数据的开始和结束时间自动计算一个范围,这会导致一天的时间,可能无法和设置的一格代表多少小时整除,出现时间轴偏移的情况,天数列和小时列不对齐;
  2. 这时需要固定时间轴的起始、结束时间范围,设置为 0 点保证能整除。
  3. 定义如下函数,返回时间轴的起始、结束时间。然后赋值给 gantt.config.start_dategantt.config.end_date,来设置时间范围即可
    /**
     * 计算甘特图时间轴的开始时间和结束时间,用来将日期和小时列对齐,时间从00点开始
     * @param taskList 甘特图任务数据
     * @returns 开始时间、结束时间日期对象
     * 注:要添加到如下配置
     * gantt.config.start_date = startDate
     * gantt.config.end_date = endDate
     */
    export const getGanttDateRange = (taskList) => {
        // 获取当前任务数据的,最小时间
        const minDate = taskList.flatMap(a => a.start_date ? a.start_date : []).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())[0]
        // 获取当前任务数据的,最大时间
        const maxDate = taskList.flatMap(a => a.end_date ? a.end_date : []).sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0]
        
        const startDate = getToDay(minDate)
        const endDate = getNextDayMidnight(maxDate)
        // 获取当天的0点日期对象
        function getToDay(dateVal) {
            const now = new Date(dateVal)
            now.setHours(0, 0, 0, 0)
            return now
        }
        // 获取第二天的0点日期对象
        function getNextDayMidnight(dateVal) {
            const now = new Date(dateVal).getTime();
            const nextDayTimestamp = now + 24 * 60 * 60 * 1000;
            const nextDayDate = new Date(nextDayTimestamp); 
            nextDayDate.setHours(0, 0, 0, 0);
            return nextDayDate;
        }

        return {
            startDate,
            endDate
        }
    }

  1. 如果甘特图展示的数据是变化的,那么要在每次数据改变后先执行 gantt.clearAll(); 清除原来的gantt数据和图形,再执行 gantt.parse(); 重新渲染;
  2. 页面关闭后执行 gantt.detachAllEvents(); 来移除 gantt 所有的事件监听器。因为dhtmlx-gantt插件的事件监听,在页面卸载后任然存在,当多个页面有这个监听的时候,当前页面触发事件,其他页面也会触发他们监听事件的处理逻辑;
  3. 使用免费版本的 dhtmlx-gantt,一个页面不能同时展示多个甘特图,因为每次生成图形用的gantt一直都是同一个实例对象。并且多个页面有甘特图,如果使用 keep-alive缓存了,它们互相切换时会互相影响的,就是由于一直同的一个gantt实例对象;
  4. 使用免费版的原来要在一行显示多个任务条时,通过 id parent render 这些字段建立数据的父子关系,子级个数是没有限制的。但是发现在 dhtmlx-gantt 更新到 9.0 版本后,这个子级数量超过 12 个浏览器控制台就会报错了。

参考链接

官方文档

官方在线编译器