fix(任务): 归档以整棵树为单位并移除隐藏中间态
Docker Build and Push / build-image (push) Successful in 1m42s

- 自动归档改为 doneAt + autoArchive 到期,只判定顶层任务,到期整树归档
- 手动归档/恢复提升到根父任务,子任务完全跟随父任务
- 新增 normalizeArchiveTree 修复历史父子归档不一致
- 移除按列保留窗口的显示过滤(前后端实现删除,T-FL-01~15 废止)
- 列 retention 字段仅兼容旧数据保留,不再参与判定
- 已归档查询返回归档子任务,删除提示可正确显示连带条数
This commit is contained in:
jif2.zhang
2026-11-10 09:20:40 +08:00
parent 7b52220aad
commit e05ed6b491
15 changed files with 255 additions and 489 deletions
@@ -27,7 +27,7 @@
| **看板视图** | 多人分工下的阶段流转 | 列(阶段)× 卡片(任务),长按拖拽换列 |
| **项目** | 数据与权限的隔离单位 | 项目即"工作区",成员可见即可读写 |
| **列-状态绑定** | 让"拖动列"与"改状态"两个动作不必重复做 | 列是**空间**,状态是**属性**,绑定是二者之间的**可选桥** |
| **列保留选项** | 已完成任务不淹没在列表里 | 每列自己的"保质期" |
| **自动归档档位** | 已完成任务不淹没在列表里 | 项目级"完成后多久归档" |
**设计的关键取舍**:把「看板列」与「任务状态」设计成**两个独立维度**,再用**项目级、逐列的绑定配置**把它们关联起来。这样既支持"拖动即改状态"的一体化体验,也支持"这一列只是分类、不进状态机"的轻量用法——这正是需求中 `Action` 默认不绑定状态的意图。
@@ -56,7 +56,8 @@
│ └─► task-domain/ ← 【领域逻辑唯一来源】 │
│ resolveStatus() 列/状态联动裁决 │
│ resolveColumn() 自动归位 │
│ retentionWindow() 保留窗口 │
│ archiveDue() 自动归档到期判定 │
│ normalizeArchiveTree() 父子归档一致性 │
│ copyTask() 跨项目拷贝 │
└──────────────────────────────┬─────────────────────────────────────────────────────────┘
▼
@@ -70,13 +71,13 @@
| 页面 | `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` 同口径,改一处必须同步另一处** |
| 日期工具 | `src/pages/task/date.js` | 日期边界、任务编号与状态常量 |
| API 封装 | `src/api/task.js` | 统一走既有 `api()`(自带 token / 错误解析) |
| 路由 | `server/routes/tasks.js`、`projects.js`、`columns.js` | 参数校验、鉴权、组装展示态 |
| 领域逻辑 | `server/task-domain/*.js` | **列-状态裁决、自动归位、保留窗口、拷贝**(唯一口径) |
| 领域逻辑 | `server/task-domain/*.js` | **列-状态裁决、自动归位、自动归档、拷贝**(唯一口径) |
| 持久化 | Prisma Client | 表操作 |
> **与打卡模块的区别**:打卡的周期口径只有 `period.js` 一个文件;任务模块的领域规则(三条)彼此有依赖(归位依赖列序、保留依赖完成时间与列配置),统一收敛到 `task-domain/` 目录,**前后端不得各自实现一遍裁决逻辑**——前端只做乐观更新,最终以服务端返回为准。
> **与打卡模块的区别**:打卡的周期口径只有 `period.js` 一个文件;任务模块的领域规则(三条)彼此有依赖(归位依赖列序、归档依赖完成时间与项目档位),统一收敛到 `task-domain/` 目录,**前后端不得各自实现一遍裁决逻辑**——前端只做乐观更新,最终以服务端返回为准。
### 1.4 目录结构(本次新增/修改)
@@ -86,9 +87,8 @@ prisma/
migrations/20261024xxxxxx_add_task_module/ # 新建表 + 外键 + 索引
server/
task-domain/
date.js # 自然日边界(今天/本周/本月)
resolveStatus.js # 列→状态、状态→列、自动归位
retention.js # 列保留窗口 → 可见性
archive.js # 自动归档到期判定 + 父子整树一致性
copyTask.js # 跨项目深拷贝
routes/
projects.js # /api/projects/*
@@ -105,7 +105,7 @@ src/
ProjectView.jsx # 列表/看板容器
ListView.jsx # 列表视图
BoardView.jsx # 看板视图
ColumnSettings.jsx # 列管理 + 绑定 + 保留选项(由项目管理进入)
ColumnSettings.jsx # 列管理 + 绑定(由项目管理进入)
TaskDetail.jsx # 任务详情(含扩展卡片 / 子任务;底部安全距离)
ProjectMembers.jsx # 成员管理(由项目管理进入)
ProjectManage.jsx # 项目管理: 成员 / 列设置 / 项目信息 三区
@@ -161,7 +161,7 @@ docs/V0.2/任务模块/ # 需求 / 框架 /
1. 未绑定的列(如默认 `Action`)要求二者**完全正交**,任何推导都会丢失信息;
2. 同一状态可被多列绑定(T-BD-03),状态无法反推唯一列;
3. 保留选项依赖"完成时间"而非列,推导会引入隐式状态。
3. 自动归档依赖"完成时间"与项目档位,不依赖列,避免推导引入隐式状态。
### 2.2 三种联动场景与判定函数
@@ -235,7 +235,7 @@ if task.parentId != null:
| 列 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) |
| 新增列 | 取默认值 `boundStatus=null`,**不继承相邻列**(B-18);`retention` 字段已废弃 |
> 为什么删除列要"重新裁决"而改绑定不裁决:删除是**数据搬家**(任务必须有个列),搬家后必须与目标列的语义自洽;而改绑定只是改了"未来的规则",不该动已有数据。
@@ -286,40 +286,33 @@ if task.parentId != null:
> 兜底:无法拖拽时(如长按被系统手势占用),卡片「⋯」菜单提供「移动到列 / 移动到子任务」的等价操作(T-UI-07)。
### 3.2 保留选项的可见性计算与"当场消失"
### 3.2 自动归档与父子整树一致性
**窗口计算**(后端 `task-domain/retention.js`,自然日口径):
> 2026-11-10 起取消「隐藏」中间态:任务只有**显示**与**归档**两种状态,不再有按列保留窗口的可见性过滤。
**到期计算**(后端 `task-domain/archive.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
due = doneAt + autoArchive 档位
autoArchive = 3d → doneAt + 72 小时
autoArchive = 1w → doneAt + 7 天
autoArchive = 1m → doneAt + 1 个上海日历月
autoArchive = never → 不自动归档
可见性判定:task.status == done
? (doneAt != null && doneAt >= retentionStart(该任务所属列的 option))
: true # 未完成任务始终可见(T-FL-12)
判定范围:parentId IS NULL / status = done / doneAt 非空 / archived = false
到期后:该顶层任务 + 全部子任务一起置 archived=true
```
**判定依据**:`Task.doneAt`(完成时刻),**与任务当前所在列无关**(T-FL-10);归属哪一列决定用哪一列的选项(T-FL-11)。
**父子一致性**:子任务不参与独立判定。手动归档/恢复、自动归档都以整棵树为单位;服务端归档扫描时执行 `normalizeArchiveTree()`,修复历史数据中父子归档态不一致的情况(子任务强制跟随父任务)。
**前端"当场消失"**(T-EX-18):
**执行时机**:
```
每分钟(以及跨日/跨周/跨月边界整点)执行一次:
重新计算当前可见集合
对"由可见变不可见"的任务卡片:
加 .is-leaving(透明 + 高度收缩,约 240ms)
动画结束移除 DOM
其余卡片通过布局动画平滑重排(不做全局刷新)
1. 读时惰性:GET /api/projects/:id/tasks 前先跑一轮该项目归档
2. 每小时 cron:扫描全部项目,先修复父子一致性,再执行到期自动归档
```
> 也允许退化为"刷新时计算"——需求明确接受该实现方式(§5 决策 23)。跨周期边界用**定时器 + 页面可见性变化(`visibilitychange`)**双保险。
> 前端不再需要保留窗口定时器或可见性过滤;`taskApi.list` 只返回 `archived = 0` 的顶层任务及其 `archived = 0` 的子任务。
### 3.3 跨项目拷贝
@@ -405,12 +398,12 @@ POST /api/tasks/:id/copy { projectId: 目标项目 }
| --- | --- | --- |
| 1 | **列与状态双维度带来的认知成本** | 默认配置下二者一一对应(除 Action),用户"不懂也不会错";只有主动改配置时才需要理解绑定 |
| 2 | **自动归位可能让用户"找不到"任务** | 任务从当前列跳走。对策:归位后该卡片带一次高亮动画;提供 Toast「已移至 <列名>」 |
| 3 | **保留选项与"未绑定完成的已完任务"** | 状态为 done 但所有列都不绑定 done 时,任务停在不绑定列,其可见性按该列选项判定(T-FL-11)——语义自洽但需要文案说明 |
| 3 | **已完成任务与"未绑定完成的已完任务"** | 状态为 done 但所有列都不绑定 done 时,任务停在不绑定列并保持显示,直到归档(不再有隐藏中间态) |
| 4 | **列可增删导致看板横向过宽** | 上限暂定 10 列(T-COL-06,**待确认**);超出时看板横向滚动 + 列头悬浮 |
| 5 | **子任务状态与父任务不一致的展示** | 卡片内嵌子任务带**小状态标记**(T-ST-09),避免"父已完成但子未做"的信息丢失 |
| 6 | **跨项目拷贝丢弃人员** | 返回被丢弃的名单并在前端提示,避免静默丢数据(T-EX-08) |
| 7 | **无成员项目的数据残留** | 本期不处理(T-PJ-08),需后台维护;在数据库设计文档中标注为"孤儿项目" |
| 8 | **前后端日期口径不一致** | `date.js` 双份实现是既有项目的做法;本期继续沿用并在两处加注释互指,写单测覆盖边界(周日 23:59 / 周一 00:00 / 月末) |
| 8 | **归档时间口径不一致** | 自动归档统一在 `server/task-domain/archive.js` 计算(`doneAt + autoArchive`),前端不重复实现;边界写单测覆盖(72h / 7d / 上海日历月) |
---
@@ -418,11 +411,11 @@ POST /api/tasks/:id/copy { projectId: 目标项目 }
| 类别 | 要点 |
| --- | --- |
| **领域规则单测** | §2.3 判定矩阵 10 条全覆盖;`retentionStart()` 六档边界(含周日/月末跨点) |
| **领域规则单测** | §2.3 判定矩阵 10 条全覆盖;`autoArchiveDue()` 四档边界(含上海日历月跨月) |
| **接口测试** | 非项目成员访问 403;个人默认项目的两个保护接口返回 400;拷贝后源任务不变 |
| **交互验证** | 拖拽换列 / 降为子任务 / 拒绝场景 Toast;保留档位切换任务当场消失并重排 |
| **交互验证** | 拖拽换列 / 降为子任务 / 拒绝场景 Toast;归档/恢复以整棵树为单位,子任务不单独变化 |
| **兼容性** | Android WebView + 移动浏览器(T-NF-02);375 / 390 / 430 三档视口 |
| **两视图一致** | 列表与看板可见条数一致(T-FL-11、T-FL-14);任一处修改另一处即时可见(T-UI-03) |
| **两视图一致** | 列表与看板数据同源、条数一致;任一处修改另一处即时可见(T-UI-03) |
---