SpringBoot+Vue3 项目管理系统设计:甘特图 + 任务看板 + 工时统计全流程拆解

🌐 演示地址http://ruoyioffice.com | 📦 源码1ruoyi-office-vben | 📦 源码2ruoyi-office | 📦 源码3ruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)

一个项目从「老板拍板要做」到「立项审批通过、排期、分工、记工时、复盘验收」,中间隔着十几张表和一条审批流。很多团队靠 Excel + 钉钉群硬撑:计划改三次就对不上、工时全凭记忆、进度永远「快好了」。RuoYi Office 在 yudao-module-project 模块里把这条链路做成了产品——立项申请走 Flowable 审批、通过后物化成项目台账、台账内嵌甘特图/看板/工时/里程碑/预算,共 18 张 project_*。本文基于真实源码,把这套设计从数据模型讲到前端实现。

project-management-architecture.png

▲ 项目管理全景:立项申请(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/terminatePOST /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 项目台账列表

台账列表是运营入口,展示所有审批通过的项目,带项目类型、状态、进度、项目经理、对方单位等关键信息:

project-ledger-list.png

▲ 项目台账列表:项目编号/名称/类型/优先级/状态/进度一览,操作列按状态提供详情、暂停、完成、终止、归档

3.1 项目台账详情:多 Tab 工作台

台账详情 ledger/detail/index.vue 是整个模块的核心工作台,按能力包动态渲染 Tab,每个 Tab 是一个独立子组件:

project-ledger-detail.png

▲ 项目台账详情页:顶部项目概览卡片,下方任务管理(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 普通表格展示任务,保证「再差也能看到数据」。

project-gantt-view.png

▲ 甘特图视图:左侧任务树(需求分析/系统设计/开发实现等阶段 + 子任务)+ 右侧时间条,支持拖拽改期、连接依赖线,进度可视化

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.vueProjectStatsService#getWorktimeStats 聚合,前端 ECharts 画工时分布

project-stats.png

▲ 项目统计看板:项目总数/进行中/已完成/逾期 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
  • 操作路径:项目管理 → 立项申请(提交审批)→ 项目台账 → 进入台账详情(任务/甘特/看板/工时)
  • 推荐体验流程
    1. 新建立项申请,填项目基本信息和预算,提交审批
    2. 用管理员审批通过,观察自动生成项目台账
    3. 进入台账详情,在「任务」Tab 建几个阶段和子任务
    4. 切到「甘特图」拖拽改期、连依赖线
    5. 切到「看板」拖拽卡片改状态
    6. 在「工时」填报并审核
    7. 到「统计」看 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_infoproject_ledger 可避免状态混乱;台账 id 沿用申请单 id,子表无需迁移。

不同类型的项目能显示不同功能吗?

能。通过 project_type_config(类型 + 能力包 + 自定义字段)和 project_module_config(能力包开关)配置,台账详情页按能力包动态显示 Tab,研发项目可开看板/Sprint,工程项目可开里程碑/验收/风险,无需改代码。


💡 想要体验 RuoYi Office 的强大功能?

🌐 在线演示http://ruoyioffice.com/web/(账号 admin / admin123)

📦 源码仓库GitCode | GitHub

💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」

如果觉得不错,请给个 Star 支持一下!


更多推荐