背景
- 公司最近在弄低代码,要求我在短时间内赶出一个甘特图组件供低代码使用,所以从掘金和 github 上搜索好用的组件,最后发现 dhtmlx-gantt 非常自由,可拓展性强,而且 api 完善,就决定使用 dhtmlx-gantt 来实现。
- 甘特图主要具备的功能
- 对表格进行自定义配置
- 给特殊日期以及特殊任务特定样式(在配置中调用 api 对日期、任务进行判断后赋予 class 类名)
- 表格渲染使用 react 组件(主要用于下面的自定义新增)
- 切换时间视图(日、周、月、年)
- 设置当前日期标识
- 甘特图表格增加展示列
- 自定义新增渲染(下拉框选择)
- 自定义新增(新增同级与子级)
- 自定义模态框
- --------------下面这些功能下期讲--------------
- 新增时子级任务的开始日期、持续日期、结束日期不能超过父任务的
- 同时拖拽父级和子级任务
- 新建、拖拽后重排任务并更新任务序号
- 拖拽任务更改开始时间以及持续时间
- 拖拽子任务不能超过父级的日期
- 任务之间链接(前置任务)
- 对表格进行自定义配置
- 因为功能比较多,我会分期进行讲述,这一期先以甘特图能正常进行增删改任务来讲述
- demo 体验地址 :3h6qyr.csb.app/
- 源码地址 :codesandbox.io/s/gantt-dem...
- 本文章建议搭配 源码一起食用,因为有些代码过长的地方,没有贴到文档里。
实现
初始化甘特图实例
-
用一个 Div 初始化甘特图实例
jsximport React, { useEffect, useRef } from 'react'; import { gantt } from 'dhtmlx-gantt'; import "dhtmlx-gantt/codebase/skins/dhtmlxgantt_material.css"; export default function App() { const ganttContainer = useRef(); useEffect(() => { registerLightBox(); // 自定义模态框 setGanttConfig(); setDateMarker(); setColumns(); setZooms(); formatData(); // 将数据处理成我们需要的集合 gantt.init(ganttContainer.current); gantt.parse(mockData); }, []); return ( <div ref={ (ref) => { ganttContainer.current = ref; } } style={ { width: '100%', height: '100%' } } /> ); }
-
将数据处理成我们之后需要的集合
- treeMap 为记录树的父级包含的子级顺序的映射,之后在新增同级与子级需要使用
js// 将数据处理成我们需要的集合 function formatData() { const taskTree = {}; mockData.data.forEach((item) => { if (!taskTree[item.parent]) { taskTree[item.parent] = []; } taskTree[item.parent].push(item); }); _treeMap.current = taskTree; }
配置 甘特图 config
-
对 甘特图 进行配置
-
配置 一些基础样式,比如 行高、自适应、本地化等等
-
如果要配置任务可进行拖拽排序,那么
gantt.config.order_branch
一定得有值
jsxfunction setGanttConfig() { gantt.config.row_height = 32; // 行高 gantt.config.date_format = "%Y-%m-%d %H:%i"; // 日期转换格式 gantt.i18n.setLocale("cn"); // 语言 gantt.config.autosize = "y"; // 甘特图是否自适应 gantt.config.work_time = true; // 工作日模式 gantt.locale.labels.new_task = '新任务'; // 新建任务时的默认label(不过如果是自定义的新增模态框的话,就用不到这个) gantt.config.order_branch = "marker"; // 允许在同一级别内重排任务,只要是需要重排,order_branch 一定得给 true 或 'marker' gantt.config.order_branch_free = true; // 允许在甘特图全部范围内重排任务,如果只给 order_branch_free,而不给 order_branch,排序也是不会生效的。 gantt.plugins({ // 导入插件 marker: true // 日期标识插件,如果不装,之后在配置日期标识线会报错 }); gantt.config.layout = {...} // 布局(代码较多,可去文档中或源码中查看以及复制) ... //(下面继续) }
-
设置 甘特图 时间列的 class 类名,用于配置禁用日期的样式
- 使用
gantt.templates.timeline_cell_class
这个 api - 具体 api 文档:docs.dhtmlx.com/gantt/api__...
jsgantt.templates.timeline_cell_class = (task, date) => { // 判断当前视图是否不为 日视图 const disableHighlight = ["month", "year", "quarter"].includes( _curZoom.current ); // 如果当前视图不为 日视图 且 该日期为周末 则给禁用样式 // TODO: 之后不止要判断周末,要对所有法定节假日进行判断,需要获取到当年的所有节假日配置进 gantt 中 if (!disableHighlight && !gantt.isWorkTime(date)) return "week_end"; return ""; };
- 使用
-
设置 任务的 class 类名,用于配置 任务完成时的 样式
- 使用
gantt.templates.task_class
这个 api - 具体 api 文档:docs.dhtmlx.com/gantt/api__...
js// 设置 任务的 class 类名,用于配置 任务完成时的 样式 gantt.templates.task_class = (start, end, task) => { if (task.progress === 1) return "completed_task"; return ""; };
- 使用
-
如果 在甘特图的表格中需要渲染 React 组件时
-
使用
gantt.config.external_render
这个 api -
具体 api 文档: docs.dhtmlx.com/gantt/api__...
-
主要是为了下面会讲的自定义新增,因为自定义新增需要使用到 antd 的下拉组件
js// 配置 可以让表格渲染 用 react 组件 gantt.config.external_render = { // checks the element is a React element isElement: (element) => { return React.isValidElement(element); }, // renders the React element into the DOM renderElement: (element, container) => { ReactDOM.render(element, container); }, };
-
-
切换时间视图
-
在 config 文件中配置好 视图
-
配置视图 最重要的属性为 scales,这个是配置时间刻度的属性
- 具体文档:docs.dhtmlx.com/gantt/api__...
scales
是一个数组,一个元素即为一行时间刻度unit
为时间单位,比如 日、周、月step
为时间刻度的长度,默认为 1,如果 unit 为 日的话,step 为 1 时,即为 1日format
为对时间刻度进行格式化- 可以像下面例子一样,自己用 html 标签格式化
- 也可以使用 dhtmlx 自己定义的 格式化规则,具体规则文档:docs.dhtmlx.com/gantt/deskt...
jsexport const zoomLevels = [ { name: "day", // 视图代码 label: "日", // 视图名称 min_column_width: 30, // 列的最小宽度 scale_height: 70, // 时间刻度的行高 scales: [ // 时间刻度行配置 { unit: "month", format: "%Y年 %F" }, { unit: "day", step: 1, format: (date) => { const weekDays = ["日", "一", "二", "三", "四", "五", "六"]; const day = new Date(date).getDate(); const weekDay = new Date(date).getDay(); return `<div class='scale-formate-date'> <span class='formate-date'>${day}</span> <span class='formate-weekDay'>${weekDays[weekDay]}</span> </div>`; } } ] }, ... // 代码较多,这里只展示时间单位为日的,剩下的可以去文档或源码进行查看以及复制 ];
-
-
渲染用于切换时间视图的按钮
jsxfunction renderZoomButton() { return ( <div> {zoomLevels.map((item) => { return ( <Button type="primary" disabled={item.name === curZoom} onClick={() => { handleChangeZoom(item.name); }} style={{ marginRight: 6 }} > {item.label} </Button> ); })} </div> ); }
-
点击按钮的时候,触发更改甘特图视图的方法
jsxfunction handleChangeZoom(zoom) { setCurZoom(zoom); _curZoom.current = zoom; gantt.ext.zoom.setLevel(zoom); }
设置当前日期标识
-
在前面的配置中,设置了
gantt.plugins({ marker: true })
后-
在这里就可以对当前日期进行表示配置
jsfunction setDateMarker() { const dateToStr = gantt.date.date_to_str(gantt.config.task_date); const today = new Date(new Date().setHours(0, 0, 0, 0)); // 获取当天零点的时间 gantt.addMarker({ start_date: today, css: "today", text: "今日", title: `Today: ${dateToStr(today)}`, }); }
-
甘特图表格增加展示列
-
甘特图有三个原始属性,即不能更改其属性code
-
text
:任务名称 -
start_time
:开始日期 -
duration
:持续时间 -
这三列是 dhtmlx-gantt 组件在实现计算任务所必须的,所以这三个属性必须存在
*js[ { type: 'input', // 类型 name: "showCode", // 属性代码 label: "任务序号", // 属性名称 tree: true, // 以树状格式展示该列,并展示伸展树形结构的箭头 width: 100, min_width: 100, }, { type: 'input', name: "text", originField: 'text', // 原始属性标识(用于之后做属性code替换) label: "任务名称", width: '*', // 当 width 给 * 时,代表自适应宽度 min_width: 160, }, { type: "date", name: "start_date", originField: "start_date", // 原始属性标识(用于之后做属性code替换) label: "开始日期", align: "center", // 列内容的布局 width: 80, min_width: 80 }, { type: "number", name: "duration", originField: "duration", // 原始属性标识(用于之后做属性code替换) label: "持续时间", align: "center", min: 1, formatType: "date" }, ... // 剩下的 column 过多,可以去源码中查看与复制 ]
-
-
将列设置进 dhtmlx-gantt 组件中
-
如果某一列有重渲染的需求,给列增加一个
onrender
的属性方法,在方法里配置好需要渲染的样式
js// 设置网格列 function setColumns() { const tempColumns = columns.map(item => { const { name, originField, options, type: colType } = item; const temp = { ...item }; // 如果是 select,在表格中展示 label 文字 if (colType === 'select') { temp.onrender = (task, node) => { if (task[name]) { node.innerText = options.filter(cur => cur.value === task[name])[0].label; } }; } else if (colType === 'slider') { // 如果是 进度,将其转为百分比格式 temp.onrender = (task, node) => { node.innerText = `${Math.round(task[name] * 100)}%`; }; } else if (colType === 'date' && originField === 'end_date') { // 如果是 结束日期,显示需要减一天 temp.onrender = (task, node) => { if (task[name]) { const date = moment(task[name]).subtract(1, 'days'); node.innerText = date.format('YYYY-MM-DD'); } }; } gantt.config.columns = tempColumns; }
-
我们之前做了可以在表格中渲染 react 组件的配置,那我们这里可以将 dhtmlx-gantt 自带的新增,重新渲染成我们需要的自定义新增的样式
- 下面代码 放进
setColumns
方法中即可
jsx// 当 列是 增加列时, 渲染 下拉 menu 组件 if (name === 'add') { temp.onrender = (task) => { return ( <Dropdown overlay={( <Menu className='operation-menu-wrapper' onClick={(cur) => { menuClick(cur, task) }} > { operationMenu.map(cur => { if (cur.key === 'delete') { return ( <> <Divider type='horizontal' /> <Menu.Item key={cur.key} className={cur.key} > {cur.label} </Menu.Item> </> ) } else { return ( <Menu.Item key={cur.key} className={cur.key} > {cur.label} </Menu.Item> ) } }) } </Menu> )} > <div className='add-icon'> + </div> </Dropdown> ) } } return temp; })
- 下面代码 放进
-
如果有需求,像低代码这边一样,需要根据用户配置替换属性 code 时,就需要一个原始属性的标识
- 我给的标识属性是
originField
,然后将新code
和originField
做映射,用 fieldMap 一个集合进行存储 - 并且数据中需要同时拥有
新code
和originField
属性对应的值,新code
的值给到后端,originField
的值给到 dhtmlx-gantt 组件进行计算 - 下面代码 放进
setColumns
方法中即可
js// 如果有 原始字段,将 动态更新后的字段还原回 原始字段 // 并将其映射关系 记录在 fieldMap 中 if (originField) { fieldMap.current[originField] = name; temp.name = temp.originField; }
- 当然,如果只是单纯的使用这套干净的组件,这段代码是完全不需要的
- 我给的标识属性是
-
自定义新增(新增同级与子级)
-
点击新增同级与新增子级时,会触发
menuClick
方法- 主要使用
gantt.createTask
这个api 方法,具体文档为:docs.dhtmlx.com/gantt/api__...- 3个参数分别是 新建任务、父级任务、以及 新增任务在父级任务子级中要在的位置的 index
- 新建任务 其实只需要一个 id,因为之后其他属性会在 模态框中添加
- 如果不给 index,会默认作为最后一个子级
js// 下拉 menu 的 点击事件 function menuClick(item, task) { const { key } = item; // 新增任务的 专属 id const id = _uuid.current + 1; const tempTask = { id }; _uuid.current = id; // 获取当前新增任务 应该在的 index const index = _treeMap.current[task.parent].findIndex( (cur) => cur.id === task.id ); // 点击 新建本级时 if (key === "add-bro") { // 创建任务,在当前任务的下一个位置 gantt.createTask( tempTask, task.parent !== "0" ? task.parent : undefined, index + 1 ); setBroIndex(index + 1); } else if (key === "add-child") { // 点击 新建子级时 gantt.createTask(tempTask, task.id); } else if (key === "delete") { // 点击删除时,弹出提示框 showDeleteConfirm(task); } }
- 主要使用
自定义模态框
-
gantt.createTask
这个方法会直接打开模态框,如果我们需要自定义模态框去新增任务时,需要重新注册自定义的模态框 -
首先引入模态框组件
jsx<Modal visible={visible} onCancel={handleModalCancel} footer={renderFooter()} destroyOnClose title="新建/编辑任务" className="edit-task-modal" > <Form initialValues={curTask} onValuesChange={handleFormChange} onFinish={handleModalSave} ref={formRef} > {renderFormList()} // 渲染form表单控件(详细代码可以看源码) </Form> </Modal>
-
然后我们写一个 注册新模态框进入 dhtmlx-gantt 中的方法,然后放到 useEffect 里去执行
- 其实最主要的是在
gantt.showLightBox
中将模态框的 visible 改为 true,其他代码主要是对数据做处理
js// 注册自定义 任务弹出框 function registerLightBox() { // 打开 弹出框事件 gantt.showLightbox = () => { ... // 代码过多,可以从源码中查看与复制 setVisible(true); gantt.resetLayout(); // 重置表格 布局,即新建任务的时候,可以看到新建的任务 }; // 关闭 弹出框事件 gantt.hideLightbox = () => { setVisible(false); setMaxCount(); }; }
- 其实最主要的是在
-
注册好后我们触发
gantt.createTask
方法时,就会打开新的模态框了 -
接下来完善一下 form 表单控件的 onChange 方法
- 主要是完善一些控件之间的联动,比如 开始日期持续时间和结束日期 以及 进度和状态
js// 任务模态框 表单值更新 function handleFormChange(value, allValue) { // 如果 开始日期 或 持续时间 的值变动了,需要更新 结束日期 if (value.start_date || value.duration) { const { start_date } = allValue; let { duration } = allValue; // 如果这次更新的时 start_date, 需要重新计算 duration 的上限 if (value.start_date) { const durationLimit = handleCalcMax(allValue); // 当 duration 上限存在 并且 duration 大于上限时, duration 等于上线 if (durationLimit && duration > durationLimit) { duration = durationLimit; } // 更新 duration formRef.current.setFieldsValue({ duration }); } const endDate = gantt.calculateEndDate(new Date(start_date), duration); endDate.setDate(endDate.getDate() - 1); // 联动更新完 结束日期要减一 formRef.current.setFieldsValue({ end_date: endDate }); } else if (value.progress) { // 进度和状态更改了,都要去修改另一项 const status = value.progress === 1 ? "finish" : "continue"; formRef.current.setFieldsValue({ task_status: status }); } else if (value.task_status) { const progress = value.task_status === "finish" ? 1 : 0; formRef.current.setFieldsValue({ progress }); } }
-
最后完成 模态框的保存方法
- 因为 antd 表单控件的选择日期组件,返回的是 moment 对象,而 dhtmlx-gantt 需要的日期是 date 对象,我们需要在保存任务前转一下
js// 新增 修改 任务保存 function handleModalSave(formValue) { const isNewFlag = curTask.isNew || curTask.$new; const newTask = { ...curTask, ...formValue, isNew: isNewFlag, isEdit: !isNewFlag }; // 当有 moment 对象时 转为 date 对象 Object.keys(newTask).forEach((key) => { if (moment.isMoment(newTask[key])) { newTask[key] = newTask[key].toDate(); } }); // 因为打开模态框之前,将父级转换为对象了,但这里只需要 对象的 value 做判断 const originParent = curTask.parent; const { parent } = newTask; // 计算 tindex 如果为新增本级,那么就是之前存的 broIndex, 如果是添加子级,直接用子级长度作为 index const parentLength = _treeMap.current[parent]?.length; const tindex = parentLength ? addType === "bro" ? broIndex : parentLength : 0; const endDate = new Date(newTask.end_date); endDate.setDate(endDate.getDate() + 1); // 确认任务时 结束日期加一天 newTask.end_date = endDate; // 如果存在 $new 则代表是新建的 if (newTask.$new) { delete newTask.$new; // 先添加任务,在重排 gantt.addTask(newTask, parent, tindex); newUpdateSortCode(newTask.id, parent, tindex, newTask); } else { if (originParent !== parent) { newUpdateSortCode(newTask.id, parent, tindex, undefined, newTask); } else { gantt.updateTask(newTask.id, newTask); updateTreeMapItem(newTask.parent, newTask.id, newTask); } } setVisible(false); setMaxCount(); setAddType(""); setBroIndex(0); gantt.resetLayout(); // 重置表格 布局,即新建任务的时候,可以看到新建的任务 }
- 这里有几个方法
- newUpdateSortCode 重新排序任务
- updateTreeMapItem 更新 treeMap 里的数据
- 这两个都是一些稍微复杂、与重排有关的逻辑,我们留到下一期详细讲
- 顺便预告一下,在这个模态框保存方法中,下一期会增加任务被父级时间限制的处理 以及 对前置任务的处理
删除任务
-
现在已经可以新增任务了,完善一下删除任务的逻辑
-
首先在点击 下拉框中的 删除任务时,先弹出个确认框,让用户决定是否删除,防止误触
*js// 显示 删除任务时的 确定提示 function showDeleteConfirm(task) { Modal.confirm({ title: '确定删除该任务吗?', content: '该任务的子任务将一并被删除,请确认', okText: '删除', okType: 'danger', cancelText: '取消', onOk() { handleModalDelete(task); }, }); }
-
点击确认后,调用 gantt.deleteTask 方法即可删除任务以及其子级
- deleteTask 方法需要任务的 id,具体 api 文档:docs.dhtmlx.com/gantt/api__...
- 如果需要将删除的任务数据传给后端,需要用 deleteList 数组去存取一下,其实最好的是放到 onAfterTaskDelete 这个回调函数去做的,但是会存在一个问题,就是回调函数会触发两次,这个在其他回调函数也会出现这个情况。
- 所以为了回避上述这个情况,我们就在这个方法里记录一下需要删除的数据
js// 删除 任务 function handleModalDelete(task) { const tempTreeMap = _treeMap.current; const tempTask = task || curTask; // 如果是 新建的任务 if (tempTask.$new || tempTask.isNew) { gantt.deleteTask(tempTask.id); } else { // 将 任务记录到需要给后端 deleteList.current.push(tempTask); // 如果存在子级, 子级也一起进入删除队列 if (tempTreeMap[tempTask.id]) { deleteChildren(tempTreeMap[tempTask.id]); } gantt.deleteTask(tempTask.id); } // 找到 该任务的位置,并删除 treeMap 中的数据 let originIndex = 0; tempTreeMap[tempTask.parent].forEach((item, index) => { if (item.id === tempTask.id) { originIndex = index; } }); tempTreeMap[tempTask.parent].splice(originIndex, 1); _treeMap.current = tempTreeMap; // 更新所有任务 以及 生成新的 codeMap updateCodeMapAndTask(tempTreeMap); setVisible(false); setMaxCount(); setCurTask({}); }
注意日期的加减
- 在代码中经常能看到 +1 天和 -1 天
- 这是因为甘特图组件用的日期格式是国外的,比如 开始日期是 8/17,结束日期为 8/17,这样刚好算 1天,如果结束日期是 8/18,就是两天了
- 而我们国内,或者说我们公司要求的日期格式是 开始日期 8/17,结束日期 8/18 这样算1天
- 所以计算的日期要与 甘特图组件的日期格式保持一致
- 渲染的数据要进入模态框时,需要 +1 天,符合国内的日期展示格式
- 保存任务后回到赶图的渲染就需要 -1 天,符合 dhtmlx-gantt 底层需要的日期格式
- 当然如果不需要纠结这种日期格式,只需要和 dhtmlx-gantt 保持一致时,这些日期加减的代码全都不需要
总结
- demo 体验地址:3h6qyr.csb.app/
- 源码地址:codesandbox.io/s/gantt-dem...
- dhtml-gantt 案例地址:docs.dhtmlx.com/gantt/sampl...
- dhtml-gantt API 文档地址:docs.dhtmlx.com/gantt/api__...
- dhtml-gantt React 中引用使用文档:dhtmlx.com/blog/create...
- 这一期结束了,大家应该都能简单的实现上了最基础的甘特图组件,接下来在下一期我会讲一下 任务之间的联动、任务序号重排等逻辑,如果不想错过的可以订阅这个专栏,接下来会继续更新的
- 文章中可能会有错误与遗漏,欢迎大家指正与讨论。