SpringBoot+Vue3 项目管理系统设计:甘特图 + 任务看板 + 工时统计全流程拆解
SpringBoot+Vue3 项目管理系统设计:甘特图 + 任务看板 + 工时统计全流程拆解
🌐 演示地址:http://ruoyioffice.com | 📦 源码1:ruoyi-office-vben | 📦 源码2:ruoyi-office | 📦 源码3:ruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)
一个项目从「老板拍板要做」到「立项审批通过、排期、分工、记工时、复盘验收」,中间隔着十几张表和一条审批流。很多团队靠 Excel + 钉钉群硬撑:计划改三次就对不上、工时全凭记忆、进度永远「快好了」。RuoYi Office 在
yudao-module-project模块里把这条链路做成了产品——立项申请走 Flowable 审批、通过后物化成项目台账、台账内嵌甘特图/看板/工时/里程碑/预算,共 18 张project_*表。本文基于真实源码,把这套设计从数据模型讲到前端实现。

▲ 项目管理全景:立项申请(BPM)→项目台账→任务树/甘特/看板/工时/里程碑/统计,能力包按项目类型动态开 Tab;HTML 源文件见 images/project-management-architecture.html
引言:项目管理系统到底难在哪?
做过项目管理工具的都知道,难的不是建几张表,而是业务形态太多变:
痛点一:立项要审批,运营要灵活。立项阶段是一张「申请单」,要走预算/资源审批,草稿可反复改;审批通过后却要变成一个长期运营的「台账」,挂着几十个任务、成员、工时——两种形态硬塞进一张表,状态字段会乱成一锅粥。
痛点二:不同项目类型,需要的功能完全不同。研发项目要敏捷看板和 Sprint,工程项目要里程碑和验收,外包项目要工时和盈利分析。如果所有项目都显示全部 Tab,界面臃肿;写死又无法扩展。
痛点三:甘特图是「重组件」。dhtmlx-gantt 这类库带自己的工厂和全局状态,在 Vue SPA 路由反复进出时极易把内部实例搞坏,报 tasksStore undefined。
痛点四:任务既要树形(WBS),又要看板(拖拽流转)。同一份任务数据,甘特图要父子层级 + 依赖关系,看板要按状态分列拖拽——两种视图不能各存一份。
痛点五:工时要闭环。填报、审核、统计三步缺一不可,且要能按项目/成员/日期多维汇总。
| 现状 | 后果 |
|---|---|
| 立项与运营共用一张表 | 状态机混乱,草稿态污染台账数据 |
| 功能 Tab 写死 | 研发/工程/外包项目体验都不对 |
| 甘特图随路由销毁重建 | 二次进入白屏、控制台报错 |
| 任务树与看板各存一份 | 数据不一致,改一处漏一处 |
| 工时只填不审不统计 | 成本核算全靠手工 Excel |
本文的答案是:双表分离 + id 沿用、能力包驱动 Tab、甘特图单例复用、任务统一数据多视图渲染、工时三段闭环。
一、业务设计:立项申请 → 项目台账双表模型
1.1 为什么要拆成两张表
RuoYi Office 把「项目」拆成了 project_info(立项申请单)和 project_ledger(项目台账)两张主表:
project_info:立项阶段的申请单,带processInstanceId/processStatus,草稿可编辑、可撤回、可重新提交,走 Flowable 审批。project_ledger:审批通过后才生成的运营主数据,是任务、成员、工时、里程碑等所有子表挂靠的主体。
关键设计:台账 id 直接沿用申请单 id。这样审批通过物化台账时,子表的 projectId 无需迁移,立项阶段填的预算、计划、成员草稿可以平滑过渡到运营态。
// ProjectInfoServiceImpl#submitProjectInfo(简化)
// 1. 保存/更新立项申请草稿
ProjectInfoDO info = saveDraft(reqVO);
// 2. 发起 BPM 流程(流程 key = project_create,单据码 601)
String processInstanceId = processInstanceApi.createProcessInstance(userId,
new BpmProcessInstanceCreateReqDTO()
.setProcessDefinitionKey(ProjectBillTypeEnum.PROJECT_CREATE.getProcessKey())
.setBusinessKey(String.valueOf(info.getId())));
// 3. 回写流程实例 id + 状态=审批中
info.setProcessInstanceId(processInstanceId)
.setStatus(ProjectStatusEnum.APPROVING.getStatus());
projectInfoMapper.updateById(info);
审批通过后,BPM 回调 ProjectFeignNotificationController#bpm-event,触发台账物化:
// ProjectLedgerServiceImpl#upsertFromDraft(简化)
public void upsertFromDraft(Long draftProjectId) {
ProjectInfoDO info = projectInfoMapper.selectById(draftProjectId);
ProjectLedgerDO ledger = BeanUtils.toBean(info, ProjectLedgerDO.class);
ledger.setId(info.getId()); // ★ 台账 id 沿用申请单 id
ledger.setStatus(ProjectLedgerStatusEnum.RUNNING.getStatus()); // 进行中
projectLedgerMapper.insertOrUpdate(ledger); // 子表 projectId 无需迁移
}
1.2 状态流转
立项申请与台账各有一套状态,互不污染:
立项申请 project_info.status:草稿 → 审批中 → 已通过/已拒绝
↓ 通过物化
项目台账 project_ledger.status:进行中(2) → 暂停(3) → 完成(4)
↘ 终止(5) → 归档(6)
台账状态由 ProjectLedgerController 的专用接口驱动,而非通用 update:PUT /pause、/resume、/terminate,POST /complete、/archive,每个动作都有独立的业务校验。
二、系统设计:能力包驱动的模块化台账
2.1 项目类型 + 能力包
不同项目类型需要的功能不同,RuoYi Office 用两张配置表解决:
| 配置表 | 作用 |
|---|---|
project_type_config |
定义项目类型(研发/工程/外包…)及其启用的能力包、自定义字段 |
project_module_config |
能力包总开关,控制台账详情页显示哪些 Tab |
前端把 Tab 分成「核心 Tab」(始终显示)和「能力包 Tab」(按配置动态开关),定义在 module-meta.ts:
| 类别 | Tab | 说明 |
|---|---|---|
| 核心 | 任务 / 甘特图 / 成员 / 里程碑 / 变更 / 验收 / 复盘 | 所有项目都有 |
能力包 agile |
看板 / Sprint | 敏捷研发项目 |
能力包 cost |
工时 / 预算 / 盈利分析 | 需要成本核算 |
能力包 resource |
资源负载 | 多项目资源协调 |
能力包 qhse |
风险 | 工程/合规项目 |
这样一个研发项目能看到「看板+Sprint+工时」,一个工程项目能看到「里程碑+验收+风险」,互不打扰,且新增能力包只需加配置不改主框架。
2.2 核心设计决策
| 决策点 | 方案 | 理由 |
|---|---|---|
| 立项与运营 | 双表 info / ledger,id 沿用 |
状态机清爽,子表零迁移 |
| 功能差异化 | 能力包配置驱动 Tab | 一套代码适配多项目类型 |
| 自定义字段 | customFields JSON 列 |
不同类型扩展字段无需改表结构 |
| 任务多视图 | 统一 project_task 树 + 前端多渲染 |
树形/甘特/看板共用一份数据 |
| 单据编号 | generateProjectCode 规则生成 |
编号可读、可检索 |
三、PC 端功能实现
3.0 项目台账列表
台账列表是运营入口,展示所有审批通过的项目,带项目类型、状态、进度、项目经理、对方单位等关键信息:

▲ 项目台账列表:项目编号/名称/类型/优先级/状态/进度一览,操作列按状态提供详情、暂停、完成、终止、归档
3.1 项目台账详情:多 Tab 工作台
台账详情 ledger/detail/index.vue 是整个模块的核心工作台,按能力包动态渲染 Tab,每个 Tab 是一个独立子组件:

▲ 项目台账详情页:顶部项目概览卡片,下方任务管理(WBS 任务计划树)+ 甘特图/成员/里程碑/工时/资源/预算/盈利/变更/验收等 Tab 动态显示
3.2 甘特图:dhtmlx-gantt 单例复用
甘特图是技术上最棘手的一块。dhtmlx-gantt 的实例一旦被 destructor() 销毁,其工厂的静态内部状态会被破坏,导致 SPA 路由再次进入时 init() 读取 $data.tasksStore 抛异常。RuoYi Office 用模块级单例 + 永不 destructor策略解决:
// project-gantt-panel.vue:模块级单例,跨页面复用同一个实例
let sharedGantt: any = null;
let sharedModule: any = null;
let sharedEventIds: any[] = []; // 已挂载的自定义事件,重建前逐个解绑
async function ensureGanttInstance() {
if (sharedGantt) return sharedGantt; // 复用,不重建
sharedModule = await import('dhtmlx-gantt'); // 按需异步加载
await import('dhtmlx-gantt/codebase/dhtmlxgantt.css');
const factory = sharedModule.gantt;
sharedGantt = factory.getGanttInstance
? factory.getGanttInstance()
: factory;
return sharedGantt;
}
onUnmounted(() => {
detachSharedEvents(ganttInstance); // 只解绑事件
ganttInstance?.clearAll?.(); // 只清数据
ganttInstance = null; // ★ 绝不调用 destructor()
});
拖拽改期、改进度、连依赖线都通过事件实时回写后端:
gantt.attachEvent('onAfterTaskDrag', async (id) => {
const task = gantt.getTask(id);
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,
});
});
gantt.attachEvent('onAfterLinkAdd', async (id, link) => {
const realId = await createGanttLink({
source: link.source, target: link.target,
type: String(link.type), lag: link.lag || 0,
});
gantt.changeLinkId(id, realId); // 用后端返回的真实 id 替换临时 id
});
还有一层兜底:初始化重试 3 次仍失败时,自动降级为 Ant Design 普通表格展示任务,保证「再差也能看到数据」。

▲ 甘特图视图:左侧任务树(需求分析/系统设计/开发实现等阶段 + 子任务)+ 右侧时间条,支持拖拽改期、连接依赖线,进度可视化
3.3 任务看板:vuedraggable 拖拽流转
看板 task-kanban.vue 复用同一份任务树数据,前端展平后按状态分列。只展示「叶子任务」(没有子任务的最末级任务),避免父节点和子节点同时出现在看板上:
function pickLeafTasks(tasks: ProjectTaskApi.Task[]) {
const flat = flattenTaskTree(tasks);
const parentIds = new Set<number>();
for (const task of flat) {
if (task.parentId && task.parentId > 0) parentIds.add(task.parentId);
}
return flat.filter((task) => !parentIds.has(task.id!)); // 只留叶子
}
拖拽卡片到新列即调用 updateTaskStatus,失败则回滚重载:
async function handleColumnChange(newStatus, evt) {
const added = evt.added?.element;
if (!added?.id) return;
try {
await updateTaskStatus(added.id, newStatus); // 状态随列变化
added.status = newStatus;
} catch {
await loadTasks(); // 失败回滚
}
}
看板列定义就是任务状态字典:待开始(0) / 进行中(1) / 已完成(2)。
3.4 工时三段闭环
工时是「填报 → 审核 → 统计」三段闭环:
- 填报
worktime/fill/index.vue:成员按项目/任务/日期记工时 - 审核
worktime/review/index.vue:项目经理审核,reviewWorktime改状态 - 统计
stats/index.vue:ProjectStatsService#getWorktimeStats聚合,前端 ECharts 画工时分布

▲ 项目统计看板:项目总数/进行中/已完成/逾期 KPI 卡片 + 任务完成率、预算使用率环形图 + 项目进度对比(ECharts)
四、后端核心实现
4.1 数据模型关系
18 张 project_* 表围绕台账组织,核心关系如下:
project_info (立项申请, +BPM) project_category (分类树)
│ 审批通过 id 沿用 project_group (项目集)
▼
project_ledger (台账主体)
├── project_task (任务树, parentId 自关联, taskType 阶段/任务/里程碑)
│ ├── project_task_link (甘特依赖: source/target/type/lag)
│ └── project_task_comment (任务评论)
├── project_milestone (里程碑) ├── project_worktime (工时)
├── project_member (成员) ├── project_budget (预算)
├── project_risk (风险) ├── project_change (变更, +BPM)
└── project_acceptance (验收, +BPM)
4.2 任务树构建
任务用 parentId 自关联实现 WBS 分解,taskType 区分阶段(1)/任务(2)/里程碑(3)。getTaskTree 一次查出扁平列表,前后端都能按需组装成树:
// ProjectTaskServiceImpl#getTaskTree
public List<ProjectTaskDO> getTaskTree(Long projectId) {
List<ProjectTaskDO> list = projectTaskMapper.selectListByProjectId(projectId);
// 前端 buildTaskTree 按 parentId 组装;甘特/看板/树形共用这份扁平数据
return list;
}
4.3 看板拖拽改状态
// ProjectTaskServiceImpl#updateTaskStatus
public void updateTaskStatus(Long id, Integer status) {
ProjectTaskDO task = validateTaskExists(id);
task.setStatus(status);
// 完成时自动写实际完成时间,进度补 100%
if (Objects.equals(status, ProjectTaskStatusEnum.DONE.getStatus())) {
task.setActualEndTime(LocalDateTime.now());
task.setProgress(100);
}
projectTaskMapper.updateById(task);
}
4.4 里程碑到期提醒(XXL-Job)
里程碑临近时主动提醒,用 XXL-Job 定时任务扫描:
// ProjectMilestoneNotifyJob
@XxlJob("projectMilestoneNotifyJob")
public void execute() {
int days = projectConfigService.getMilestoneRemindDays(); // 全局配置提前天数
List<ProjectMilestoneDO> list =
milestoneService.getUpcomingMilestones(days); // 查 N 天内未达成
for (ProjectMilestoneDO m : list) {
notifyApi.sendToProjectMembers(m.getProjectId(),
"里程碑「" + m.getName() + "」将于 " + m.getPlanDate() + " 到期");
}
}
五、RuoYi Office 创新设计
5.1 立项与台账分表 + id 沿用
相比「一张表用状态区分」,分表让审批态与运营态彻底解耦,子表又因 id 沿用零迁移——鱼和熊掌兼得。
5.2 能力包动态 Tab
project_module_config 让同一套代码适配研发/工程/外包等不同项目形态,新增能力包是「加配置」而非「改框架」。
5.3 甘特图单例复用
用「永不 destructor」绕开 dhtmlx-gantt 的工厂状态污染,是踩过坑才有的工程经验——附带表格降级兜底,可用性拉满。
5.4 任务一份数据多视图
树形计划、甘特、看板、Sprint 全部基于同一份 project_task,杜绝多视图数据不一致。
六、数据结构
project_ledger(台账主表,节选)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint | 主键(沿用立项申请 id) |
project_code |
varchar | 项目编号 |
project_name |
varchar | 项目名称 |
category_id |
bigint | 项目分类 |
manager_user_id |
bigint | 项目经理 |
status |
tinyint | 2进行中/3暂停/4完成/5终止/6归档 |
progress |
int | 总进度 |
budget_amount |
decimal | 预算金额 |
custom_fields |
json | 按项目类型扩展字段 |
project_task(任务表,节选)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint | 主键 |
project_id |
bigint | 所属项目 |
parent_id |
bigint | 父任务(WBS 自关联) |
task_type |
tinyint | 1阶段/2任务/3里程碑 |
status |
tinyint | 0待开始/1进行中/2已完成 |
assignee_user_id |
bigint | 负责人 |
estimated_hours / actual_hours |
decimal | 预估/实际工时 |
progress |
int | 任务进度 |
注:本系统当前未内置「燃尽图」实体,进度/工时趋势通过
project_stats系列接口 + ECharts 呈现;燃尽图为规划增强项。
七、技术亮点总结
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| 双表分离 | project_info + project_ledger |
审批/运营解耦 |
| id 沿用 | 台账 id = 申请单 id | 子表零迁移 |
| 能力包 | project_module_config 驱动 Tab |
一套代码多形态 |
| 甘特图单例 | 模块级 sharedGantt 永不 destructor | 路由重入不崩 |
| 表格降级 | 初始化失败兜底 Ant Table | 可用性保障 |
| 任务多视图 | 统一 project_task 树 |
数据一致 |
| 工时闭环 | 填报/审核/统计三接口 | 成本可核算 |
| 自定义字段 | customFields JSON |
扩展不改表 |
| 里程碑提醒 | XXL-Job 定时扫描 | 关键节点不遗漏 |
| BPM 集成 | 立项/变更/验收走 Flowable | 审批合规留痕 |
八、快速体验
- 在线演示:http://ruoyioffice.com/web/(账号
admin/admin123) - 操作路径:项目管理 → 立项申请(提交审批)→ 项目台账 → 进入台账详情(任务/甘特/看板/工时)
- 推荐体验流程:
- 新建立项申请,填项目基本信息和预算,提交审批
- 用管理员审批通过,观察自动生成项目台账
- 进入台账详情,在「任务」Tab 建几个阶段和子任务
- 切到「甘特图」拖拽改期、连依赖线
- 切到「看板」拖拽卡片改状态
- 在「工时」填报并审核
- 到「统计」看 ECharts 工时/进度图表
| 仓库 | 地址 |
|---|---|
| 后端 | GitCode · GitHub |
| 前端 | GitCode |
常见问题(FAQ)
RuoYi Office 的项目管理模块是开源的吗?
是。yudao-module-project 后端基于 Spring Boot 3.5 + MyBatis-Plus + Flowable,前端 Vue3 + Vben Admin,开源可商用,本地约 10 分钟启动。
甘特图用的什么库?为什么不会随路由切换崩溃?
用的是 dhtmlx-gantt。关键在于采用「模块级单例 + 永不 destructor」策略:跨页面复用同一个实例,卸载时只 clearAll() 并解绑事件,绝不调用 destructor(),从而避开其工厂静态状态被破坏导致的 tasksStore undefined 问题;初始化多次失败还会自动降级为表格。
任务看板和甘特图的数据是分开的吗?
不是。看板、甘特图、树形计划、Sprint 视图全部基于同一份 project_task 数据(getTaskTree 一次查询),前端按需渲染成不同视图,从根本上保证多视图数据一致。
立项审批和项目运营为什么要拆成两张表?
立项是「申请单」形态(草稿可改、走 Flowable 审批),运营是「台账」形态(长期挂载任务/工时/成员)。两者状态机和生命周期完全不同,拆成 project_info 与 project_ledger 可避免状态混乱;台账 id 沿用申请单 id,子表无需迁移。
不同类型的项目能显示不同功能吗?
能。通过 project_type_config(类型 + 能力包 + 自定义字段)和 project_module_config(能力包开关)配置,台账详情页按能力包动态显示 Tab,研发项目可开看板/Sprint,工程项目可开里程碑/验收/风险,无需改代码。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:http://ruoyioffice.com/web/(账号 admin / admin123)
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!
更多推荐


所有评论(0)