一、使用须知
1. 目前在flow-product-web中使用的实施策略
自定义Table + DHTMLX的gantt视图部分,手动同步两部分的数据与视图。
工作重难点:需要掌握DHTMLX甘特图的API,实现联动的交互逻辑与视图。
2. 基本概念
效果图
- 支持基线等行内多条线的显示;
- 支持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: 这些操作需要遵循如下顺序!
组件初始化
- 先注册使用到的插件:
gantt.plugins({ ... });
- 然后进行一系列的配置,如设置scale、添加marker、添加baseline等额外视图(要在添加tooltip之前,否则tooltip会失效)
- 添加tooltip(要在添加baseline等额外视图之后,否则tooltip会失效)
- 最后进行组件挂载
gantt.init("gantt_here");
与数据初始化gantt.parse(taskData);
更新组件(全部数据)
- 清除已有数据
if (gantt.$container) gantt.clearAll();
- 注册使用到的插件:
gantt.plugins({ ... });
- 重新加载数据
gantt.parse(taskData);
- 重新添加markers
- 进行addTaskLayer操作(要在添加tooltip之后,否则tooltip会失效)
- 重新添加自定义tooltip
二、存在的问题
1. (已解决)添加基线等内置功能需要付费解锁
-
问题描述:
添加基线 或 在同一行中添加自定义的多条bar 需要使用
addTaskLayer
方法来将bar所对应的标签挂载到相应视图layer中。但
addTaskLayer
方法在GPL社区版中没有,需要付费使用Pro版。 -
解决方法:
先用
getTask
方法获取task对象,然后将task对象传给getTaskPosition
方法获取task的位置信息后,手动添加baseline的div到DOM树的指定节点处(手动实现addTaskLayer的逻辑)——官方添加baseline的示例PS:更新数据与切换视图模式(日/月/年等)时需要手动从DOM中移除baseline相关的DOM节点。(已实现)
PPS:展开/折叠后需要移除并重新添加一次(因为位置已经发生变化)(已实现)

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;
}
三、使用时的注意事项
- 每个节点必须有start_date和duration,否则gantt中不会显示对应的bar(塌陷),会导致Table和gantt无法对齐。
- gantt组件的节点之间以
id
属性作为key来区分。 - data中的open属性控制默认的(初始的)「展开(1)」和「收起(0)」状态。
- 每次
gantt.clearAll()
会清除所有数据,包括视图中的各种标记(markers),所以addMarker
等操作需要在gantt.parse(data)
之后重新执行一次。 - 在
gantt.clearAll()
之后,需要重新注册plugins,比如:
if (gantt.$container) gantt.clearAll();
gantt.plugins({
marker: true,
tooltip: true
});
-
【dhtmlx/gantt组件的bug】要给容器div设置一个固定的width,否则
flex-grow: 1
的动态调整宽度在某些情况下会失效。 -
element-plus的Table组件实现受控折叠时,row-key的key要统一为string类型,不能是number,否则表现异常。另外,
expandRowKeys
数组只控制哪些行展开,只把expandRowKeys
数组置空不能使所有行折叠,须通过toggleRowExpansion
方法以及expand-change
事件的处理方法来实现控制逻辑。 -
添加tooltip操作要在添加baseline等额外视图之后,否则tooltip会失效;另外添加tooltip的操作不要放在onGanttRender等事件的回调函数中,容易意外失效。
-
修改task的持续时间时,应修改
end_date
而不是duration
。 -
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
}
- 如果
start_date
在甘特图时间起止范围min-max
之外,bar DOM节点就会消失。导致甘特图塌陷。
四、常用方法与相关文档
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
添加自定义任务类型
4. 相关链接
官方Demo
官方指引
https://docs.dhtmlx.com/gantt/desktop__guides.html
Standard vs Pro
https://docs.dhtmlx.com/gantt/desktop__editions_comparison.html
Comments NOTHING