DevOps平台 — 第十三篇:规划的设计与实现

知道「有哪些工作项」和知道「这些工作项什么时候做」是两件事。工作项列表回答前者------谁负责、什么状态;规划回答后者------什么时候开始、什么时候结束、哪些工作项在并行、哪些已经逾期。规划模块以甘特图和日历两种视图呈现工作项的计划周期,支持拖拽排期、视图内新建、父子折叠,同时兼容企业域和项目域两种范围。本篇记录规划的双域数据流、甘特图与日历视图的实现、拖拽改期的乐观更新机制、以及与工作项模块的复用关系。

一、规划要解决什么问题

1.1 列表 vs 规划

工作项列表(第十一篇)以表格/看板呈现工作项,关注的是「状态」和「负责人」。但项目经理还需要回答:

  • 这个迭代周期内,哪些工作项在并行?
  • 哪些工作项快到期了?哪些已经逾期?
  • 父工作项和子工作项的时间排期是否合理?
  • 团队成员的工作负载分布如何?

这些问题用列表无法回答------需要时间轴视图。

1.2 两种视图

规划模块提供两种时间轴视图:

视图 说明 交互能力
甘特图 左侧工作项列表 + 右侧时间轴条,按天排列 拖拽移动/缩边改期、父子折叠、展开/收起列表
日历 月/周视图,工作项以横条卡片排列在日期格中 拖拽移动/缩边改期、空白格选段新建

两种视图共享同一份数据源和同一套拖拽改期逻辑,只是渲染方式不同。

二、双域设计

2.1 企业规划 vs 项目规划

规划页面通过路由参数区分两个域:

路由 域 数据源
/plan 企业域 企业下全部工作项
/project/:projectId/plan 项目域 当前项目下工作项

企业域规划展示当前企业所有项目的工作项,适合跨项目排期概览;项目域规划只展示当前项目的工作项,适合项目内精细排期。

2.2 数据流切换

两个域共享同一个 PlanPage.vue 组件和同一个 usePlanList 组合式函数,通过路由参数自动切换数据源:

scss 复制代码
双域数据流

  ┌──────────────────────────────────────────────┐
  │ PlanPage.vue                                 │
  │                                              │
  │  projectId = route.params.projectId || ''    │
  │                                              │
  │  usePlanList({ projectId })                  │
  └──────────────────┬───────────────────────────┘
                     │
                     ▼
  ┌──────────────────────────────────────────────┐
  │ usePlanList                                  │
  │                                              │
  │  isProjectScope = !!projectId               │
  │                                              │
  │  isProjectScope?                             │
  │  ├─ 是 → listProjectWorkItems(projectId)    │
  │  │      updateProjectWorkItem(projectId,..) │
  │  │      loadProjectMembers(projectId)       │
  │  │                                           │
  │  └─ 否 → listEnterpriseWorkItems()          │
  │         updateCompanyWorkItem(..)            │
  │         ensureWorkItemBaseData()             │
  └──────────────────────────────────────────────┘

切换域时,数据源接口、更新接口、成员列表全部自动切换,无需手动判断。

2.3 大数据量策略

规划页面不分页------一次加载全部工作项(pageSize: 5000)。这是因为甘特图和日历需要在时间轴上展示所有工作项的相对位置,分页会导致时间轴断裂。

如果工作项数量超过 5000,后端会截断。在实际使用中,单个企业或项目的工作项数量通常在数百级别,这个策略够用。

三、甘特图实现

3.1 布局结构

甘特图由三部分组成:

css 复制代码
甘特图布局

  ┌──────────────────────────────────────────────────────────┐
  │ 工具栏:收起/展开子项 | 显示/隐藏列表 | 显示/隐藏时间条     │
  ├────────────┬─────────────────────────────────────────────┤
  │ 左侧列表    │ 右侧时间轴                                    │
  │ (372px)    │                                              │
  │            │  ┌─ 月头 ─────────────────────────────────┐    │
  │ ┌─ 标题列  │  │  9月          10月                     │    │
  │ │  状态    │  ├─ 日头 ─────────────────────────────────┤    │
  │ │  负责人  │  │  1  2  3  4  5  6  7  8  9 ...        │    │
  │ │  计划时间│  ├────────────────────────────────────────┤    │
  │ │          │  │                                        │    │
  │ │ ▸ 需求A  │  │     ████████░░░░░░░░░░░░░░             │    │
  │ │   ├ 任务1│  │     ████░░░░░░                        │    │
  │ │   ├ 任务2│  │          ████████                     │    │
  │ │ ▸ 需求B  │  │               ████████████████        │    │
  │ │          │  │                                        │    │
  │ │          │  │  ↑ 今天线 ──────────────↑              │    │
  └────────────┴─────────────────────────────────────────────┘
  • 左侧列表:展示工作项标题、编号、类型标签、状态、负责人、计划时间,支持父子折叠
  • 右侧时间轴:按天排列,自动计算起止范围(前后各延伸 7 天),月头/日头双层表头
  • 今天线:高亮当前日期所在的列

3.2 父子树构建

甘特图左侧列表支持树状展示------父工作项可折叠/展开子工作项。树构建逻辑:

ini 复制代码
父子树构建

  ┌────────────────────────────────┐
  │ 输入:平铺 list (WorkItemVO[])  │
  └──────────────┬─────────────────┘
                 │
                 ▼
  ┌────────────────────────────────┐
  │ 1. childrenMap: parentId → []  │
  │    遍历 list,按 parentId 分组   │
  │                                │
  │ 2. roots = list.filter(        │
  │      r => isRootWorkItem(      │
  │        r.parentId))            │
  │                                │
  │ 3. rows = roots.map(p => {     │
  │      kids = childrenMap[p.id]  │
  │      展平行:                   │
  │        { row: p, depth: 0,     │
  │          hasKids: kids.length  │
  │        }                        │
  │      if !collapsed[p.id]:      │
  │        kids.map(c => {          │
  │          { row: c, depth: 1,   │
  │            hasKids: false }     │
  │        })                       │
  │    })                           │
  │                                │
  │ 4. 补漏:未在树中的孤立项       │
  │    追加到末尾 (depth: 0)        │
  └────────────────────────────────┘

折叠/展开通过 collapsed Set 管理------点击折叠按钮将父项 ID 加入 Set,展开时移除。expandAll / collapseAll 方法清空或填充全部根项 ID。

3.3 时间轴计算

时间轴范围根据工作项的计划时间自动计算:

ini 复制代码
时间轴范围计算

  min = min(所有工作项 planStartDate) - 7 天
  max = max(所有工作项 planEndDate) + 7 天

  如果没有任何计划时间:
    min = 今天 - 14 天
    max = 今天 + 60 天

  days = [min, min+1, ..., max]  // 每天一个刻度
  totalWidth = days.length × PX_PER_DAY  // 每天 30px

月头按 yyyy-MM 分组,日头取日期的 dd 部分。今天线通过计算今天在 days 数组中的索引位置得到像素偏移。

四、拖拽改期

4.1 三种拖拽模式

甘特图和日历都支持三种拖拽模式:

模式 触发方式 效果
move 拖拽条中间 整体平移,起止同时移动
edge-l 拖拽条左边缘 只改起始日期
edge-r 拖拽条右边缘 只改结束日期
ini 复制代码
拖拽改期流程

  ┌──────────────┐
  │ pointerdown   │
  │ on bar        │
  └──────┬───────┘
         │
         ▼
  ┌──────────────────────────────┐
  │ 判断拖拽模式:                 │
  │  data-handle="l" → edge-l    │
  │  data-handle="r" → edge-r    │
  │  其他 → move                 │
  │                              │
  │ 记录初始状态:                 │
  │  drag = { mode, id,          │
  │    x0: e.clientX,            │
  │    oStart: 原始planStart,     │
  │    oEnd: 原始planEnd,         │
  │    moved: false }            │
  └──────────────┬───────────────┘
                 │
                 ▼
  ┌──────────────────────────────┐
  │ pointermove (全局监听)        │
  │                              │
  │ dx = e.clientX - drag.x0     │
  │ d = Math.round(dx / PX_PER_DAY)│
  │                              │
  │ if d != 0: moved = true     │
  │                              │
  │ mode == "move":              │
  │   row.planStart = oStart + d │
  │   row.planEnd = oEnd + d     │
  │                              │
  │ mode == "edge-l":            │
  │   ns = oStart + d            │
  │   if ns > oEnd: ns = oEnd   │  ← 不允许左边缘超过右边缘
  │   row.planStart = ns         │
  │                              │
  │ mode == "edge-r":            │
  │   ne = oEnd + d              │
  │   if ne < oStart: ne = oStart│ ← 不允许右边缘超过左边缘
  │   row.planEnd = ne           │
  └──────────────┬───────────────┘
                 │
                 ▼
  ┌──────────────────────────────┐
  │ pointerup (全局监听)         │
  │                              │
  │ if !moved: return           │ ← 未移动,当作点击
  │ suppressClick = true         │ ← 阻止后续 click 打开详情
  │                              │
  │ if start == oStart &&         │
  │    end == oEnd: return       │ ← 移动了但结果没变
  │                              │
  │ emit('schedule-change', {     │
  │   id, planStartDate: start,  │
  │   planEndDate: end })        │
  └──────────────────────────────┘

4.2 乐观更新与回滚

拖拽结束时,PlanPage 收到 schedule-change 事件,调用 savePlanRange 写回后端。写回采用乐观更新策略------先改本地数据,再发请求,失败则回滚:

sql 复制代码
savePlanRange 乐观更新流程

  ┌────────────────────────────────────────┐
  │ 1. normalizePlanRange(start, end)      │
  │    → 保证 start ≤ end                  │
  │                                        │
  │ 2. 拍快照: oldStart, oldEnd            │
  │                                        │
  │ 3. patchPlanLocal(id, start, end)      │
  │    → 立即写回列表行数据(UI 即时更新)   │
  │                                        │
  │ 4. 发请求:                              │
  │    isProjectScope?                      │
  │    ├─ updateProjectWorkItem(pid, id, {  │
  │    │    planStartDate, planEndDate })  │
  │    └─ updateCompanyWorkItem(id, {      │
  │         planStartDate, planEndDate })  │
  │                                        │
  │ 5. 成功 → return true                  │
  │    失败 → 回滚 oldStart/oldEnd         │
  │           → return false               │
  └────────────────────────────────────────┘

4.3 点击与拖拽的冲突处理

拖拽结束后立即触发 click 事件是浏览器默认行为。为了区分「拖拽」和「点击」,使用 suppressClick 标志位:

  • 拖拽结束时设 suppressClick = true
  • 下一次 click 事件检查到 suppressClick,消费标志位并 return(不打开详情)
  • 如果未拖拽(moved = false),不设标志位,click 正常打开详情

五、日历视图

5.1 月视图与周视图

日历支持两种粒度:

视图 说明 每格数量
月视图 按月展示,每格一天 最多 5 条工作项
周视图 按周展示,每格一天 最多 8 条工作项

工作项在日历格中以横条卡片形式排列,通过泳道(lane)分配避免重叠。每个工作项占据从 planStartDate 到 planEndDate 的横向跨度。

5.2 空白格选段新建

日历支持在空白格子上按住拖拽选段------选起止日期后,弹出新建工作项抽屉,预填计划时间。这个交互与甘特图点击空轨补默认 7 天的交互互补:

交互 触发 效果
点击空轨 甘特图无计划行点击 补默认 7 天计划(今天 ~ 今天+6)
选段新建 日历空白格拖拽 按选段预填计划时间,打开新建

两种交互都通过 emit('create', { planStartDate, planEndDate }) 上抛到 PlanPage,统一调用 openCreate 打开新建抽屉。

六、状态色与进度

6.1 时间条着色

甘特图和日历的时间条根据工作项状态着色,着色逻辑优先使用状态机元数据(statusKind / statusFinalType),回退到状态码:

ini 复制代码
时间条着色逻辑

  ┌──────────────────────────────────────────────┐
  │ planBarColor(row)                            │
  │                                              │
  │ kind == "final" && finalType == "success"?   │
  │  → #00B42A (绿色)                           │
  │                                              │
  │ kind == "final"?                             │
  │  → #86909C (灰色)                           │
  │                                              │
  │ isDisplaySettledHint(status)?               │
  │  → #00B42A (绿色)                           │
  │                                              │
  │ status includes "progress" / "doing"?       │
  │  → #1677FF (蓝色)                           │
  │                                              │
  │ status includes "review" / "confirmed"?    │
  │  → #FF7D00 (橙色)                           │
  │                                              │
  │ status == "todo" / "draft" / "pending"?     │
  │  → #86909C (灰色)                           │
  │                                              │
  │ 默认 → #1677FF (蓝色)                        │
  └──────────────────────────────────────────────┘

6.2 完成态标识

完成态(终态成功或已验收状态)的时间条使用斜纹半透明样式(planBarDone),与进行中状态的实色条区分。

七、与工作项模块的复用

7.1 组件复用

规划页面大量复用工作项模块的组件和逻辑:

复用项 来源 用途
WorkItemFormDrawer 工作项模块 新建/详情/编辑抽屉
useWorkItemMembers 工作项模块 企业成员列表
useWorkItemProjects 工作项模块 项目列表
ensureWorkItemBaseData 工作项模块 基础数据缓存
isRootWorkItem 工作项 helpers 判断根工作项
categoryLabel / statusLabel 工作项 helpers 分类/状态文案
WI_DISPLAY_MEMBER_NAME 工作项模块 成员展示名注入

规划页面通过 provide(WI_DISPLAY_MEMBER_NAME, displayMemberName) 注入展示名函数,让复用的 WorkItemFormDrawer 内部组件能正确显示成员名称。

7.2 API 复用

规划页面调用的接口与工作项列表完全相同:

操作 企业域 项目域
列表 listEnterpriseWorkItems listProjectWorkItems
改期 updateCompanyWorkItem updateProjectWorkItem
详情 getCompanyWorkItem getProjectWorkItem

规划页面不需要后端提供专门的规划接口------它只是一个不同的视图层,数据源和工作项列表完全一致。

八、设计 Token 体系

规划页面有独立的设计 Token,通过 planTokens.css 定义,不修改全局主题:

css 复制代码
.plan-tokens {
  --plan-brand: #1677ff;
  --plan-brand-hover: #4096ff;
  --plan-bg: #f0f2f5;
  --plan-card: #ffffff;
  --plan-border: #e5e6eb;
  --plan-ink: #1d2129;
  --plan-ink-2: #4e5969;
  --plan-ink-3: #86909c;
  --plan-success: #00b42a;
  --plan-warning: #ff7d00;
  --plan-error: #f53f3f;
  --plan-radius-card: 13px;
  --plan-radius-md: 8px;
  --plan-radius-sm: 5px;
  --plan-row-h: 34px;
  /* ... */
}

这套 Token 确保规划页面的视觉风格独立可控,不影响也不被影响其他模块的样式。

九、日期工具

规划页面的日期处理使用独立的 planDate.ts 工具模块,全部基于 yyyy-MM-dd 字符串操作,不涉及时间部分:

函数 说明
parsePlanDay 解析 yyyy-MM-dd 为 Date
formatPlanDay Date → yyyy-MM-dd
addPlanDays 日期加减天数
planDayDiff 两日期间隔天数
formatPlanMd MM-DD 展示格式
normalizePlanRange 保证 start ≤ end

独立日期工具避免引入 moment/dayjs 等日期库的依赖,保持轻量。

十、路由与导航

10.1 路由注册

规划页面注册了两条路由:

路由 名称 组件 导航位置
/plan Plan PlanPage.vue 企业侧栏
/project/:projectId/plan ProjectPlan PlanPage.vue 项目侧栏

两条路由指向同一个组件,通过路由参数区分域。

10.2 项目侧栏导航

项目侧栏通过 projectModuleNav.ts 配置模块图标和标题。规划模块的侧栏 key 为 plan,图标使用 FolderKanbanIcon,标题为「规划」。

侧栏的模块列表由后端配置中心(第九篇)驱动------根据项目模板启用的模块动态渲染。如果模板未启用规划模块,侧栏不显示规划入口。

十一、总结

规划模块的设计要点:

  1. 双域共享:企业规划和项目规划共用同一套组件和逻辑,通过路由参数自动切换数据源
  2. 甘特图 + 日历:两种时间轴视图满足不同粒度的排期需求------甘特图适合项目级精细排期,日历适合月度概览
  3. 父子树状:甘特图左侧列表支持父子折叠,直观展示工作项拆分关系
  4. 拖拽改期 :三种拖拽模式(移动/缩左/缩右),乐观更新 + 失败回滚,点击与拖拽通过 suppressClick 标志位消歧
  5. 视图内新建:甘特图点击空轨补默认 7 天,日历拖拽选段预填计划时间,都复用工作项表单抽屉
  6. 状态着色:优先使用状态机元数据(kind/finalType),回退到状态码,完成态使用斜纹半透明
  7. 组件复用:大量复用工作项模块的表单抽屉、成员列表、helpers,规划只是工作项的另一种视图
  8. 独立 Token:规划页面有独立的设计 Token 体系,样式不外溢
  9. 轻量日期工具:基于字符串的日期操作,不依赖日期库
  10. 配置驱动导航:项目侧栏的规划入口由配置中心模块开关控制
相关推荐
今天不在线1 小时前
旧 Java 项目接入 AI 实战系列(一):四层递进,从基础对话到流式输出
后端
卷无止境1 小时前
WebGIS生态全景丨从浏览器里的地图到背后的空间数据库
后端·python
JuiceFS2 小时前
JuiceFS 企业版 5.4:从千亿文件到百万客户端
后端
Lost of 程序猿2 小时前
命令模式实战:把“下单后的动作“打包成可执行的任务
后端·设计模式·c#·asp.net·命令模式
焦玉全2 小时前
Sharding-JDBC 分库分表实战:从 800 万订单表的查询优化说起
后端
王中阳Go2 小时前
面试官问"你怎么证明它有效",200个转AI的后端没几个答得上来
人工智能·后端·面试
深入云栈2 小时前
Netty 4.2.x 源码深度解析 (十四):KQueue 传输 —— macOS 高性能 IO 的 Netty 实现
java·后端
一只公羊2 小时前
在 Ubuntu 26.04 LTS (Root) 下使用 uv 丝滑安装 Microsoft MarkItDown
后端