Vue3+Gantt:把任务排期、依赖关系和里程碑放进项目管理
项目里有几十项任务时,一张表能告诉你每项任务的负责人和截止日期,却很难迅速回答:哪些工作正在并行,哪一步晚了会影响后续,验收节点落在哪天?
把任务放进同一条时间轴,项目计划就有了另一种读法。阶段的跨度、子任务的安排、前后工作的衔接,可以放在一个画面里讨论。

RuoYi Office 使用 Vue3 和 dhtmlx-gantt 实现这一视图,SpringBoot 提供任务和依赖数据。这篇从实际项目页面往下拆,重点看一根时间条怎样关联业务任务,以及修改之后怎样写回。
前两张系统截图来自同一演示项目,后面另列一份项目画面对照;均为历史完整画面,实现按当前源码核对;本轮只读查看了该项目,没有执行拖拽、连线或修改业务数据。下面的接入过程是源码解析,末尾另给出可复现的联调步骤。
任务树和甘特图,应该读同一份计划
先看项目台账里的 WBS。需求分析、系统设计、开发实现、测试与上线,分别作为阶段组织任务。这里既有计划日期,也有任务状态与进度,适合逐项核对。

历史完整画面:任务管理视图展示项目的阶段划分,左侧保留项目模块菜单。
切到甘特图,同一项目的需求分析可以继续展开为需求调研、需求规格说明书编写、需求评审;调研下面又有 HR 和 OA 两项工作。表格里的层级关系被保留下来,日期则变成横向的位置和长度。

历史完整画面:HR 与 OA 调研落在同一时段,需求规格说明书安排在后续日期。这里展示已有计划,不是本轮拖动前后对比。
这两种视图各有用途。WBS 方便定位"谁负责、处于什么状态";甘特图适合讨论"什么时候做、与哪些工作重叠"。如果两边各存一份日期,项目经理在图上改了计划,经办人仍可能在任务表里看到旧值。
当前接入选择从业务任务生成图表数据。getGanttData(projectId) 读取项目任务和依赖,返回 data 与 links;甘特图里的任务 ID 直接沿用业务任务 ID。后续更新因此能定位到同一条业务记录。
先把三种关系分开,图才不会越画越乱
一个常见误区,是用任务的上下排列表示先后顺序。实际上,"属于哪个阶段"和"依赖哪项任务"是两回事。
HR 调研与 OA 调研可以属于同一个父任务,同时进行;需求评审即使位于另一层,也可能需要等待需求文档完成。前者由 parent 表达,后者由 links 表达。里程碑则回答另一个问题:到某个节点,应该交付或确认什么。

数据关系示意:采用当前实现中的对象和字段,连线表示业务关联,不代表数据库外键约束。
后端转换的关键字段不多,但每一个都影响图的含义:
| 业务任务数据 | 甘特图字段 | 实际转换 |
|---|---|---|
id、taskName |
id、text |
保留业务身份和任务名称 |
parentId |
parent |
无父任务时用 0 |
taskType |
type |
1 对应阶段 project,3 对应 milestone,其余为 task |
| 计划开始、结束 | start_date、end_date |
格式化为 yyyy-MM-dd HH:mm:ss |
| 百分比进度 | progress |
业务值除以 100,例如 25 转成 0.25 |
这层转换还能隔离组件协议。任务表继续服务表单、统计和项目业务,甘特图需要什么形状的数据,由接口统一适配。将来调整展示组件,不必把整个任务模型改成另一套命名。
日期尤其值得单独检查。当前接口用两个计划时间的 Duration.between(...).toDays() 计算工期,最小返回 1。例如从 6 月 7 日零点到 6 月 13 日零点,按这段代码是 6 天,而不是把首尾日期都算进去的 7 天;不足一天的时间差也会受整天取值影响。
因此,不能把这里的"天数"直接解释为扣除周末、节假日后的工作日。若项目要求工作日排期,要进一步统一日历、日期边界和服务端算法。只在前端改一下时间刻度,业务工期口径不会跟着改变。
拖动一根时间条,真正要保存什么
组件接入可以分成加载、映射、绑定事件三步。ProjectGanttPanel 动态导入库与 CSS,配置日期格式、树形名称列和编辑开关,随后调用接口,使用 clearAll() 与 parse(data) 装载当前项目。
编辑能力取决于 readonly 和项目 ID。新增、双击编辑没有另做一套简化表单,而是打开现有的 Vben 任务表单,成功后重新加载甘特图。这使任务字段和校验可以沿用已有业务入口。
拖动则走更短的更新路径。下面是现有事件处理的关键片段:
typescript
gantt.attachEvent('onAfterTaskDrag', async (id: any) => {
const task = gantt.getTask(id);
try {
await updateGanttTask({
id: task.id,
start_date: gantt.templates.format_date(task.start_date),
end_date: gantt.templates.format_date(task.end_date),
duration: task.duration,
progress: task.progress,
});
emit('changed');
} catch {
message.error('更新失败');
}
});
这个时点很关键:DHTMLX 文档中的 onAfterTaskDrag 用于拖动结束后的处理,组件内存里的位置已经改变;业务保存由我们接着完成。用户看见条形移动,并不代表数据库已经写入。
请求到达 /project/gantt/task 后,后端把开始、结束字符串解析为计划时间,把进度乘回 100,更新任务,再调用里程碑同步服务。前端虽然传了 duration,当前更新方法并未把它单独写入任务;下次查询仍由起止时间计算工期。

保存链路示意:重新打开后的读取是核对持久化结果的一步,不是一次成功拖拽截图的替代品。
这里有个二开时应补齐的交互:现有拖动失败分支只有错误提示,没有恢复原位置。网络异常时,屏幕上的日期可能暂时与数据库不一致。可考虑保存拖动前的快照,失败后还原;如果结果不明,先重新读取服务端数据,再让用户继续编辑。它属于改进建议,当前代码不能据此宣传为"自动回滚已完善"。
依赖线要有业务 ID,才经得住刷新
拖出一条依赖线时,组件会先拥有一个临时 ID。后端保存的依赖记录却有自己的主键。如果不处理这两个身份,首次画线可能正常,随后删除或刷新就容易对不上。
现有实现把创建分为两段:保存 source、target、type、lag,然后用返回的主键替换临时 ID。
typescript
gantt.attachEvent('onAfterLinkAdd', async (id: any, link: any) => {
try {
const result = await createGanttLink({
source: link.source,
target: link.target,
type: String(link.type),
lag: link.lag || 0,
});
gantt.changeLinkId(id, result);
} catch {
gantt.deleteLink(id);
message.error('创建依赖失败');
}
});
后端从前置任务读取所属项目,将两端任务 ID、依赖类型和延迟值写入 ProjectTaskLinkDO。下一次查询按项目取回这些记录,重新转换成 links。删除依赖则把连线 ID 发回后端。
这里完成的是依赖关系的记录与展示 。代码里有 lag 字段,不代表调整前置任务后,后面所有任务都会自动重排;保存了关系,也不能代替循环依赖、跨项目连接或关键路径分析的验证。
例如"设计完成后才能开发",至少要分别处理两层需求:一层是把关系存下来,让计划可读;另一层是日期冲突时提醒、阻止还是自动调整。第二层需要明确的排程规则。做二开方案时,应把它单列出来估算,避免把"能连线"当成"已有完整调度引擎"。
一个里程碑,既在时间轴上,也在业务记录里
"需求评审"可以作为一个检查节点。甘特图需要知道它是 milestone 类型,业务管理还需要名称、计划日期、达成状态和实际日期。把这些内容全部塞进图形属性,会让其他页面难以使用。
当前服务通过 taskId 关联里程碑记录。任务更新以后,syncFromTask 判断是否为类型 3;是则创建或更新关联记录,否则解除原有关联。关键判断如下:
java
@Transactional(rollbackFor = Exception.class)
public void syncFromTask(ProjectTaskDO task) {
if (task == null || task.getId() == null) {
return;
}
if (!Objects.equals(task.getTaskType(), TASK_TYPE_MILESTONE)) {
unlinkFromTask(task.getId());
return;
}
upsertFromTask(task);
}
upsertFromTask 按任务 ID 查已有记录:没有就创建,并把里程碑 ID 回写任务;已有则更新名称、计划日期、排序等字段。计划日期优先取任务计划结束时间的日期部分,没有结束时间才退到开始时间。
由此可以推出一个具体的联调点:调整里程碑任务的结束时间后,关联记录的计划日期应相应变化。这里不能只对着图形的位置判断,要核对业务字段;尤其当前取的是结束日期,而非无条件取图上节点的起始日期。
计划移动和实际达成是两个动作。 同步已有记录时,没有因为拖动或进度变化就覆盖达成状态、实际日期;achieveMilestone 才负责设置达成状态和当天的实际日期。否则把验收节点往后挪一天,就可能误写成当天已经验收。
另一个容易忽略的区别是:单独建立的业务里程碑,不会仅因为存在于里程碑表,就自动出现在这份甘特图里。当前图表数据来自任务列表,必须结合对应任务与类型理解展示范围。
放进 Vue3 页签之后,还要处理"再打开一次"
同一套组件还会被不同项目使用。下面这份历史画面的项目编号是 PJ202607290001,计划从 8 月开始;前文的 PJ202606070002 则从 6 月开始。阶段名称相似,任务 ID、日期和数据范围仍属于各自项目。

另一项目的历史完整画面,用于说明项目上下文;不是前文项目改期后的结果,也不是多页签切换测试截图。
因此,组件复用时要传递明确的 projectId,切换后清理旧图表数据,再加载目标项目。当前实现监听项目 ID 与只读属性变化,读取对应数据并更新编辑开关。
独立演示页能显示,不代表放进项目台账后就结束了。甘特图位于页签中,组件挂载时容器可能隐藏;用户又会切换任务管理、甘特图或离开页面。这些时机都影响尺寸和事件。
现有组件会等待容器出现,用初始化代次标记放弃过期的初始化过程;切回激活页签时,先等待 DOM 更新,再执行 setSizes()、render() 并读取数据。卸载时解绑自定义事件、清空数据,避免旧回调继续承担当前页面的操作。初始化失败还提供表格降级和手动重试。
有一处值得直接提醒复制代码的开发者:名为 sharedGantt 的变量声明在 <script setup> 内。按 Vue 官方的执行语义,这里的代码会随组件实例创建而执行。变量名叫 shared,并不能证明它是跨组件的模块级单例。 当前组件也没有调用 destructor(),但不能据此推导所有版本、所有多页签场景都应该禁止销毁;实例归属与库版本要一起确认。
如果自己接入,可以在独立测试项目中放入"设计、开发、验收"三个任务,按下面的顺序检查。它们是联调步骤,不是本文已执行的结果:
- 移动普通任务后,核对更新请求的 ID 和日期,再刷新、打开任务表单对照。
- 新建依赖,核对临时 ID 已替换;刷新后再删除,确认删的是同一条服务端记录。
- 调整里程碑任务日期,对照关联计划日期;再检查达成状态没有被排期操作覆盖。
- 切换页签、关闭后重开,检查尺寸、项目数据和一次操作对应的请求次数。
- 模拟保存失败,观察界面是否与服务端一致;单独验证跨项目依赖和只读角色的服务端权限。
完成这些核对,项目经理讨论的时间条才有可追踪的业务依据:它对应哪项任务、改了什么日期、关联哪条依赖,以及这个节点是否真的达成。Vue3+Gantt 的接入价值,就体现在这些具体联系里。