简介:一个结合jQuery、LayUI、Bootstrap与dhtmlxGantt四种前端技术的甘特图开发示例,面向需要任务进度可视化和项目管理的Web开发者。资源包共499个文件,大小约2.9MB,包含139个js脚本、128个html页面、35个css样式,并附多款dhtmlxGantt主题样式、动态演示gif、SQL初始数据与MPP项目文件,便于直接查看运行效果和二次开发。已有477人学习下载。示例重点展示了dhtmlxGantt的任务管理、依赖关系、拖放操作及时间轴缩放等核心能力,同时结合jQuery动态处理数据交互,利用LayUI与Bootstrap搭建响应式后台界面。通过学习这套代码,可以快速搭建一套任务进度处理系统,对开发项目管理工具或需要可视化进度的Web应用都具有切实参考价值。
1. 为什么用jquery+layui+bootstrap+dhtmlxgantt拼任务进度甘特图
后台管理系统做到一定阶段,任务进度可视化几乎是必备模块。原生的时间线手写难度不高,但要做到拖拽、依赖线、按时长渲染任务条,代码量和测试成本都会成倍上涨。DHTMLX Gantt把甘特图最核心的渲染和交互封装好了,但它默认的皮肤和后台常用组件并不统一,需要一套周边库来补齐表单、弹窗和页面框架。
jQuery负责事件和Ajax,layui处理日期控件、下拉框、滑块和弹层,bootstrap提供栅格、模态框和基础组件,dhtmlxgantt只关心图本身。这套组合最大的价值是:不需要把项目迁移到新框架,老后台页面引入几个静态文件就能跑出一张可交互的任务进度甘特图。适合已有bootstrap或layui后台、想快速交付任务进度模块的团队,也适合前端基础较弱、想少踩坑的开发者。
2. 搭好页面骨架:bootstrap布局下用dhtmlxgantt渲染第一张任务进度图
2.1 四个库的引入顺序与职责边界
先明确各自的活。bootstrap只能管网格、按钮、模态框这类静态基础组件;layui负责动态的表单控件,比如日期、下拉框、滑块和layer弹层;jQuery是地基,管事件绑定、Ajax请求和DOM操作;dhtmlxgantt负责甘特图的时间轴、任务条和依赖线。这样划分后,每个库改版时影响范围都能缩小。
引入顺序要按依赖关系排:jQuery必须在最前面,bootstrap和layui的js都依赖jQuery;如果页面里同时用了bootstrap和layui的弹层,尽量让bootstrap的css先加载,layui的css后加载,因为layui的弹层样式更完整,后加载能覆盖部分bootstrap默认样式。
<!-- head里先放样式 --> <link rel="stylesheet" href="static/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="static/layui/css/layui.css"> <link rel="stylesheet" href="static/gantt/dhtmlxgantt.css"> <!-- body结束前放脚本 --> <script src="static/jquery/jquery.min.js"></script> <script src="static/bootstrap/js/bootstrap.min.js"></script> <script src="static/layui/layui.js"></script> <script src="static/gantt/dhtmlxgantt.js"></script>代码逻辑说明:bootstrap.min.js依赖jQuery,所以要等jquery.min.js执行完再加载。dhtmlxgantt.css放在最后,是想让甘特图里的大部分尺寸不被bootstrap的box-sizing规则影响到。但实践中,bootstrap会强制给页面元素设置box-sizing: border-box,这会让甘特图的任务条宽度少算边框,整体偏移1到2个像素。遇到这种情况,写一条覆盖规则:
.gantt_task_cell, .gantt_task_row, .gantt_task_content { box-sizing: content-box; }注意:这段覆盖规则要放在dhtmlxgantt.css之后,否则会被同类选择器覆盖。如果甘特图版本较新,内部已经兼容了border-box,可以不加这段,但加上也基本无损。
2.2 初始化甘特图的最小代码与数据字段
DHTMLX Gantt渲染一张图需要三样东西:一个容器div、一份任务数据、一次gantt.init。示例.rar里最常见的结构是父子数据,父任务表示阶段,子任务表示具体工作项。
<div id="gantt_here" style="width: 100%; height: 600px;"></div>const tasks = { data: [ { id: 1, text: '需求评审', start_date: '2024-11-01 09:00', duration: 2, progress: 1, parent: 0 }, { id: 2, text: '技术方案', start_date: '2024-11-03 09:00', duration: 3, progress: 0.6, parent: 0 }, { id: 3, text: '后端开发', start_date: '2024-11-06 09:00', duration: 10, progress: 0.3, parent: 2 }, { id: 4, text: '前端开发', start_date: '2024-11-08 09:00', duration: 8, progress: 0, parent: 2 } ], links: [ { id: 1, source: 1, target: 2, type: '0' } ] }; gantt.config.date_format = '%Y-%m-%d %H:%i'; gantt.init('gantt_here'); gantt.parse(tasks);逻辑说明:data数组里每条记录代表一个任务,start_date是计划开始时间,duration是工期天数,progress是0到1的小数,1代表100%完成;parent为0表示顶层任务,否则是父任务id。links定义任务依赖关系,type为0代表“结束到开始”,即前置任务结束后后置任务才能开始。
参数说明:gantt.config.date_format是DHTMLX Gantt自己的一套格式符,%Y是四位年份,%m是两位月份,%d是日期,%H:%i是时分。很多人误用JavaScript的YYYY-MM-DD,结果日期解析成NaN,任务条全部消失。这个配置要和后台返回的字符串格式严格一致,否则gantt.parse会用当前时间兜底,造成甘特图时间轴错乱。
时间刻度也可以在这里一并调好。任务粒度是天,就用scale_unit指定主刻度,subscales指定副刻度:
gantt.config.scale_unit = 'day'; gantt.config.step = 1; gantt.config.date_scale = '%d %M'; gantt.config.subscales = [ { unit: 'month', step: 1, date: '%Y, %F' } ];scale_unit是主时间单位,step是主刻度跨越几个单位,date_scale控制主刻度标签显示格式,subscales是次级刻度。任务跨度超过30天时,把主刻度改成week,副刻度改成day,横轴会更舒服。这些配置要在gantt.init之前设置,初始化后再改不会生效。
任务字段用下面这张表做参考,示例数据里一般不会全部出现,但项目落地时大概率都要补:
| 字段名 | 类型 | 说明 | 是否必须 |
|---|---|---|---|
| id | number | 任务唯一标识,父任务和依赖都靠它关联 | 是 |
| text | string | 任务名称,显示在左侧列 | 是 |
| start_date | string | 计划开始时间,按date_format解析 | 是 |
| duration | number | 工期,单位由duration_unit控制,默认天 | 是 |
| progress | number | 0~1小数,0.3表示已进行30% | 否 |
| parent | number | 父任务id,0为顶层任务 | 否 |
| owner | string | 自定义字段,负责人 | 否 |
| status | string/number | 自定义字段,业务状态 | 否 |
2.3 把示例数据换成jQuery的Ajax接口
演示数据写死在JS里方便,但真实项目要从后台拉取。最直接的做法是把gantt.parse包进$.ajax的done回调里。后台返回的数据结构通常是{code: 0, data: {tasks, links}},需要按实际结构调整。
function loadGantt() { $.ajax({ url: '/api/task/plan', type: 'GET', dataType: 'json' }).done(function (resp) { if (resp.code === 0) { gantt.clearAll(); gantt.parse(resp.data); } else { layer.msg(resp.msg, { icon: 2 }); } }).fail(function () { layer.msg('任务计划加载失败', { icon: 2 }); }); }这里用layui的layer.msg做提示,而不是bootstrap的alert,因为alert会阻塞渲染且样式无法统一。clearAll强制清空旧数据再解析,防止之前的分页数据或点击测试数据残留。
需要注意时间格式统一。后台如果返回时间戳,要先把时间戳转成'YYYY-MM-DD HH:mm'字符串。DHTMLX Gantt也支持%s格式,但只认秒级时间戳,毫秒要自己除以1000。我一般在后端返回时就格式化好,前端不做二次转换,避免时区偏差。
3. jQuery+bootstrap把甘特图交互接到任务进度处理上
3.1 用bootstrap模态框接管任务新增与编辑
DHTMLX Gantt自带一个lightbox弹窗,任务字段少时够用,但业务系统里常有负责人、状态、优先级这些额外字段,lightbox里自定义字段的成本不低。常见做法是关闭lightbox,改用bootstrap modal,让表单样式和页面其他部分保持一致。
gantt.config.details_on_create = false; gantt.config.details_on_dblclick = false; gantt.attachEvent('onTaskClick', function (id) { const task = gantt.getTask(id); $('#taskForm [name=id]').val(task.id); $('#taskForm [name=text]').val(task.text); $('#taskForm [name=duration]').val(task.duration); $('#progressInput').val(Math.round(task.progress * 100)); $('#progressText').text(Math.round(task.progress * 100) + '%'); $('#taskModal').modal('show'); return false; });逻辑说明:details_on_create=false和details_on_dblclick=false分别关掉新建时和双击时弹出的lightbox,onTaskClick里返回false可以阻止单击时的默认弹窗。gantt.getTask(id)拿到的是内存里的任务对象,直接取字段赋给表单。注意progress要乘以100再填滑块,因为layui滑块一般用0到100的整数。
保存时,用jQuery收集表单内容,根据id是否存在决定调用gantt.updateTask还是gantt.createTask:
$('#saveTaskBtn').on('click', function () { const taskId = $('#taskForm [name=id]').val(); const task = { text: $('#taskForm [name=text]').val(), start_date: $('#taskForm [name=start_date]').val(), duration: parseInt($('#taskForm [name=duration]').val(), 10), progress: parseInt($('#progressInput').val(), 10) / 100 }; if (taskId) { const target = gantt.getTask(parseInt(taskId, 10)); target.text = task.text; target.start_date = task.start_date; target.duration = task.duration; target.progress = task.progress; gantt.updateTask(target.id); } else { gantt.createTask(task); } $('#taskModal').modal('hide'); });说明:gantt.updateTask(id)会根据内存中已存在task对象刷新甘特图,所以要先把新值赋给gantt.getTask拿到的对象,再调用updateTask。直接调用gantt.parse整体重绘也能达到效果,但会破坏视图的滚动位置和展开状态。新建任务时,gantt.createTask(task)默认挂到根级别,如果想挂到某个父任务下,用gantt.createTask(task, parentId)。progress从滑块读出来的是0到100,必须除以100,否则90进度会渲染成5000%。
3.2 layui滑块调整任务进度并实时回写
任务进度处理里最常见的操作是拖一个进度条,看百分比变化。layui的slider组件做这件事比input[type=range]好看得多,而且自带主题色和回调。
let progressSlider = null; layui.use(['slider'], function () { progressSlider = layui.slider.render({ elem: '#progressSlider', min: 0, max: 100, value: 0, step: 5, theme: '#1E9FFF', change: function (value) { $('#progressText').text(value + '%'); $('#progressInput').val(value); } }); });change回调在滑块拖动松开后触发,适合保存场景;如果想在拖动过程中实时更新进度文本,要改用schoosing事件。step: 5让进度按5%递进,适合粗粒度估算;如果是按工时汇报,改成1。
关键坑在任务切换:打开编辑弹窗时不能只给隐藏input赋值,要用progressSlider.setValue(progress),否则滑块UI还停留在上一次的位置。对应的初始化函数这样写:
function setProgressValue(percent) { $('#progressInput').val(percent); $('#progressText').text(percent + '%'); if (progressSlider) { progressSlider.setValue(percent); } }3.3 jQuery滚动到底部加载更多任务
任务超过几百条时,一次性渲染会让拖拽和缩放都有明显延迟。最简单有效的方案是分页:先加载最近30天或前50条任务,等用户滚动到底部时再加载下一批。DHTMLX Gantt没有内置的分页接口,但它的滚动区域可以像普通DOM一样监听。
let currentPage = 1; $('#gantt_here .gantt_ver_scroll').on('scroll', function () { const el = this; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 80) { if ($(el).data('loading')) return; $(el).data('loading', true); loadMoreTasks(function () { $(el).data('loading', false); }); } }); function loadMoreTasks(callback) { $.ajax({ url: '/api/task/plan/paged', data: { page: ++currentPage, pageSize: 50 }, dataType: 'json' }).done(function (resp) { gantt.parse(resp.data, 'extend'); callback(); }).fail(function () { layer.msg('加载更多失败', { icon: 2 }); callback(); }); }逻辑说明:.gantt_ver_scroll是DHTMLX Gantt新版里的垂直滚动条容器,旧版本的类名可能是.gantt_task或.gantt_data_area,如果选择器不生效,先打开浏览器开发者工具,找到滚动元素真实类名再替换。gantt.parse(data, 'extend')的第二个参数extend表示追加数据而不是清空重画,这是分页加载的关键。
参数说明:判断滚动到底部的余量写了80像素,这个值根据感受调整;太大会提前加载,太小会看到明显的加载空白。用data('loading')做节流标记,防止滚动事件在底部连续触发时发出多个重复请求。另外,分页请求最好带上当前时间范围和父任务id,否则新加载的子任务可能因为父任务还没加载而静默挂在根节点。
4. layui日期控件和select动态赋值在甘特图表单里的联动
4.1 layui date 最大日期当前日期,限制计划时间范围
甘特图里的任务开始日期一般不允许晚于某个截止日,也不允许太随意。layui的laydate用max参数控制最大可选日期,配合min可以圈出一个合理的计划区间。
layui.use('laydate', function () { const laydate = layui.laydate; const today = laydate.now(); laydate.render({ elem: '#startDate', format: 'yyyy-MM-dd HH:mm', min: '-15d', max: today + ' 23:59:59', done: function (value) { $('#startDate').val(value); calcEndDate(); } }); });逻辑说明:laydate.now()返回的是2024-01-01这样的日期字符串,如果不拼23:59:59,max会变成当天零点,用户选择今天下午的时间会被禁止。min: '-15d'表示允许选择15天前的日期,适合补录历史任务。done回调在日期选定时触发,这里主要做一件事:把选好的值写回表单,同时联动计算结束日期。
任务进度处理的联动里,常用结束日期=开始日期+工期,用户在modal里改了开始日期后,结束日期输入框需要跟着变:
function calcEndDate() { const start = $('#startDate').val(); const days = parseInt($('#duration').val(), 10) || 1; if (!start) return; const d = new Date(start.replace(/-/g, '/')); d.setDate(d.getDate() + days); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); $('#endDate').val(d.getFullYear() + '-' + month + '-' + day + ' 18:00:00'); }这里有一个跨平台坑:在iOS Safari里,new Date('2024-11-01 09:00')可能解析失败,而new Date('2024/11/01 09:00')更可靠。所以replace横杠为斜杠是习惯写法。甘特图里结束日期是动态算出来的,不需要写进任务对象,保存时DHTMLX Gantt会根据start_date和duration自己算结束时间。
4.2 编辑弹窗里给layui select动态赋值并重渲表单
任务状态、负责人、优先级这类字段用layui select渲染,不能直接$('#status').val(1)赋值。因为layui把原生select隐藏,另建了一套div结构。正确做法是用form.val批量赋值,再调用form.render('select')让图层刷新。
layui.use('form', function () { const form = layui.form; form.on('submit(saveTask)', function (data) { // 这里处理保存,data.field包含表单所有值 const task = data.field; return false; }); }); function openEditModal(task) { layui.form.val('taskForm', { id: task.id, text: task.text, status: task.status || 0, owner: task.owner || '', priority: task.priority || 'normal' }); layui.form.render('select'); }form.val('taskForm', {...})的第一个参数是表单容器上的lay-filter,第二个参数是赋值对象。赋完值必须紧跟form.render('select'),否则下拉框选中项是高亮样式,但提交时原生select值可能还是旧值。
select的选项数据如果是动态加载的,比如负责人列表从接口拿,要注意时机:先渲染选项,再form.render('select'),然后再form.val赋值。顺序反了会导致选中不生效。常见做法是:
$.get('/api/user/list', function (resp) { const opts = resp.data.map(function (u) { return '<option value="' + u.id + '">' + u.name + '</option>'; }); $('#ownerSelect').html(opts.join('')); layui.form.render('select'); if (task.owner) { layui.form.val('taskForm', { owner: task.owner }); layui.form.render('select'); } });这个模式在“新增/编辑共用一套下拉选项”时特别有用。第一次渲染下拉,第二次只赋值并重渲染,可以避免选项还没加载完就被赋值覆盖。
4.3 layui日期控件点击后日历面板位置调整与bootstrap静态下拉菜单
在bootstrap modal里打开laydate,最容易遇到的问题就是日历面板定位错位:面板飘到页面左上角,或者盖在弹层下面。原因是laydate默认用position: absolute定位,而modal本身含有transform属性时,absolute定位的参考容器会变成modal,坐标计算就乱了。
解决办法是给laydate传入position: fixed,并把zIndex调到一个足够大的值:
laydate.render({ elem: '#startDate', position: 'fixed', zIndex: 99999999, trigger: 'click' });trigger: 'click'表示点击输入框弹出,默认是focus,在modal里focus可能会触发两次弹出,造成面板抖动。加了position: fixed后,面板会相对于浏览器视口定位,不再受modal的transform影响。如果还不行,在bootstrap modal的shown.bs.modal事件里重新初始化一次laydate,一般能解决大部分定位问题。
至于“jquery 模拟 bootstrap html静态下拉菜单”,这是一个常见需求:不引入bootstrap的dropdown.js,但希望静态下拉菜单能展开收起。做法很简单,jQuery直接切换open或show类:
$(document).on('click', '.dropdown-toggle', function (e) { $(this).parent().toggleClass('open'); e.stopPropagation(); }); $(document).on('click', function () { $('.dropdown.open').removeClass('open'); });逻辑说明:第一次点击.dropdown-toggle时给父容器加open类,下拉菜单通过CSS中的.open .dropdown-menu规则显示出来;第二次点击其他地方时,removeClass('open')收起。这里用事件委托,保证动态创建的下拉按钮也能生效。bootstrap 4把open类换成了show,所以代码要相应改成toggleClass('show')。这个技巧在你只想用bootstrap基础组件、不想再引入一堆JS时很实用。
5. dhtmlxgantt任务进度处理的参数微调与数据验证
5.1 自定义任务条上的进度文本
甘特图默认在任务条里画一条进度填充,但文本格式固定。想让看板更直观,可以自己控制进度百分比展示:
gantt.templates.task_progress = function (start, end, task) { return Math.round(task.progress * 100) + '%'; };这个模板函数返回的字符串会显示在任务条上。函数接收的start、end是任务的时间范围,task是任务对象。如果任务条宽度很小,百分比文本会溢出,可以用CSS裁剪:
.gantt_task_content { overflow: hidden; white-space: nowrap; }5.2 数据校验:不让progress越界和孤儿节点吃掉任务
任务进度处理里最容易出现两类脏数据:progress不在0到1之间,parent指向不存在的任务。我在gantt.parse之前总会跑一遍清洗函数:
function normalizeTasks(tasks) { tasks.data.forEach(function (task) { task.progress = Math.min(1, Math.max(0, task.progress)); task.duration = (task.duration && task.duration > 0) ? task.duration : 1; if (task.parent && !tasks.data.some(function (t) { return t.id === task.parent; })) { task.parent = 0; } }); return tasks; }如果接口返回的progress本来就是50,不是0.5,这个函数会把50压成1,任务看起来100%完成。所以清洗前最好明确接口约定。我更建议在后端就统一成小数,前端只负责展示。孤儿节点的处理上,DHTMLX Gantt不会报错,但子任务会默默消失,清洗后再parse可以省去很多排查时间。
5.3 用gantt.serialize做本地备份和字段核对
调试任务进度模块时,最直接的验证办法是把当前甘特图数据序列化成JSON,打印出来看任务和依赖是否完整:
$('#exportBtn').on('click', function () { const json = gantt.serialize(); const backup = JSON.stringify(json, null, 2); console.log(backup); const blob = new Blob([backup], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'gantt-backup.json'; a.click(); });gantt.serialize()返回的对象带有data和links,结构可以直接喂给gantt.parse。做保存接口时,把json.data里每条任务的progress乘以100再提交,后端按百分比存储。如果进度精度到小数点后一位,格式化时用toFixed(1)避免浮点误差。最后一个实用技巧:把序列化JSON和修改后的HTML一起存成示例.rar同款结构,下次重装环境时不用重新点一遍面板就能复现甘特图当前状态。
本文还有配套的精品资源,点击获取