vue甘特图gantt
很抱歉这篇博客不够好,关于甘特图,我有了更简单、功能更强大、效果更完美的解决方案,不要再看这个。
用 hightcharts 实现,对 vue react 原生 js 等等都支持。
新的甘特图以及源代码下载请点击这里查看 hightcharts 版甘特图
vue 做甘特图,先大致介绍下核心功能: (1)横轴、纵轴拖拽; (2)自定义监听点击事件(双击、右键等)(3)任务之间显示父子层级关系;(4)左侧列表信息,右侧时间轴表示任务;(5)每个任务可以订制样式,并且可以动态修改样式;(6)自定义时间粒度显示(小时、天、星期、月、年);(7)支持大批量数据渲染;(8) 支持同行多节点渲染;(9)支持选中,以及批量选中;(9)优秀的扩展性,支持第三方插件。等等还有其他的一些功能。这里先看一下效果图:
接下来会介绍用什么实现的,怎么使用,怎么添加拖拽、点击等各种功能,我以 vue 为例进行开发。
1、使用 GSTC 做甘特图开发
Git 项目地址:https://github.com/neuronetio/gantt-schedule-timeline-calendar#weekendhighlight-plugin
官方 vue 实例:https://github.com/neuronetio/vue-gantt-schedule-timeline-calendar
npm 指令: npm i gantt-schedule-timeline-calendar
官方做了 3 大主流框架的封装,具体看 Git 链接,这里我也附上了 vue 版本的 npm 包地址。
基本使用如下: ps: 文章末尾我会贴一个完整的代码,如果是 vue 项目可以直接复制查看效果。下边这个是个极度阉割的。
<template> <GSTC :config="config" /> </template> <script> import GSTC from "vue-gantt-schedule-timeline-calendar"; export default {data(){ return { config: { height: 500, list: { rows: { "1": { id: "1", order: '订单 1', }, }, columns: { data: { id: { id: "id", data: "id", width: 50, header: { content: "序号" } }, } } }, chart: { items: { "1": { id: "1", rowId: "1", time: { start: new Date().getTime() + 1 * 24 * 60 * 60 * 1000, end: new Date().getTime() + 2 * 24 * 60 * 60 * 1000 } } } }, }, subs: []} }, beforeDestroy() { this.subs.forEach(unsub => unsub());} } </script> <style lang="less" scoped> .wrapper .gantt-elastic__grid-line-time {display: none;} </style>
基础使用已经贴代码了,不做赘述,不清楚的查看官方示例,接下来主要说核心功能如何配置,这方面官方描述的不是很清楚,但是 Git 的 issues 好多问题都关闭了,基本大部分问题都可以查到。
1、基础展示,左侧多列表格展示
这个主要配置 config 中的 list 属性,
rows 代表左侧表格的行属性,key 值是每行的 id,多个 key 就有多行,通常都以数字做 key 值, 内部 具体属性是列信息。比如 order label line 等都是列信息,这个会一一对应到指定列。
parentID 是父节点配置,一般配置了父节点,就会在 甘特图 中展示出父子层级来。
expanded 是展开属性,默认 false,父子层级是合上的,折叠隐藏子节点。如果想默认展示需要每个节点都加上这个属性。
columns 代表左侧表格的列属性,key 唯一就是列关键字。
data 属性,是列,可以有多个属性,每个代表一列
id 当前列的 id
data 列标识,和 rows 中每行的数据的字段唯一对应,比如 order、line 等
isHTML 是否要展示 HTML,默认 false。 这个直接关系到 content、html 字段用哪个
width 当前列宽度
expander 是否显示层级,默认 false 不展示,设置为 true,会展示出父子层级来,一般我们仅设置一列,当然设置多列也行。
header 配置表头内容的
content 表头想显示的内容
html 写 HTML,用来订制表头样式的,内容就是 HTML,行内 css
percent 是左侧表格总宽度占甘特图的百分比,0 就直接隐藏表格
minWidth:是左侧表格的最小宽度
list: { rows: { "1": { id: "1", order: '订单 1', label: "压缩机", line: '线体 1', expanded: true }, "3": { id: "3", order: '订单 3', label: "箱体", line: '线体 3', parentId: '2', } }, columns: { data: { id: { id: "id", data: "id", width: 50, expander: true, header: { content: "序号" } }, order: { id: "order", data: "order", header: { content: "生产订单" } }, label: { id: "label", data: "label", header: { content: "描述" } } } } }
2、右侧任务排列显示(包括订制样式)
这个主要配置 config 中的 chart 属性,
time 配置时间轴
from 左侧开始时间,填写毫秒数
to 右侧结束时间,填写毫秒数
zoom 显示层级,10-22,越大,时间粒度展示的越大,越小,显示越精细,最小到 5 分钟
items 任务快配置,注意这个可以同行若干任务展示
id 当前任务的 id
rowId 左侧表格 rows 的 id,通过这个关联,渲染到某一行
label 当前任务的名称,会默认展示在任务中
time 任务的开始、结束时间
start 开始时间,填写毫秒数
end 结束时间, 填写毫秒数
style 订制样式,是个对象,写过 jsx 写法,写过 react 、vue jsx 的应该都不默认,这里举个简单的例子,订制任务 div 的背景色 圆角等样式 {background: 'red', borderRadius: '3px'}
chart: { time: { from: new Date().getTime() - 2 * 24 * 60 * 60 * 1000, to: new Date().getTime() + 8 * 24 * 60 * 60 * 1000, zoom: 22, }, items: { "1": { id: "1", rowId: "1", label: "Item 1", time: { start: new Date().getTime() + 1 * 24 * 60 * 60 * 1000, end: new Date().getTime() + 2 * 24 * 60 * 60 * 1000 }, style: { // 每个块的样式 background: 'blue' } }, "21": { id: "21", rowId: "2", label: "Item 2-1", time: { start: new Date().getTime() + 2 * 24 * 60 * 60 * 1000, end: new Date().getTime() + 3 * 24 * 60 * 60 * 1000 } } } }
3、配置右侧横轴的时间显示
这个主要配置 config 中的 locale 属性,时间的语言环境配置,这里看文档详细些,下面只详说 2 个属性,
weekdays 配置 每周显示的文案 主要是做国际化用的
months 配置月的,也是做国际化的
locale: { name: "zh", Now: "Now", weekdays:["周日","周一","周二","周三","周四","周五","周六"], months:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"], }
4、监听鼠标右击事件
这个主要配置 config 中的 actions 属性,他是对象,以下是他所有能监听 dom,很多,这篇博客就只介绍人物块的事件监听,其他的不做一一赘述了
main
list
list-column
list-column-header
list-column-header-resizer
list-column-header-resizer-dots
list-column-row
list-column-row-expander
list-column-row-expander-toggle
list-toggle
chart
chart-calendar
chart-calendar-date
chart-timeline
chart-timeline-grid
chart-timeline-grid-row
chart-timeline-grid-row-block
chart-timeline-items
chart-timeline-items-row
chart-timeline-items-row-item
这个监听函数会接收 2 个参数,element 和 data ,一个是 dom,另一个是 任务节点的数据。根据官方要求,监听函数必须返回一个对象,此对象必须包含 update destroy 2 个方法,分别是位置更新和销毁时需要执行的方法。具体写法请见如下代码:
actions: { 'chart-timeline-items-row-item': [this.addListenClick] // 监听右击事件 }methods:{
addListenClick(element, data) {
const onClick = (e) => {
e.preventDefault()
// console.log(data)
this.modal = {
visible: true,
title: data.item.label,
data
}
return false
}
element.addEventListener('contextmenu', onClick);
return {
update(element, newData) {
data = newData;
},
destroy(element, data) {
element.removeEventListener('click', onClick);
}
};
},
closeModal() {
this.modal = {
visible: false,
title: '',
data: {}
}
}
},
5、任务的横轴、纵轴拖动
这个主要配置 config 中的 plugins 属性,
ItemMovement 插件,这个是官方开发的用来拖拽任务的插件。这个包的插件系统做的很好,官方提供了几种不错的插件,同时还支持其他的第三方插件,有兴趣的可以自己试试,这里先介绍拖拽插件,
moveable 拖拽的方向, x 支持横轴拖拽; y 支持纵轴拖拽; true 横轴、纵轴都可以拖拽; false 禁止拖拽
resizeable 是否可以拖拽,true 开启拖拽
resizerContent 拖拽的图标,直接写 HTML,可以自己定制拖拽图标的样式
collisionDetection: 拖拽过程中是否允许元素重叠, true 不允许重叠
ghostNode false 不展示重影节点
snapStart 拖拽开始时间点回调,这个比较机制特殊,拖拽位置的时候触发这个方法,参数接收开始时间 时间变化 当前节点数据,默认是毫秒级的刷新,会卡,我们做 if 判断 1 小时拖拽
snapEnd 拖拽结束时间点回调,这个是拖动任务块大小时触发,接收结束时间 时间段。用法同上。具体请看如下代码:
plugins: [ // 拖动 x 横向, y 纵向 ItemMovement({ moveable: 'x', resizerContent: '<div class="resizer">-></div>', ghostNode: false, snapStart(time, diff, item) { if(Math.abs(diff) > 14400000) { return time + diff } return time }, snapEnd(time, diff, item) { if(Math.abs(diff) > 14400000) { return time + diff } return time } }) ]
6、选中任务
这个主要配置 config 中的 plugins 属性,
Selection 插件,单个选中、批量选中插件。
grid 能否选中单元格
items 能否选中任务
rows 能否选中行
rectStyle 矩形样式
selected 选中的回调
deselected 取消选中的回调
canSelected 可选中的的回调,用来过滤哪些可以选中
canDeselected 可取消选中的回调,用来过滤哪些可以取消选中
plugins: [ Selection({ items: false, rows: false, grid: true, rectStyle: { opacity: '0.0' }, canSelect(type, currentlySelecting) { if (type === 'chart-timeline-grid-row-block') { return currentlySelecting.filter(selected => { if (!selected.row.canSelect) return false; for (const item of selected.row._internal.items) { if ( (item.time.start >= selected.time.leftGlobal && item.time.start <= selected.time.rightGlobal) || (item.time.end >= selected.time.leftGlobal && item.time.end <= selected.time.rightGlobal) || (item.time.start <= selected.time.leftGlobal && item.time.end >= selected.time.rightGlobal) ) { return false; } } return true; });} return currentlySelecting; }, canDeselect(type, currently, all) { if (type === 'chart-timeline-grid-row-blocks') { return all.selecting['chart-timeline-grid-row-blocks'].length ? [] : currently;} return [];} }) ]
小结:
以上就是整个甘特图的使用了,这是我用过最符合项目需求的甘特图,他的开发团队也在持续的维护这个项目,很赞。
最后贴一段完整的 vue 示例代码:
<template> <div class="wrapper"> <GSTC :config="config" /> <infor-modal :visible="modal.visible" :title="modal.title" :dataSource="modal.data" @handleModal="closeModal" /> </div> </template><script>
import GSTC from "vue-gantt-schedule-timeline-calendar";
import ItemMovement from "gantt-schedule-timeline-calendar/dist/ItemMovement.plugin.js"
import Selection from "gantt-schedule-timeline-calendar/dist/Selection.plugin.js"
import inforModal from "./inforModal"export default {
components:{
GSTC,
inforModal
},
props:{},
data(){
return {
config: {
height: 500,
list: {
// 行属性
rows: {
"1": {
id: "1",
order: '订单 1',
label: "压缩机",
line: '线体 1',
expanded: true
},
"3": {
id: "3",
order: '订单 3',
label: "箱体",
line: '线体 3',
parentId: '2',
},
"4": {
id: "4",
order: '订单 4',
label: "空调总装",
line: '线体 4',
},
"2": {
id: "2",
order: '订单 2',
label: "门体",
parentId: '1',
line: '线体 2',
expanded: true
},
"5": {
id: "5",
order: '订单 5',
label: "冰箱总装",
line: '线体 5',
},
"6": {
id: "6",
order: '订单 6',
label: "洗衣机总装",
line: '线体 6',
},
},
// 列定义
columns: {
data: {
id: {
id: "id",
data: "id",
width: 50,
header: {
content: "序号"
}
},
order: {
id: "order",
data: "order",
width: 120,
header: {
content: "生产订单"
}
},
label: {
id: "label",
data: "label",
width: 120,
expander: true,
header: {
content: "描述"
}
},
line: {
id: "line",
data: "line",
width: 120,
header: {
content: "线体"
}
},
}
}
},
chart: {
time: { // 时间轴开始截至,
from: new Date().getTime() - 2 * 24 * 60 * 60 * 1000,
to: new Date().getTime() + 8 * 24 * 60 * 60 * 1000,
zoom: 22, // 10-22 缩放,默认 Shift + 滚轮, 默认缩放展示时间粒度, 一共有 小时、天、周、月、年
},
items: {
"1": {
id: "1",
rowId: "1",
label: "Item 1",
time: {
start: new Date().getTime() + 1 * 24 * 60 * 60 * 1000,
end: new Date().getTime() + 2 * 24 * 60 * 60 * 1000
},
style: { // 每个块的样式
background: 'blue'
}
},
"21": {
id: "21",
rowId: "2",
label: "Item 2-1",
time: {
start: new Date().getTime() + 2 * 24 * 60 * 60 * 1000,
end: new Date().getTime() + 3 * 24 * 60 * 60 * 1000
}
},
"22": {
id: "22",
rowId: "2",
label: "Item 2-2",
time: {
start: new Date().getTime() + 3 * 24 * 60 * 60 * 1000,
end: new Date().getTime() + 4 * 24 * 60 * 60 * 1000
}
},
"3": {
id: "3",
rowId: "3",
label: "Item 3",
time: {
start: new Date().getTime() + 3 * 24 * 60 * 60 * 1000,
end: new Date().getTime() + 5 * 24 * 60 * 60 * 1000
}
},
"4": {
id: "4",
rowId: "4",
label: "Item 4",
time: {
start: new Date().getTime() + 2 * 24 * 60 * 60 * 1000,
end: new Date().getTime() + 5 * 24 * 60 * 60 * 1000
}
},
"5": {
id: "5",
rowId: "5",
label: "Item 5",
time: {
start: new Date().getTime() + 3 * 24 * 60 * 60 * 1000,
end: new Date().getTime() + 5 * 24 * 60 * 60 * 1000
}
},
"6": {
id: "6",
rowId: "6",
label: "Item 6",
time: {
start: new Date().getTime() + 5 * 24 * 60 * 60 * 1000,
end: new Date().getTime() + 6 * 24 * 60 * 60 * 1000
}
},
}
},
locale: {
name: "zh",
Now: "Now",
weekdays:["周日","周一","周二","周三","周四","周五","周六"],
months:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],
},
actions: {
'chart-timeline-items-row-item': [this.addListenClick] // 监听右击事件
},
plugins: [
// 拖动 x 横向, y 纵向
ItemMovement({
moveable: 'x',
resizerContent: '<div class="resizer">-></div>',
ghostNode: false,
collisionDetection: false,
snapStart(time, diff, item) {
if(Math.abs(diff) > 14400000) {
return time + diff
}
return time
},
snapEnd(time, diff, item) {
if(Math.abs(diff) > 14400000) {
return time + diff
}
return time
}
}),
Selection({
items: false,
rows: false,
grid: true,
rectStyle: { opacity: '0.0' },
canSelect(type, currentlySelecting) {
if (type === 'chart-timeline-grid-row-block') {
return currentlySelecting.filter(selected => {
if (!selected.row.canSelect) return false;
for (const item of selected.row._internal.items) {
if (
(item.time.start >= selected.time.leftGlobal && item.time.start <= selected.time.rightGlobal) ||
(item.time.end >= selected.time.leftGlobal && item.time.end <= selected.time.rightGlobal) ||
(item.time.start <= selected.time.leftGlobal && item.time.end >= selected.time.rightGlobal)
) {
return false;
}
}
return true;
});
}
return currentlySelecting;
},
canDeselect(type, currently, all) {
if (type === 'chart-timeline-grid-row-blocks') {
return all.selecting['chart-timeline-grid-row-blocks'].length ? [] : currently;
}
return [];
}
})
]
},
modal: {
visible: false,
title: '',
data: {}
},
subs: []
}
},
watch:{},
computed:{},
methods:{
addListenClick(element, data) {
const onClick = (e) => {
e.preventDefault()
// console.log(data)
this.modal = {
visible: true,
title: data.item.label,
data
}
return false
}
element.addEventListener('contextmenu', onClick);
return {
update(element, newData) {
data = newData;
},
destroy(element, data) {
element.removeEventListener('click', onClick);
}
};
},
closeModal() {
this.modal = {
visible: false,
title: '',
data: {}
}
}
},
created(){},
mounted(){
},
beforeDestroy() {
this.subs.forEach(unsub => unsub());
}
}
</script>
<style lang="less" scoped>
.wrapper .gantt-elastic__grid-line-time {
display: none;
}
</style>