基于jQuery+LayUI+Bootstrap+dhtmlxGantt的甘特图实现方案
2026/9/16 10:26:18 网站建设 项目流程

简介:面向Web前端与项目管理开发者的任务进度甘特图集成示例,基于jQuery、LayUI、Bootstrap与dhtmlxGantt四个技术栈,实现从页面布局、响应式适配到任务展示、拖拽编辑与数据交互的完整流程。压缩包共499个文件,以js、html、css等源码与样式文件为主,辅以gif演示、png图标、map与json配置、字体文件及sql数据库脚本,整体大小约2.9MB,目录结构便于按技术模块对照学习。已有477人学习下载,适合需要快速搭建可视化项目进度系统或熟悉dhtmlxGantt二次开发的中级前端工程师。通过该示例可掌握jQuery与LayUI/Bootstrap协同布局、dhtmlxGantt任务与时间线配置方法,以及前后端数据联动思路,直接复用其中的交互逻辑与视觉样式,缩短实际项目开发周期。

1. 为什么我用四个库拼甘特图而不是直接上全功能 SaaS

接手过一个内部项目管理系统,任务进度要靠人脑在 Excel 里对日期,状态一改就要重发一轮邮件。甘特图之所以一直没删,是因为管理者只关心两件事:每项任务落在哪一周、谁的任务阻塞了谁。当时选型排除了现成的 SaaS,也排除了重前端框架,最后定下了 jQuery + LayUI + Bootstrap + dhtmlxGantt 这条看起来“老派”却足够务实的路线:dhtmlxGantt 负责时间线计算和拖拽渲染,jQuery 管 Ajax 回写,LayUI 和 Bootstrap 负责后台管理界面与弹窗表单。这个组合对后端技术栈没有要求,Java、PHP、Node 都能接,适合从零开始搭一个任务进度处理模块,也适合塞进已有后台系统的某个页面里。

2. 壳层框架组合:LayUI、Bootstrap 和 jQuery 怎么分工不打架

先解决“为什么同时用两个 CSS 框架”的问题。LayUI 的优势是后台组件和现成的 layer 弹层,Bootstrap 的价值在于栅格系统、表格样式和响应式工具类。如果页面是纯内网后台,可以只留 LayUI;一旦要考虑不同屏幕尺寸甚至嵌入第三方门户,Bootstrap 的栅格会让布局省事很多。同一个页面里两个框架并存时,最常见的冲突是 class 命名不一致导致的按钮和表单错位,避免方法就是分区使用:布局和栅格用 Bootstrap,弹层和表单校验用 LayUI,核心甘特图容器两边都不碰。

2.1 先把 HTML 骨架搭出来

<div class="container-fluid"> <div class="row"> <div class="col-md-3"> <div class="layui-card"> <div class="layui-card-header">任务类型筛选</div> <div class="layui-card-body"> <select id="taskType" lay-filter="taskTypeFilter"></select> </div> </div> </div> <div class="col-md-9"> <div id="gantt_here" style="width: 100%; height: 560px;"></div> </div> </div> </div>

左侧栏放筛选器,右侧栏放甘特图容器。gantt_here是 dhtmlxGantt 的挂载点,它只需要一个 div,不需要框架提供的类,所以两个 CSS 都不会污染它。select 上的lay-filter属性是 LayUI form 模块的专用钩子,后续要用form.on('select(...)')来监听,普通 jQuery change 事件在这里拿不到值。

LayUI 的layui-card和 Bootstrap 的container-fluid各管一段。需要注意,Bootstrap 4 以上的.row自带负 margin,如果页面已经在 LayUI 的布局容器里,建议给container-fluid补齐padding: 0 15px,否则横向滚动条会多出来一个小几像素的缝隙,排查起来很隐蔽。

2.2 jQuery 入口与 LayUI 模块的初始化顺序

layui.use(['form', 'layer', 'laydate'], function () { var form = layui.form; var layer = layui.layer; var laydate = layui.laydate; $(document).ready(function () { loadTaskType(); initGantt(); initGanttEvents(); }); form.on('select(taskTypeFilter)', function (data) { refreshGantt(data.value); }); });

layui.use是异步加载机制,回调里才能拿到 form 等模块;$(document).ready保证 DOM 就绪。二者触发的先后受脚本放置位置影响,我一般在</body>前引入 layui.js 和 jquery.js,再用layui.use包一层,避免 jQuery ready 在 LayUI 模块尚未加载时就执行。initGantt()要放在 ajax 加载任务之前,因为甘特图组件必须先渲染出空白时间轴,再往里填充数据,顺序反了会出现时间轴宽度计算错误,拖拽时任务条与日期刻度对不齐。

2.3 下拉菜单到底用哪套:Bootstrap 静态下拉还是 LayUI form

需求场景推荐方案原因
仅做筛选,选项固定不常变Bootstrap 原生 select不需要额外初始化,样式跟页面统一
选项由接口动态返回LayUI form selectlayui.use后可用form.render('select')刷新
需要级联筛选,且选项有联动LayUI form select + jQuery通过form.on监听重载数据,交互闭环短
时间范围筛选LayUI laydate 配合独立面板日历面板位置可控,后面专门讲

Bootstrap 的静态下拉菜单在代码里写死 option 就行,它的交互插件对动态 option 支持比较弱,动态数据场景下要自己维护 DOM,容易和 LayUI 的渲染机制互相覆盖。我的习惯是:一个页面里若已经引入 layui.js,就统一用 LayUI 的 form 模块管理所有下拉框,Bootstrap 只保留栅格和基础排版。

2.3.1 动态给 LayUI select 赋值
function loadTaskType() { $.getJSON('/api/task/types', function (res) { var html = '<option value="">全部类型</option>'; res.data.forEach(function (item) { html += '<option value="' + item.id + '">' + item.name + '</option>'; }); $('#taskType').html(html); form.render('select'); }); }

form.render('select')是必需步骤,很多人在这一步漏了,结果 HTML 变了但页面上还是旧选项。res.data的字段假设是idname,你写后端接口时保持这两个字段名一致,前端的拼串逻辑就不用改。值用数字还是字符串会影响后续筛选时的类型判断,建议前端统一Number(data.value),避免'01'和数字1的严格相等判断出问题。

3. dhtmlxGantt 的数据模型与初始化参数:时间字段和任务依赖是地基

dhtmlxGantt 能成为甘特图实现的首选,核心不在于它画出来的横条,而是它直接用数组数据驱动整个时间轴渲染。工程上只需要给组件一个 JSON 数组,组件内部会自己构建层级、计算依赖线、排布泳道。要掌握它,先要弄清楚它的数据模型,否则后续所有交互都是空中楼阁。

3.1 必须理解的两张“表”:tasks 与 links 的字段约束

var tasks = { data: [ { id: 1, text: '需求评审', start_date: '2024-11-01 09:00', duration: 2, progress: 0.8, parent: 0, type: 'task' }, { id: 2, text: '数据库设计', start_date: '2024-11-03 09:00', duration: 5, progress: 0.35, parent: 0, type: 'task' } ], links: [ { id: 1, source: 1, target: 2, type: '0' } ] };

data数组的每条记录对应一个任务条,links数组描述任务之间的依赖关系。task 的start_date默认格式是'%Y-%m-%d %H:%i'duration的单位是整天,progress是 0 到 1 的小数。parent为 0 表示顶级任务,如果要建子任务,把父任务的parent设为某个任务的 id,并把父记录的type设为'project',这样才会出现“父任务条包裹子任务”的折叠效果。links里的type: '0'表示“结束到开始”(finish-to-start),这是最常见的依赖类型;type: '1'是开始到开始,type: '2'是结束到结束,type: '3'是开始到结束。第一次接触时容易记混,我的经验是只记住常用的 0 和 1,其余的可选项使用时再查文档。

3.2 初始化配置项:哪些参数不改会出问题

gantt.config.date_format = '%Y-%m-%d %H:%i'; gantt.config.duration_unit = 'day'; gantt.config.scale_unit = 'day'; gantt.config.step = 1; gantt.config.subscales = [{ unit: 'month', step: 1, date: '%F, %Y' }]; gantt.config.drag_move = true; gantt.config.drag_resize = true; gantt.config.drag_progress = true; gantt.config.round_dnd_dates = true; gantt.init('gantt_here'); gantt.parse(tasks);

核心配置项说明:

  • scale_unit决定时间轴主刻度的最小单位,'day'表示按天;如果任务是按小时排的,改成'hour'配合step能逼真展示小时级排程。
  • round_dnd_dates设为 true,拖拽任务条时起始日期会就近取整,避免出现“下午 15:47 开始”这类让人抓狂的时间点;如果你要精确控制拖拽粒度,可以改成 false 配合round_dnd_days自定义取整规则。
  • parse()是同步方法,传进去的数组会被转成内部结构并立即渲染,这也是为什么必须在 DOM 挂载点存在之后调用。

完整的时间刻度配置项:

配置项可选值作用常见坑
scale_unitday / week / month / hour主刻度粒度与 duration_unit 不一致时任务条长度失真
step正整数每个刻度跨越几个单位过大时时间轴太密,拖拽难对齐
subscales数组次刻度,通常放月/年不设定时显示不全
date_format字符串模板解析输入数据的日期格式与后端日期字符串不一致则解析成 NaN

3.3 从后端接口拿数据:用 jQuery 清洗后交给 gantt.parse

function initGantt() { gantt.config.date_format = '%Y-%m-%d %H:%i:%s'; gantt.init('gantt_here'); $.getJSON('/api/project/tasks', function (res) { if (res.code !== 0) { layer.msg(res.msg || '任务加载失败'); return; } var mapped = { data: res.data.tasks.map(function (item) { return { id: item.id, text: item.name, start_date: item.startTime, // "2024-11-01 09:00:00" duration: item.workDays, progress: item.progress, parent: item.parentId || 0, type: item.isProject ? 'project' : 'task' }; }), links: res.data.links.map(function (l) { return { id: l.id, source: l.from, target: l.to, type: l.linkType }; }) }; gantt.parse(mapped); }); }

后端返回的字段不可能恰好叫start_dateparent,所以必须设一层数据清洗。start_date这里假设后端给的是'2024-11-01 09:00:00'带秒的字符串,所以把date_format配上%H:%i:%s;如果你只传到分钟,格式不对会导致浏览器时间解析出 Invalid Date 占位。parentId在顶层为空时,用|| 0保证它是数字,dhtmlxGantt 要求 parent 必须能对应存在的任务 id,传空字符串会触发层级错误警告。这里先定义layer变量,是因为前面layui.use的回调作用域,initGantt函数能直接访问到它。

4. 任务交互与数据回写:拖拽、点击、弹窗到 Ajax 持久化

甘特图只有展示没有交互,价值就打对折。dhtmlxGantt 的事件机制覆盖了“用户操作”到“数据更新”的完整链路,配合 jQuery 的 Ajax,可以做成一个闭环:用户拖任务条 → 组件更新内存数据 → 触发事件 → 前端把变更后的字段发送给后端 → 后端执行 update。

4.1 监听哪些事件才能真正把改动抓到

gantt.attachEvent('onAfterTaskDrag', function (id, mode, task) { var changed = { id: task.id, start_date: gantt.templates.date_format(task.start_date), duration: task.duration, progress: task.progress }; saveTaskChange(changed); return true; }); gantt.attachEvent('onAfterLinkAdd', function (id, link) { saveLinkChange({ source: link.source, target: link.target, type: link.type }); }); gantt.attachEvent('onTaskClick', function (id, e) { var task = gantt.getTask(id); openEditDialog(task); return true; });

onAfterTaskDrag是拖拽结束事件,mode参数可取值'move''resize''progress',分别对应三种拖拽形态。如果不需要区分操作类型,可以忽略mode,只用 task 的最终值。特别注意:返回true或不返回,甘特图保持默认行为;返回false会撤销拖拽,这一步经常有人把event.cancelBubble之类的逻辑混进来,结果拖完数据跳回去了。

onAfterLinkAdd新增依赖线后触发,link对象里有sourcetargettype,正好对应 links 表三个字段。删除依赖线要监听onAfterLinkDelete,事件签名一致,只是拿到的 link 对象已经被移除,不能再做二次确认。

4.2 用 jQuery 批量回写,不用每个事件单独请求

var changeQueue = []; function saveTaskChange(taskData) { changeQueue.push({ action: 'task_update', data: taskData }); debounceFlush(); } function saveLinkChange(linkData) { changeQueue.push({ action: 'link_add', data: linkData }); debounceFlush(); } var flushTimer = null; function debounceFlush() { clearTimeout(flushTimer); flushTimer = setTimeout(function () { var payload = changeQueue.splice(0, changeQueue.length); $.ajax({ url: '/api/project/tasks/sync', type: 'POST', contentType: 'application/json', data: JSON.stringify(payload), success: function (res) { if (res.code !== 0) { layer.msg('同步失败: ' + res.msg); } } }); }, 600); }

把多次拖拽动作合并成一批请求,后端少受几次无效写库的干扰。600ms 的防抖时间适合单用户操作场景,如果多人同时编辑同一项目,需要后端做行级版本号比对,否则后提交的人会整体覆盖别人的修改。changeQueue存放在内存里,页面刷新就会丢失,所以只适合在线操作;如果需要离线编辑后批量上传,改用 localStorage 缓存队列,页面加载时若检测到残留队列先执行恢复。

4.3 在 LayUI 弹层里嵌入任务编辑表单

function openEditDialog(task) { var editHtml = [ '<form class="layui-form" id="taskEditForm">', ' <div class="layui-form-item">', ' <label class="layui-form-label">任务名称</label>', ' <div class="layui-input-block">', ' <input type="text" name="text" value="' + task.text + '" class="layui-input">', ' </div>', ' </div>', ' <div class="layui-form-item">', ' <label class="layui-form-label">开始时间</label>', ' <div class="layui-input-block">', ' <input type="text" name="start_date" id="editStartDate" class="layui-input">', ' </div>', ' </div>', ' <div class="layui-form-item">', ' <label class="layui-form-label">工期(天)</label>', ' <div class="layui-input-block">', ' <input type="number" name="duration" value="' + task.duration + '" class="layui-input">', ' </div>', ' </div>', '</form>' ].join(''); layer.open({ type: 1, title: '编辑任务', area: ['520px', '360px'], content: editHtml, btn: ['保存', '取消'], yes: function (index) { var formData = $('#taskEditForm').serializeArray(); var payload = {}; formData.forEach(function (field) { payload[field.name] = field.value; }); gantt.updateTask(task.id, { text: payload.text, start_date: payload.start_date, duration: parseInt(payload.duration, 10) }); saveTaskChange({ id: task.id, text: payload.text, start_date: payload.start_date, duration: parseInt(payload.duration, 10) }); layer.close(index); } }); laydate.render({ elem: '#editStartDate', type: 'datetime', value: task.start_date, max: 0 }); }

这里有个关键衔接:LayUI 的layer.open用的是 type 1,content 是 HTML 字符串,等弹层 DOM 出现之后再调用laydate.render绑定日期控件,不能在 open 之前绑定。max: 0表示不允许选未来时间,对应内部项目管理里“只能调整已发生或今天的任务”的诉求,如果你想放开,直接删掉这个配置就行。弹窗保存后先用gantt.updateTask更新内存,再走saveTaskChange进防抖队列。整体流程是“本地立即可见,后台稍后落库”,用户体验比直接 await Ajax 完再刷新整张图舒服得多。

4.4 回写失败时最容易出问题的三个位置

第一,后端接口如果要求时间格式%Y-%m-%d %H:%i,而前端从gantt.templates.date_format(task.start_date)拿到的字符串带秒,超过 MySQL datetime 精度也行,但如果接口是严格格式比对,就会卡在数据解析这步。第二,type字段在 links 里是字符串'0',JSON 序列化后它在请求体里是字符串,后端如果用 int 类型接收,强转时要容忍。第三,跨时区问题:浏览器所在时区与服务器时区不一致时,task.start_date会被 dhtmlxGantt 用Date对象转换一次,建议前端传 UTC 字符串、后端统一转成业务时区,不要在接口层做加减。

5. 主题切换、日期面板位置调整与滚动加载更多:三个实战细节

最后讲三个我在真实项目中调试过的点,都属于“功能没问题,但体验差一口气”的类型,调完之后的观感和操作效率会有明显改观。

5.1 一键换肤:从 skyblue 到 material

dhtmlxGantt 的皮肤是纯 CSS 控制的,引入对应 CSS 文件即可切换主视觉。示例压缩包里已经有dhtmlxgantt_broadway.cssdhtmlxgantt_contrast_white.cssdhtmlxgantt_contrast_black.cssdhtmlxgantt_skyblue.cssdhtmlxgantt_material.cssdhtmlxgantt_meadow.css,按需保留一份就好,不要全引。material 风格适合内部管理系统,线条更细,配 LayUI 默认主题不会突兀。

<link rel="stylesheet" href="css/dhtmlxgantt_material.css">
gantt.config.row_height = 36; gantt.config.bar_height = 28; gantt.config.taskbar_select = true;

row_heightbar_height是视觉节奏的关键。36 行高配 28 条高,任务条在垂直方向有呼吸感;要把一屏塞更多任务,可以把 row 压到 28、bar 压到 20,但字会小许多,触屏设备上不建议。LayUI 侧的主题变量通过覆盖--layui-font-size等 CSS 变量调整,配合.layui-card的背景色,可以让两侧区块的颜色倾向一致。如果不想写一堆样式覆盖,最简单的方法是先把dhtmlxgantt_broadway.css排除掉,因为 broadway 默认偏深色,和 LayUI 亮色面板放一起违和。

5.2 LayUI date 的最大日期限制与日历面板位置

项目里经常要求“只能选今天及之前”,其实不只在任务编辑弹窗里,筛选器也常用。max: 0表示今天,但 LayUI 的 laydate 组件在某些版本里max: 0会让“今天”本身也变成灰色,实际踩过这个坑之后,我的写法是用new Date()动态格式化:

function pad(n) { return n < 10 ? '0' + n : '' + n; } var now = new Date(); var today = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate()); laydate.render({ elem: '#editStartDate', type: 'datetime', max: today, position: 'fixed' });

position: 'fixed'解决弹层内部日期面板的位置错位。LayUI 弹层默认是 absolute 定位,当页面出现滚动条或者弹层本身 overflow 时,日期面板可能飘到页面左下角,改成 fixed 后它会相对视口定位,不再跟随滚动。如果仍然错位,还可以给弹层 content 外层包一个position: relative的 div,同时给 laydate 加position: 'static'让它直接内嵌到表单里,而不是弹出浮层——这是数据表单常用的变通,代价是弹层高度会变大一些。

5.3 甘特图滚动到底部加载更多任务的方案

长项目的任务数超过几百条,一次性全部渲染会明显卡顿。常见做法是分页加载:滚动到甘特图容器底部时,追加下一页任务。

var pageIndex = 1; var pageSize = 200; $('#gantt_here').on('scroll', function () { var el = this; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) { loadNextPage(); } }); function loadNextPage() { pageIndex++; $.getJSON('/api/project/tasks?page=' + pageIndex + '&size=' + pageSize, function (res) { if (!res.data || res.data.tasks.length === 0) { $(document).off('scroll', '#gantt_here'); return; } var newTasks = res.data.tasks.map(function (item) { return { id: item.id, text: item.name, start_date: item.startTime, duration: item.workDays, progress: item.progress, parent: item.parentId || 0 }; }); gantt.parse({ data: newTasks, links: [] }, 'extend'); gantt.render(); }); }

gantt.parse的第二个参数'extend'是关键:它告诉组件把新数据追加到已有数据里,而不是整体替换。'replace'会清空全部任务后重新渲染,看起来像页面闪一下,体验很差。分页追加后还要处理一个问题:新增任务可能引用了还没加载出来的父任务 id,所以加载顺序要保证父任务先于子任务出现,否则父 id 对不上,渲染会提示任务不存在。我的做法是后端按 parentId 为空的节点在前排序返回,前端就不用额外调整。追加数据后调用gantt.render(),组件会重新计算时间轴范围并把新任务条画到正确位置。打开浏览器开发者工具的 Network 面板,观察请求参数从 page=1 变到 page=2;如果 page 没变而不断重复请求,多半是滚动容器的 clientHeight 与 scrollHeight 判断条件算错了——注意甘特图内部有自己的滚动容器,事件要绑在挂载 div 本身,绑到外层document上是不会触发的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询