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

423 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) |