前言 根据业务要求,需要做一个根据任务周期来统计人员任务量的功能。需要能够清晰的展示人员与工作量的安排情况,以便每个项目组或者部门 leader 方便安排人手及工作,界面展示如下:
要求
有人员列表,并且人员列表下有其对应的任务及相关信息
拥有检索,查询功能
在人员行显示工作任务的数量,并显示在相对应的日期上(根据颜色不同反应出人员工作的任务量是正常或是积压)
在事项行上显示相对应的任务周期
点击人员工作任务显示详情弹窗
点击事项任务显示任务详情弹窗
点击事项行显示事项详情弹窗
准备工作 组件选择 首先我们需要的是一个传统的 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 ( <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 if (((task || {}).ganttList || []).length > 0 ) { task.ganttList.map((item ) => { item._start = date_utils.parse(item.start); item._end = date_utils.parse(item.end); if (date_utils.diff(item._end, item._start, "year" ) > 10 ) { item.end = null ; } item._index = i; 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" ); } if (!item.start || !item.end) { item.invalid = true ; } 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; }); }
修改 弹框组件 开发弹框组件的时候,主要遇到的两个大坑:
点击展示弹框时,父容器也出现了弹框;
由于弹框是个真实 dom,如果列表拉到最下面了,真实 dom 会将整个画布容器撑起来,出现一大片空白
第一个问题一看就是需要取消冒泡,但是在 index 中很多地方是这样绑定的点击事件,开发的时候也被误导了
1 2 3 4 5 6 7 8 9 10 11 12 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 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) { 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 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 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 show (options, clickedPosition ) {const _ganttChartTotalHeight = document .getElementById("gantt" ).clientHeight;this .parent.style.left = clickedPosition.x - 115 + "px" ;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 的类中根据产品要求修改即可,至此,整个功能算是基本实现了。