知道「有哪些工作项」和知道「这些工作项什么时候做」是两件事。工作项列表回答前者------谁负责、什么状态;规划回答后者------什么时候开始、什么时候结束、哪些工作项在并行、哪些已经逾期。规划模块以甘特图和日历两种视图呈现工作项的计划周期,支持拖拽排期、视图内新建、父子折叠,同时兼容企业域和项目域两种范围。本篇记录规划的双域数据流、甘特图与日历视图的实现、拖拽改期的乐观更新机制、以及与工作项模块的复用关系。
一、规划要解决什么问题
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,标题为「规划」。
侧栏的模块列表由后端配置中心(第九篇)驱动------根据项目模板启用的模块动态渲染。如果模板未启用规划模块,侧栏不显示规划入口。

十一、总结
规划模块的设计要点:
- 双域共享:企业规划和项目规划共用同一套组件和逻辑,通过路由参数自动切换数据源
- 甘特图 + 日历:两种时间轴视图满足不同粒度的排期需求------甘特图适合项目级精细排期,日历适合月度概览
- 父子树状:甘特图左侧列表支持父子折叠,直观展示工作项拆分关系
- 拖拽改期 :三种拖拽模式(移动/缩左/缩右),乐观更新 + 失败回滚,点击与拖拽通过
suppressClick标志位消歧 - 视图内新建:甘特图点击空轨补默认 7 天,日历拖拽选段预填计划时间,都复用工作项表单抽屉
- 状态着色:优先使用状态机元数据(kind/finalType),回退到状态码,完成态使用斜纹半透明
- 组件复用:大量复用工作项模块的表单抽屉、成员列表、helpers,规划只是工作项的另一种视图
- 独立 Token:规划页面有独立的设计 Token 体系,样式不外溢
- 轻量日期工具:基于字符串的日期操作,不依赖日期库
- 配置驱动导航:项目侧栏的规划入口由配置中心模块开关控制