基于frappe-gantt开发自定义扩展的甘特图业务

前言

根据业务要求,需要做一个根据任务周期来统计人员任务量的功能。需要能够清晰的展示人员与工作量的安排情况,以便每个项目组或者部门 leader 方便安排人手及工作,界面展示如下:

要求

  1. 有人员列表,并且人员列表下有其对应的任务及相关信息
  2. 拥有检索,查询功能
  3. 在人员行显示工作任务的数量,并显示在相对应的日期上(根据颜色不同反应出人员工作的任务量是正常或是积压)
  4. 在事项行上显示相对应的任务周期
  5. 点击人员工作任务显示详情弹窗
  6. 点击事项任务显示任务详情弹窗
  7. 点击事项行显示事项详情弹窗

准备工作

组件选择

首先我们需要的是一个传统的 crud 的功能,顶部是筛选的表单组件,左边的列表我选择的是 antd 的 table 组件,原因是 antd 的 table 组件可以直接支持树结构的数据。

调研了几个甘特图的组件,最终选择了 frappe-gantt,选择原因是其非常的轻量,性能高,由于不需要甘特图组件之间的线,以及里程碑之类的功能,所以没必要选择功能性很庞大的组件避免提高浏览器的负荷。

评审功能

传统甘特图都是一条数据对应一条甘特图的进度条,而本次功能要求是在筛选出的时间范围内展示出所有的任务进度条,所以需要对组件进行源码改造,实现方案是根据任务的时间段返回相对应的分段进度条,然后拼接起来行成完成的进度条,即渲染进度条字段的返回数据类型必须是数组类型,跟后端沟通后,字段格式如下图:

children 字段存放的是这个成员在筛选出的时间范围内的任务数量,由于任务只存在一个进度条,故直接定义对象存储即可,除了需要的数据字段,必有的是 start、end 字段,代表的是进度条的起止时间,组件也是通过这两个字段渲染图表的。

ganttList 字段代表的是这个成员在筛选出的时间范围内的时间进度条,根据每一项的 start、end 字段进行渲染,这个字段只会存在每一个成员的字段下。

其他字段是用于展示任务数量,完成情况,任务类型,优先级,名称等。

开发功能

表格

表格组件根据 antd 的文档开发即可,这里我们是将表格封装成了组件,cb 方法返回了调取接口后的数据。当获取到了接口返回的信息后,先注册甘特图的图表,如果存在了数据,就调用甘特图刷新的方法。这里自己定义了甘特图与 table 每行的高度保持一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

const GanttList: React.FC<Props> = ({...props}) => {
const gantt = useRef<any>();
const datas = useRef<any>();

const tableCallback = (data) => {
setLoading(false);
datas.current = data;
setDataList(data);
const tasks = createGanttList(data);
//非空判断 避免组件(甘特图组件)报错
if (tasks.length > 0) {
if (gantt.current) {
gantt.current?.refresh(tasks);
} else {
gantt.current = new Gantt('#gantt', tasks, {
header_height: 35,
padding: 31.8,
language: 'zh',
view_mode: viewMode,
custom_popup_html: (task) => {
return `<div>....弹框组件</div>`;
},
});
}
}
changeGanttHeight();
}

return (
//自定义table组件
<TablePageList
//......
//......
cb={tableCallback}
/>
);
};

export default GanttList;

在 onRow 属性里面定义了表格的 data 属性,将值存入,用于 expand 方法里,遍历每行属性,生成最新的甘特图的值,然后调用刷新方法,同时重新计算甘特图的高度,这样就能保证每条数据都保持一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function onRow(record: any) => {
if (record.length === 0) {
return record
}
if (record?.ganttList?.length >= 0) {
return {
data: JSON.stringify({
...record,
ganttList: record?.ganttList?.map((y: any) => ({
...y,
custom_class: renderTaskBarStatus(y?.issueCount)?.barColor,
progress: 100,
name: '',
title: renderTaskBarStatus(y?.issueCount)?.title
}))
})
}
}
return {
data: JSON.stringify({
...record,
custom_class: record?.issueDLStatusSet?.includes('3') ? styles.red : (record.statusType === 1 ? styles.gray : record.statusType === 2 ? styles.green : styles.blue),
}),
};
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function onExpandedRowsChange(expandedRows: Key[]) => {
setExpandedRowKeys(expandedRows);
const _trs = document.querySelectorAll(
'#table' + ' .ant-table-tbody tr',
);
const timeout = setTimeout(() => {
const trs = document.querySelectorAll(
'#table' + ' .ant-table-tbody tr',
);
if (_trs.length === trs.length) {
return;
}
const tasks: any[] = [];
trs.forEach(v => {
tasks.push(JSON.parse(v.getAttribute('data') || ''));
});
gantt.current.refresh(tasks);
changeGanttHeight();
clearTimeout(timeout)
}, 200);
},

计算高度

计算高度的原理很简单,由于 table 每一行都和甘特图的一一对应,因此我们只需要获取到 table 的高度,就能定义甘特图的高度。需要注意的是如果出现数据为空时,这里的操作是让图标高度展示为 0,而不是销毁组件,因为在获取数据的回调中,set 最新的 list 那一事件循环还未结束,组件就被销毁了因此会直接报错,同时为了不重新再次定义图表,这里将图表的高度设置成 0,并展示出空数据组件即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const changeGanttHeight = () => {
let ganttSvg = document.querySelector("#gantt");
let table = document.querySelector("#table" + " .ant-table");
const _height = datas.current?.length === 0 ? 0 : table?.clientHeight + "px";
ganttSvg?.setAttribute("style", "height: " + _height);

//调整图表高度时隐藏点击的弹框
gantt.current?.hide_popup();

const _ganttContainerDom = document?.querySelector(".gantt-container");
if (datas.current?.length === 0) {
_ganttContainerDom?.setAttribute(
"style",
"min-height: 0 !important; overflow: hidden"
);
} else {
_ganttContainerDom?.setAttribute(
"style",
"min-height: calc(100vh - 213px) !important;"
);
}
};

//不销毁图表,且数据为空时展示空组件
<Col span={17} className={styles.gannt_page}>
<svg id="gantt"></svg>
{dataList?.length === 0 && <Empty />}
</Col>;

修改甘特图源码

修改 渲染数据

源码中,图表并不是直接用外层传值进去的数据渲染的,而是将其数据重新封装了一层,当然有些字段也会随之而变,比如时间字段会转成相应的时间戳类型,也会将每层数据增加一层独立 id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//如果有ganttList
if (((task || {}).ganttList || []).length > 0) {
task.ganttList.map((item) => {
item._start = date_utils.parse(item.start);
item._end = date_utils.parse(item.end);
// make task invalid if duration too large
if (date_utils.diff(item._end, item._start, "year") > 10) {
item.end = null;
}
// cache index
item._index = i;
// if hours is not set, assume the last day is full day
// e.g: 2018-09-09 becomes 2018-09-09 23:59:59
const task_end_values = date_utils.get_date_values(item._end);
if (task_end_values.slice(3).every((d) => d === 0)) {
item._end = date_utils.add(item._end, 24, "hour");
}
// invalid flag
if (!item.start || !item.end) {
item.invalid = true;
}
// uids
if (!item.id) {
item.id = generate_id(item);
}
});
}

最后生成的数据如下图:
甘特图组件渲染出图表的数据

修改 进度条组件

1
2
3
4
5
6
7
8
//源码
make_bars() {
this.bars = this.tasks.map(task => {
const bar = new Bar(this, task);
this.layers.bar.appendChild(bar.group);
return bar;
});
}

这是修改前的源码,我们能看到图表是根据数据量来生成相应的进度条,由此我们能得出,如果我们想要拼接的话,就要在一个进度条里包含多个进度条,最后再把这一个进度条画在 layers 上面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//修改后
make_bars() {
this.bars = this.tasks.map(task => {
const _this = this;
let bar = new Bar(this, task);
this.layers.bar.appendChild(bar.group);
if (((task || {}).ganttList || []).length > 0) {
task.ganttList.forEach((item) => {
const _childBar = new Bar(_this, item);
bar.group.appendChild(_childBar.group);
})
}
return bar;
});
}

修改 弹框组件

开发弹框组件的时候,主要遇到的两个大坑:

  1. 点击展示弹框时,父容器也出现了弹框;
  2. 由于弹框是个真实 dom,如果列表拉到最下面了,真实 dom 会将整个画布容器撑起来,出现一大片空白

第一个问题一看就是需要取消冒泡,但是在 index 中很多地方是这样绑定的点击事件,开发的时候也被误导了

1
2
3
4
5
6
7
8
9
10
11
12
//index.js
export function $(expr, con) {
return typeof expr === 'string'
? (con || document).querySelector(expr)
: expr || null;
}

$.on(this.$svg, 'mousedown', '.bar-wrapper, .handle', (e, element) => {
const bar_wrapper = $.closest('.bar-wrapper', element);
//......
//......
)

最后终于在定义进度条的文件中找到,并增加取消冒泡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//bar.js
bind() {
if (this.invalid) return;
this.setup_click_event();
}

setup_click_event() {
$.on(this.group, 'focus ' + this.gantt.options.popup_trigger, e => {
//去掉冒泡,防止点击子元素时父容器也弹出
e && e.stopPropagation();
if (this.action_completed) {
// just finished a move action, wait for a few seconds
return;
}

this.show_popup({ x: e.offsetX, y: e.offsetY });
this.gantt.unselect_all();
this.group.classList.add('active');
});

第二个问题是在弹框文件中,默认定义的弹框都是展示在点击进度条的下方,为了使样式不出现混乱,最开始想过从进度条右边展示,但是一旦数据超过了 10 天或者并排多时,右边展示的弹框需要把进度条拖到相应位置才会展示,不美观因此废弃。还有一种是在画布上画出 svg 的弹框,这样的弊端在于不能自己定义弹框里的展示内容,模板一旦写好就不能扩展,因此也不是最佳选择。最后想到一个比较优质的解决方式就是,通过获取到容器高度以及进度条的 y 的坐标大小进行对比,判断出如果点击的是最底下的几条数据,那么就将弹框向上展示。

首先,在点击进度条的事件里找到展示弹框的方法,并将其传入当前的坐标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//bar.js
this.show_popup({ x: e.offsetX, y: e.offsetY });

show_popup(clickedPosition) {
if (this.gantt.bar_being_dragged) return;

const start_date = date_utils.format(this.task._start, 'MMM D', this.gantt.options.language);
const end_date = date_utils.format(
date_utils.add(this.task._end, -1, 'second'),
'MMM D',
this.gantt.options.language
);
const subtitle = start_date + ' - ' + end_date;

//手动传入坐标
this.gantt.show_popup({
target_element: this.$bar,
title: this.task.name,
subtitle: subtitle,
task: this.task,
group: this.group
}, clickedPosition);

index.js 中接收到坐标,传到弹框实例的方法中

1
2
3
4
5
6
7
8
9
10
//index.js
show_popup(options, clickedPosition) {
if (!this.popup) {
this.popup = new Popup(
this.popup_wrapper,
this.options.custom_popup_html
);
}
this.popup.show(options, clickedPosition);
}

在弹框文件中的 show 方法中定义弹框的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//popup.js
show(options, clickedPosition) {

//......

const _ganttChartTotalHeight = document.getElementById("gantt").clientHeight;
this.parent.style.left = clickedPosition.x - 115 + "px";
//如果y的坐标 > 总高度-250,弹框向上展示
if (
position_meta.y > 160 &&
position_meta.y + 30 > _ganttChartTotalHeight - 250
) {
const isParent = options.task.issueCount >= 0;
this.parent.style.top = position_meta.y - (isParent ? 120 : 200) + "px";
} else {
this.parent.style.top = position_meta.y + 30 + "px";
}
this.pointer.style.transform = "rotateZ(90deg)";
this.pointer.style.left = "-7px";
this.pointer.style.top = "2px";
}

其他还有一些别的优化,如图表上显示的年月日等,这些配置统一放到了 format 方法中,都能找得到。包括样式,在相对应的 css 的类中根据产品要求修改即可,至此,整个功能算是基本实现了。