SpringBoot+Vue3 项目管理系统设计:甘特图 + 任务看板 + 工时统计全流程拆解
SpringBootVue3 项目管理系统设计甘特图 任务看板 工时统计全流程拆解演示地址http://ruoyioffice.com | 源码1ruoyi-office-vben | 源码2ruoyi-office | 源码3ruoyi-office | 微信17156169080备注「RuoYi Office」一个项目从「老板拍板要做」到「立项审批通过、排期、分工、记工时、复盘验收」中间隔着十几张表和一条审批流。很多团队靠 Excel 钉钉群硬撑计划改三次就对不上、工时全凭记忆、进度永远「快好了」。RuoYi Office 在yudao-module-project模块里把这条链路做成了产品——立项申请走 Flowable 审批、通过后物化成项目台账、台账内嵌甘特图/看板/工时/里程碑/预算共 18 张project_*表。本文基于真实源码把这套设计从数据模型讲到前端实现。▲ 项目管理全景立项申请(BPM)→项目台账→任务树/甘特/看板/工时/里程碑/统计能力包按项目类型动态开 TabHTML 源文件见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. 保存/更新立项申请草稿ProjectInfoDOinfosaveDraft(reqVO);// 2. 发起 BPM 流程流程 key project_create单据码 601StringprocessInstanceIdprocessInstanceApi.createProcessInstance(userId,newBpmProcessInstanceCreateReqDTO().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简化publicvoidupsertFromDraft(LongdraftProjectId){ProjectInfoDOinfoprojectInfoMapper.selectById(draftProjectId);ProjectLedgerDOledgerBeanUtils.toBean(info,ProjectLedgerDO.class);ledger.setId(info.getId());// ★ 台账 id 沿用申请单 idledger.setStatus(ProjectLedgerStatusEnum.RUNNING.getStatus());// 进行中projectLedgerMapper.insertOrUpdate(ledger);// 子表 projectId 无需迁移}1.2 状态流转立项申请与台账各有一套状态互不污染立项申请 project_info.status草稿 → 审批中 → 已通过/已拒绝 ↓ 通过物化 项目台账 project_ledger.status进行中(2) → 暂停(3) → 完成(4) ↘ 终止(5) → 归档(6)台账状态由ProjectLedgerController的专用接口驱动而非通用 updatePUT /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/ledgerid 沿用状态机清爽子表零迁移功能差异化能力包配置驱动 Tab一套代码适配多项目类型自定义字段customFieldsJSON 列不同类型扩展字段无需改表结构任务多视图统一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模块级单例跨页面复用同一个实例letsharedGantt:anynull;letsharedModule:anynull;letsharedEventIds:any[][];// 已挂载的自定义事件重建前逐个解绑asyncfunctionensureGanttInstance(){if(sharedGantt)returnsharedGantt;// 复用不重建sharedModuleawaitimport(dhtmlx-gantt);// 按需异步加载awaitimport(dhtmlx-gantt/codebase/dhtmlxgantt.css);constfactorysharedModule.gantt;sharedGanttfactory.getGanttInstance?factory.getGanttInstance():factory;returnsharedGantt;}onUnmounted((){detachSharedEvents(ganttInstance);// 只解绑事件ganttInstance?.clearAll?.();// 只清数据ganttInstancenull;// ★ 绝不调用 destructor()});拖拽改期、改进度、连依赖线都通过事件实时回写后端gantt.attachEvent(onAfterTaskDrag,async(id){consttaskgantt.getTask(id);awaitupdateGanttTask({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){constrealIdawaitcreateGanttLink({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复用同一份任务树数据前端展平后按状态分列。只展示「叶子任务」没有子任务的最末级任务避免父节点和子节点同时出现在看板上functionpickLeafTasks(tasks:ProjectTaskApi.Task[]){constflatflattenTaskTree(tasks);constparentIdsnewSetnumber();for(consttaskofflat){if(task.parentIdtask.parentId0)parentIds.add(task.parentId);}returnflat.filter((task)!parentIds.has(task.id!));// 只留叶子}拖拽卡片到新列即调用updateTaskStatus失败则回滚重载asyncfunctionhandleColumnChange(newStatus,evt){constaddedevt.added?.element;if(!added?.id)return;try{awaitupdateTaskStatus(added.id,newStatus);// 状态随列变化added.statusnewStatus;}catch{awaitloadTasks();// 失败回滚}}看板列定义就是任务状态字典待开始(0) / 进行中(1) / 已完成(2)。3.4 工时三段闭环工时是「填报 → 审核 → 统计」三段闭环填报worktime/fill/index.vue成员按项目/任务/日期记工时审核worktime/review/index.vue项目经理审核reviewWorktime改状态统计stats/index.vueProjectStatsService#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#getTaskTreepublicListProjectTaskDOgetTaskTree(LongprojectId){ListProjectTaskDOlistprojectTaskMapper.selectListByProjectId(projectId);// 前端 buildTaskTree 按 parentId 组装甘特/看板/树形共用这份扁平数据returnlist;}4.3 看板拖拽改状态// ProjectTaskServiceImpl#updateTaskStatuspublicvoidupdateTaskStatus(Longid,Integerstatus){ProjectTaskDOtaskvalidateTaskExists(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 定时任务扫描// ProjectMilestoneNotifyJobXxlJob(projectMilestoneNotifyJob)publicvoidexecute(){intdaysprojectConfigService.getMilestoneRemindDays();// 全局配置提前天数ListProjectMilestoneDOlistmilestoneService.getUpcomingMilestones(days);// 查 N 天内未达成for(ProjectMilestoneDOm:list){notifyApi.sendToProjectMembers(m.getProjectId(),里程碑「m.getName()」将于 m.getPlanDate() 到期);}}五、RuoYi Office 创新设计5.1 立项与台账分表 id 沿用相比「一张表用状态区分」分表让审批态与运营态彻底解耦子表又因 id 沿用零迁移——鱼和熊掌兼得。5.2 能力包动态 Tabproject_module_config让同一套代码适配研发/工程/外包等不同项目形态新增能力包是「加配置」而非「改框架」。5.3 甘特图单例复用用「永不 destructor」绕开 dhtmlx-gantt 的工厂状态污染是踩过坑才有的工程经验——附带表格降级兜底可用性拉满。5.4 任务一份数据多视图树形计划、甘特、看板、Sprint 全部基于同一份project_task杜绝多视图数据不一致。六、数据结构project_ledger台账主表节选字段类型说明idbigint主键沿用立项申请 idproject_codevarchar项目编号project_namevarchar项目名称category_idbigint项目分类manager_user_idbigint项目经理statustinyint2进行中/3暂停/4完成/5终止/6归档progressint总进度budget_amountdecimal预算金额custom_fieldsjson按项目类型扩展字段project_task任务表节选字段类型说明idbigint主键project_idbigint所属项目parent_idbigint父任务WBS 自关联task_typetinyint1阶段/2任务/3里程碑statustinyint0待开始/1进行中/2已完成assignee_user_idbigint负责人estimated_hours/actual_hoursdecimal预估/实际工时progressint任务进度注本系统当前未内置「燃尽图」实体进度/工时趋势通过project_stats系列接口 ECharts 呈现燃尽图为规划增强项。七、技术亮点总结设计要点实现方式价值双表分离project_infoproject_ledger审批/运营解耦id 沿用台账 id 申请单 id子表零迁移能力包project_module_config驱动 Tab一套代码多形态甘特图单例模块级 sharedGantt 永不 destructor路由重入不崩表格降级初始化失败兜底 Ant Table可用性保障任务多视图统一project_task树数据一致工时闭环填报/审核/统计三接口成本可核算自定义字段customFieldsJSON扩展不改表里程碑提醒XXL-Job 定时扫描关键节点不遗漏BPM 集成立项/变更/验收走 Flowable审批合规留痕八、快速体验在线演示http://ruoyioffice.com/web/账号admin/admin123操作路径项目管理 → 立项申请提交审批→ 项目台账 → 进入台账详情任务/甘特/看板/工时推荐体验流程新建立项申请填项目基本信息和预算提交审批用管理员审批通过观察自动生成项目台账进入台账详情在「任务」Tab 建几个阶段和子任务切到「甘特图」拖拽改期、连依赖线切到「看板」拖拽卡片改状态在「工时」填报并审核到「统计」看 ECharts 工时/进度图表仓库地址后端GitCode · GitHub前端GitCode常见问题FAQRuoYi 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源码仓库GitCode | GitHub技术咨询添加微信17156169080备注「RuoYi Office」⭐如果觉得不错请给个 Star 支持一下