Files
iboard/docs/V0.2/任务模块/任务模块设计文档-框架设计.md
T
jif2.zhang 6c9cd3fd99
Docker Build and Push / build-image (push) Failing after 3m51s
feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
需求 142 条 (T-*), 设计见 docs/V0.2/任务模块/

数据层:
- 新增 6 张表: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag
- 看板列是实体(可增删改排序), Task.columnId 外键 RESTRICT 防误删列丢任务
- Task.status(todo/doing/done) 与 columnId 双落库, 二者正交

领域层 server/task-domain/ (唯一口径, 前后端不重复实现):
- resolveStatus: 列决定状态(只看目标列, 无拖出回退); 状态与列不匹配时按列序取第一个绑定该状态的列自动归位, 无匹配列则不移动; 子任务完全豁免
- retention: 逐列 6 档保留窗口(永不显示/当天/3天/本周/本月/永久显示), 以 doneAt 判定
- copyTask: 跨项目深拷贝, 人员按目标项目成员过滤并回报丢弃名单

接口: /api/projects, /api/columns, /api/tasks (均校验项目成员)

前端:
- 底部 Tab 插入「任务」, 位于「打卡」之后; 默认首页仍为打卡
- 列表视图(子任务缩进+折叠) / 看板视图(长按拖拽换列、拖到卡片降为子任务)
- 列设置(列管理+状态绑定+保留选项) / 任务详情(扩展信息卡片默认收起) / 成员管理
- 视图记忆与上次项目存设备端

已验证: 95 文件语法 0 错误、相对导入 0 缺失、领域逻辑用例全部通过
2026-10-24 11:21:16 +08:00

24 KiB
Raw Blame History

iBoard V0.2 任务模块 设计文档 — 框架设计

项 内容
版本 V0.2(对应分支 dev-0.2,应用版本 0.2.0)
文档状态 设计基线(待评审;不含实现代码)
上游文档 任务模块需求文档.md(需求编号 T-* 原文见该文档,本文按编号逐条对应)
配套文档 任务模块功能设计.md(功能设计,需求点一一对应)、任务模块数据库设计文档.md、任务模块界面设计.html
技术栈 Tauri 2 + React 18 + antd-mobile + React Router v6 + Express 4 + Prisma / MariaDB
设计基线 移动端优先,视口 420×820,375~430px 自适应;时区统一 Asia/Shanghai

编号约定:本文多处出现需求编号(如 T-BD-14),其完整描述与验收要点以《任务模块需求文档》为准。每个模块的「需求点 → 设计落点」对照见《任务模块功能设计》。


1. 总体设计

1.1 目标与范围

在既有 iBoard 之上新增任务模块,并把它作为顶级 Tab 插入「打卡」之后(顺序:打卡 → 任务 → 宏观 → 监控 → 我,默认首页仍为打卡,T-PM-06)。

模块解决的问题与核心隐喻:

视图 / 概念 解决的问题 核心隐喻
列表视图 快速记录、逐条推进 Todoist 式条目 + 一级子任务缩进
看板视图 多人分工下的阶段流转 列(阶段)× 卡片(任务),长按拖拽换列
项目 数据与权限的隔离单位 项目即"工作区",成员可见即可读写
列-状态绑定 让"拖动列"与"改状态"两个动作不必重复做 列是空间,状态是属性,绑定是二者之间的可选桥
列保留选项 已完成任务不淹没在列表里 每列自己的"保质期"

设计的关键取舍:把「看板列」与「任务状态」设计成两个独立维度,再用项目级、逐列的绑定配置把它们关联起来。这样既支持"拖动即改状态"的一体化体验,也支持"这一列只是分类、不进状态机"的轻量用法——这正是需求中 Action 默认不绑定状态的意图。

1.2 架构总览

┌──────────────────── 前端 (React + antd-mobile,打包进 Tauri / OTA) ────────────────────┐
│  App.jsx 路由                                                                          │
│    └ /task            ──► TaskPage ──┬── ProjectList   (项目列表 / 个人默认项目)         │
│                                       └── /task/:pid ──► ProjectView                   │
│                                              ├── ListView   (列表 + 子任务缩进折叠)      │
│                                              ├── BoardView  (看板 + 长按拖拽)           │
│                                              └── ColumnSettings / ProjectSettings      │
│  组件: BoardColumn / TaskCard / SubtaskRow / StatusDot / ExtrasCard / UserPicker        │
│  状态: TaskStore (项目内单例) / ReorderableList / useLongPressDrag                      │
│  工具: src/pages/task/date.js (与后端同口径的自然日边界)                                 │
│        src/pages/task/prefs.js (设备端视图记忆 / 上次项目)                               │
└──────────────────────────────┬─────────────────────────────────────────────────────────┘
                               │ HTTPS + JWT (Authorization: Bearer)
┌──────────────────────────────▼─────────────────────────────────────────────────────────┐
│ Express                                                                                │
│   /api/projects/*  ─► routes/projects.js ─┐                                            │
│   /api/tasks/*     ─► routes/tasks.js     ─┤─► middleware.authRequired                   │
│   /api/columns/*   ─► routes/columns.js   ─┤─► requireProjectMember (项目成员校验)       │
│                                           └─► task-domain/  ← 【领域逻辑唯一来源】       │
│                                                 resolveStatus()  列/状态联动裁决         │
│                                                 resolveColumn()  自动归位                 │
│                                                 retentionWindow() 保留窗口                │
│                                                 copyTask()       跨项目拷贝               │
└──────────────────────────────┬─────────────────────────────────────────────────────────┘
                               ▼
        MariaDB: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag

1.3 分层与职责

层 位置 职责
页面 src/pages/task/*.jsx 视图编排、本地状态、乐观更新与回滚
组件 src/components/task/*.jsx 纯展示 + 局部交互(卡片、列、子任务行、状态点、扩展卡片)
设备端偏好 src/pages/task/prefs.js viewMode / lastProjectId(仅设备端,T-VW-06、T-VW-07)
日期工具 src/pages/task/date.js 自然日边界计算,与后端 task-domain/date.js 同口径,改一处必须同步另一处
API 封装 src/api/task.js 统一走既有 api()(自带 token / 错误解析)
路由 server/routes/tasks.js、projects.js、columns.js 参数校验、鉴权、组装展示态
领域逻辑 server/task-domain/*.js 列-状态裁决、自动归位、保留窗口、拷贝(唯一口径)
持久化 Prisma Client 表操作

与打卡模块的区别:打卡的周期口径只有 period.js 一个文件;任务模块的领域规则(三条)彼此有依赖(归位依赖列序、保留依赖完成时间与列配置),统一收敛到 task-domain/ 目录,前后端不得各自实现一遍裁决逻辑——前端只做乐观更新,最终以服务端返回为准。

1.4 目录结构(本次新增/修改)

prisma/
  schema.prisma                                        # +6 个 model,User 加反向关系
  migrations/20261024xxxxxx_add_task_module/           # 新建表 + 外键 + 索引
server/
  task-domain/
    date.js                                            # 自然日边界(今天/本周/本月)
    resolveStatus.js                                   # 列→状态、状态→列、自动归位
    retention.js                                       # 列保留窗口 → 可见性
    copyTask.js                                        # 跨项目深拷贝
  routes/
    projects.js                                        # /api/projects/*
    columns.js                                         # /api/columns/*
    tasks.js                                           # /api/tasks/*
  index.js                                             # 挂载三个路由
src/
  api/task.js                                          # projectApi / columnApi / taskApi
  pages/task/
    date.js                                            # 与后端同口径
    prefs.js                                           # 设备端偏好(localStorage)
    TaskPage.jsx                                       # 模块容器 + 路由
    ProjectList.jsx                                    # 项目列表
    ProjectView.jsx                                    # 列表/看板容器
    ListView.jsx                                       # 列表视图
    BoardView.jsx                                      # 看板视图
    ColumnSettings.jsx                                 # 列管理 + 绑定 + 保留选项
    TaskDetail.jsx                                     # 任务详情(含扩展卡片 / 子任务)
    ProjectMembers.jsx                                 # 成员管理
  components/task/
    TaskCard.jsx  SubtaskRow.jsx  StatusDot.jsx
    ExtrasCard.jsx  UserPicker.jsx  useLongPressDrag.js
  components/BottomNav.jsx                             # Tab 增「任务」,位于打卡之后
  components/TabIcon.jsx                               # 增 task 图标
  App.jsx                                              # +/task、/task/:pid
  styles/globals.css                                   # .task-* 样式段
docs/V0.2/任务模块/                                      # 需求 / 框架 / 功能 / 数据库 / 界面

1.5 数据流总览

用户操作 ──► 前端乐观更新(本地 store 立即变)
              │
              ▼
        PUT /api/tasks/:id { columnId?, status?, done? }
              │
              ▼
        requireProjectMember(非成员 403)
              │
              ▼
        resolveStatus()  ── 读项目 columnBindings(列序 + 绑定)
              │            ① 目标列绑定状态 ⇒ status = boundStatus
              │            ② 目标列不绑定   ⇒ 保留原 status
              │            ③ 状态变了且当前列不绑定该状态 ⇒ 自动归位(列序首个匹配)
              │            ④ 无匹配列        ⇒ 列不动
              ▼
        写库(status + columnId 同时落库)
              │
              ▼
        返回最新任务 ──► 前端以响应覆盖本地乐观值(不一致则纠正)

2. 领域规则设计(重点)

本模块所有"难"的地方都集中在列与状态的双向联动上。下面把规则收敛为一组无歧义的判定函数,前后端都以此为准。

2.1 两个正交维度的定义

维度 字段 取值 谁决定
空间 Task.columnId 指向项目内某个 BoardColumn 用户拖拽 / 自动归位
属性 Task.status todo(未开始)/ doing(执行中)/ done(完成) 用户勾选 / 列绑定联动

二者独立落库,不做互相推导(见《数据库设计文档》§2)。原因:

  1. 未绑定的列(如默认 Action)要求二者完全正交,任何推导都会丢失信息;
  2. 同一状态可被多列绑定(T-BD-03),状态无法反推唯一列;
  3. 保留选项依赖"完成时间"而非列,推导会引入隐式状态。

2.2 三种联动场景与判定函数

设项目列列表为 C = [c₁, c₂, …, cₙ](按 sortOrder 升序),每列 c.boundStatus ∈ {todo, doing, done, null}。

场景 A · 拖拽换列(T-BD-04/07/08)

function onColumnChange(task, targetColumnId):
    c = columnById(targetColumnId)
    if c.boundStatus != null:
        task.status = c.boundStatus        # 列决定状态
    else:
        # 目标列不绑定 ⇒ 保留原状态(T-BD-07、T-EX-05)
        pass
    task.columnId = targetColumnId

注意:这里不存在「从某列拖出即回退状态」的规则。是否改状态只看目标列。这是评审中明确修正过的口径(原设计曾写"拖出回退为未开始",已废弃)。

场景 B · 改状态(勾选/选择器,T-BD-14/15/16)

function onStatusChange(task, newStatus):
    task.status = newStatus
    # 当前列是否已经"匹配"这个状态?
    if task.column.boundStatus == newStatus:
        return                            # 已匹配,列不动
    # 不匹配 ⇒ 自动归位,按列序取第一个绑定 newStatus 的列(T-BD-15)
    target = first(c in C where c.boundStatus == newStatus)
    if target != null:
        task.columnId = target.id
    else:
        # 无匹配列 ⇒ 不移动,保留原列(T-BD-16)
        pass

场景 C · 子任务(T-BD-13)

if task.parentId != null:
    # 子任务完全跳过上述两种联动
    persist(status, columnId) as-is

2.3 判定矩阵(可转写为单测)

以默认配置 plan→todo, doing→doing, action→null, done→done 为例:

# 初始(列 / 状态) 操作 结果(列 / 状态) 覆盖需求
1 plan / todo 拖到 doing doing / doing T-BD-04
2 plan / todo 拖到 action(不绑定) action / todo(保留) T-BD-07、T-BD-08
3 action / todo 改为 done done / done(自动归位) T-BD-14、T-BD-15
4 action / todo 改为 doing doing / doing T-BD-14
5 done / done 拖到 action action / done(保留) T-BD-07、T-EX-05
6 done / done 改为 doing doing / doing T-BD-14
7 plan / todo 改为 todo plan / todo(已匹配,不动) T-BD-14
8 全部列不绑定 done,任务在 action 改为 done action / done(无匹配不移动) T-BD-16
9 父任务 done,子任务在 action 改父任务状态 子任务完全不受影响 T-BD-13
10 两列都绑定 done(done、action) 改为 done 列序靠前者(done) T-BD-06、T-BD-15

第 8 行是评审中特别确认的:没有「已完成统一归到 Done 列」的强制规则,那只是默认配置下的自然效果。用户取消全部绑定后,任务就停在原列。

2.4 列配置变更的语义(T-BD-11、T-COL-08)

核心原则:配置变更只影响后续操作,不批量改写历史数据。

变更 处理
列 A 由「绑定 done」改为「不绑定」 已在 A 中且状态为 done 的任务保持不动(列与状态都保留),见 T-EX-03
列 A 绑定状态由 doing 改为 done 已在 A 中的任务状态不回写;后续拖入 A 的任务才按 done 处理(T-BD-11)
删除列 A 二次确认 → 列内任务迁移到指定列(默认相邻列)→ 迁移后按目标列重新裁决状态;A 的绑定解除(T-COL-08、T-EX-16)
新增列 取默认值 retention=forever、boundStatus=null,不继承相邻列(B-18)

为什么删除列要"重新裁决"而改绑定不裁决:删除是数据搬家(任务必须有个列),搬家后必须与目标列的语义自洽;而改绑定只是改了"未来的规则",不该动已有数据。

2.5 空归属与一致性保障

风险 保障
删除列后任务 columnId 悬空 迁移在同事务内完成;Task.columnId 为 NOT NULL,外键 ON DELETE RESTRICT 从库层面阻止误删
列被删后 boundStatus 残留 BoardColumn 是实体,列删则绑定自然消失,无残留(优于早期 JSON 方案)
并发拖拽同一任务 服务端以「读-判定-写」在同一事务内完成;前端防抖 + 版本号(可选)
状态改了但归位列不存在 判定函数显式处理「无匹配列」分支(T-BD-16),不抛异常

3. 重点流程设计

3.1 看板拖拽(长按 → 跨列 → 落到子任务)

这是本模块交互最复杂的一条链路,同时涉及换列与降级为子任务两种语义。

长按 300ms ─► 卡片抬起(缩小 + 浮层跟随手指)
                │
                ├─ 拖到【另一列空白区】
                │     └─ 松手 → onColumnChange(任务, 目标列)
                │              → 按 §2.2 场景 A 裁决
                │              → 乐观更新本地列与状态 → PATCH(失败回滚 + Toast)
                │
                ├─ 拖到【另一列某卡片上】
                │     └─ 该卡片高亮为"落点"
                │        松手 → 若落点是顶层任务且被拖任务无子任务 ⇒ 降为子任务
                │               ├─ 是子任务 ⇒ 拒绝(T-ST-13)
                │               ├─ 被拖任务有子任务 ⇒ 拒绝(T-ST-14)
                │               └─ 拖到自己/后代 ⇒ 拒绝(T-EX-15)
                │
                └─ 拖到【原列】→ 视为排序(本期仅更新 sortOrder)

难点与对策:

难点 对策
移动端长按与滚动冲突 长按 300ms 后才进入拖拽态;进入后 touch-action:none 并锁定滚动容器
看板横向滚动 vs 拖拽换列 拖拽时容器允许横向自动滚动(边缘 24px 触发),松手位置决定列
落点判定(列 vs 卡片) 用 elementFromPoint 命中 [data-column-id] / [data-task-id],卡片优先
误操作 拒绝场景统一 Toast 说明原因;松手动画回弹原位
降为子任务后卡片消失 子任务不出独立卡片(T-ST-10),故从其原列移除,父任务卡片内出现该子任务行(T-ST-06)

兜底:无法拖拽时(如长按被系统手势占用),卡片「⋯」菜单提供「移动到列 / 移动到子任务」的等价操作(T-UI-07)。

3.2 保留选项的可见性计算与"当场消失"

窗口计算(后端 task-domain/retention.js,自然日口径):

function retentionStart(option, now):
    today = startOfDay(now)                 # Asia/Shanghai 00:00
    switch option:
        never     => 返回"不可见"
        forever   => 返回"无下界"(全可见)
        today     => today
        3d        => today - 2 天           # 含今天共 3 天
        week      => 本周一 00:00
        month     => 本月 1 日 00:00

可见性判定:task.status == done
            ? (doneAt != null && doneAt >= retentionStart(该任务所属列的 option))
            : true                           # 未完成任务始终可见(T-FL-12)

判定依据:Task.doneAt(完成时刻),与任务当前所在列无关(T-FL-10);归属哪一列决定用哪一列的选项(T-FL-11)。

前端"当场消失"(T-EX-18):

每分钟(以及跨日/跨周/跨月边界整点)执行一次:
  重新计算当前可见集合
  对"由可见变不可见"的任务卡片:
      加 .is-leaving(透明 + 高度收缩,约 240ms)
      动画结束移除 DOM
      其余卡片通过布局动画平滑重排(不做全局刷新)

也允许退化为"刷新时计算"——需求明确接受该实现方式(§5 决策 23)。跨周期边界用**定时器 + 页面可见性变化(visibilitychange)**双保险。

3.3 跨项目拷贝

拷贝是深拷贝,且要处理目标项目成员差异(T-CP-07)与绑定差异(T-CP-09)。

POST /api/tasks/:id/copy { projectId: 目标项目 }
  │
  ├─ 校验:源任务可读、目标是本人成员(T-CP-06)
  ├─ 事务内:
  │    newTask = create({
  │        ...全部业务字段(标题/描述/状态/列/扩展卡片/标签)
  │        creatorId = 当前操作者        ← T-CP-04
  │        projectId = 目标项目
  │        columnId  = 目标项目中"列名相同"的列,找不到则取第一列
  │    })
  │    assigneeId = 目标项目成员?保留 : 置 null        ← T-CP-07
  │    taskMembers = 过滤出目标项目成员中的交集        ← T-CP-07
  │    for 每个子任务:同法 create(parentId = newTask.id)  ← T-CP-03、T-CP-08
  │    状态与列不强行纠正(T-EX-14)
  └─ 返回 { task, dropped: { assignee, members[] } }   ← 提示被丢弃的人员

新旧任务完全独立(T-CP-05):拷贝后无任何关联字段,是两条独立记录。

3.4 个人默认项目的创建与保护

创建时机:用户首次访问 /api/projects(或注册时)→ 不存在 isPersonal 项目则创建
          name = `${user.name}的任务`(T-PJ-04,可改名)

保护规则(服务端强制,前端不提供入口):
  DELETE /api/projects/:id        → isPersonal ⇒ 400(T-PJ-02、T-EX-09)
  POST   /api/projects/:id/members → isPersonal ⇒ 400(T-PJ-03、T-EX-09)
  PUT    /api/projects/:id         → 允许改名(T-PJ-04)

3.5 成员清空后的项目

删除最后一名成员时不删除项目,只删 ProjectMember 记录(B-11、T-PJ-08):

  • 项目成为"无成员项目",任何人都不可见(T-PJ-10);
  • 前端不做特判、不报错(T-EX-11);
  • 数据保留,由后台维护流程后续处理(不在本模块范围,§1.5)。

副作用:曾为该项目任务的负责人 / 成员会显示为「已退出」(T-EX-06),因为其 userId 已不是项目成员。


4. 状态管理与性能

4.1 前端状态

状态 存放 生命周期
当前项目、任务列表、列配置 TaskStore(项目内单例) 进入项目时加载,切换项目时替换
视图(list/board) 设备端 localStorage.iboard_task_prefs 按 projectId 分键(T-VW-03/06)
上次访问项目 设备端 同上 单个 key(T-VW-07)
折叠状态(子任务) 内存(组件内) 会话内,不持久化
弹窗 / 拖拽态 组件内 操作结束即清

4.2 性能设计

场景 目标 手段
首屏加载(T-NF-01) < 1.5s 任务与列配置一次请求返回;子任务随父任务同批返回
交互响应(T-NF-01) < 300ms 乐观更新,不等接口返回
千级任务(T-NF-05) 可用 索引覆盖(projectId+columnId、projectId+status);列表虚拟化(按需)
保留计算 不额外请求 前端本地按 doneAt + 列选项实时过滤,切换档位零请求
看板拖拽 60fps 拖拽期只用 transform,不改布局;落下后才重排

4.3 一致性与容错

  • 乐观更新 + 回滚:失败时恢复快照并 Toast(T-EX-02)。
  • 幂等:拖拽/勾选 500ms 内重复提交合并(T-EX-01)。
  • 配置变更即时生效:保存列配置后服务端返回最新列列表,前端整体替换并重算可见集合,无需刷新(T-BD-12)。

5. 风险与权衡

# 风险 / 权衡 说明与对策
1 列与状态双维度带来的认知成本 默认配置下二者一一对应(除 Action),用户"不懂也不会错";只有主动改配置时才需要理解绑定
2 自动归位可能让用户"找不到"任务 任务从当前列跳走。对策:归位后该卡片带一次高亮动画;提供 Toast「已移至 <列名>」
3 保留选项与"未绑定完成的已完任务" 状态为 done 但所有列都不绑定 done 时,任务停在不绑定列,其可见性按该列选项判定(T-FL-11)——语义自洽但需要文案说明
4 列可增删导致看板横向过宽 上限暂定 10 列(T-COL-06,待确认);超出时看板横向滚动 + 列头悬浮
5 子任务状态与父任务不一致的展示 卡片内嵌子任务带小状态标记(T-ST-09),避免"父已完成但子未做"的信息丢失
6 跨项目拷贝丢弃人员 返回被丢弃的名单并在前端提示,避免静默丢数据(T-EX-08)
7 无成员项目的数据残留 本期不处理(T-PJ-08),需后台维护;在数据库设计文档中标注为"孤儿项目"
8 前后端日期口径不一致 date.js 双份实现是既有项目的做法;本期继续沿用并在两处加注释互指,写单测覆盖边界(周日 23:59 / 周一 00:00 / 月末)

6. 测试与验证要点

类别 要点
领域规则单测 §2.3 判定矩阵 10 条全覆盖;retentionStart() 六档边界(含周日/月末跨点)
接口测试 非项目成员访问 403;个人默认项目的两个保护接口返回 400;拷贝后源任务不变
交互验证 拖拽换列 / 降为子任务 / 拒绝场景 Toast;保留档位切换任务当场消失并重排
兼容性 Android WebView + 移动浏览器(T-NF-02);375 / 390 / 430 三档视口
两视图一致 列表与看板可见条数一致(T-FL-11、T-FL-14);任一处修改另一处即时可见(T-UI-03)