From e05ed6b491f42883b5f821ce4dd84aa469cb8e9f Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Tue, 10 Nov 2026 09:20:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E4=BB=BB=E5=8A=A1):=20=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E4=BB=A5=E6=95=B4=E6=A3=B5=E6=A0=91=E4=B8=BA=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=E5=B9=B6=E7=A7=BB=E9=99=A4=E9=9A=90=E8=97=8F=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自动归档改为 doneAt + autoArchive 到期,只判定顶层任务,到期整树归档 - 手动归档/恢复提升到根父任务,子任务完全跟随父任务 - 新增 normalizeArchiveTree 修复历史父子归档不一致 - 移除按列保留窗口的显示过滤(前后端实现删除,T-FL-01~15 废止) - 列 retention 字段仅兼容旧数据保留,不再参与判定 - 已归档查询返回归档子任务,删除提示可正确显示连带条数 --- docs/V0.2/任务模块/任务模块功能设计.md | 72 +++++----- docs/V0.2/任务模块/任务模块数据库设计文档.md | 22 +-- docs/V0.2/任务模块/任务模块界面设计.html | 8 +- .../任务模块/任务模块设计文档-框架设计.md | 69 ++++----- docs/V0.2/任务模块/任务模块需求文档.md | 106 ++++++-------- server/mcp/tools/tasks.js | 15 +- server/routes/tasks.js | 32 +++-- server/task-domain/archive.js | 135 +++++++++--------- server/task-domain/date.js | 93 ------------ server/task-domain/retention.js | 31 ---- src/api/task.js | 31 ++-- src/pages/task/ColumnSettings.jsx | 30 +--- src/pages/task/ProjectManage.jsx | 2 +- src/pages/task/ProjectView.jsx | 38 ++--- src/pages/task/date.js | 60 +------- 15 files changed, 255 insertions(+), 489 deletions(-) delete mode 100644 server/task-domain/date.js delete mode 100644 server/task-domain/retention.js diff --git a/docs/V0.2/任务模块/任务模块功能设计.md b/docs/V0.2/任务模块/任务模块功能设计.md index 5daf786..1ed698e 100644 --- a/docs/V0.2/任务模块/任务模块功能设计.md +++ b/docs/V0.2/任务模块/任务模块功能设计.md @@ -23,7 +23,7 @@ | --- | --- | --- | | 状态枚举 | `todo` / `doing` / `done` | 未开始 / 执行中 / 完成 | | 列 | `BoardColumn` 实体,按 `sortOrder` 升序 | 默认 `Plan/Doing/Action/Done` | -| 保留选项 | `never` / `today` / `3d` / `week` / `month` / `forever` | 永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示 | +| 自动归档档位 | `3d` / `1w` / `1m` / `never` | 完成后 3 天 / 一周 / 一月 / 永不 | | 视图 | `list` / `board` | 设备端记忆 | | 设备端键 | `iboard_task_prefs` | `{ lastProjectId, viewMode: { [projectId]: 'list'\|'board' } }` | @@ -39,7 +39,7 @@ | POST | `/api/projects/:id/members` | 加成员(个人项目 400) | | DELETE | `/api/projects/:id/members/:uid` | 移成员 / 自己退出 | | GET/POST | `/api/projects/:id/columns` | 列列表 / 新增列 | -| PUT | `/api/columns/:id` | 改名 / 改绑定 / 改保留选项 | +| PUT | `/api/columns/:id` | 改名 / 改绑定 | | DELETE | `/api/columns/:id` | 删列(需 `moveToColumnId`,最后列 400) | | PUT | `/api/projects/:id/columns/order` | 列排序 | | GET | `/api/projects/:id/tasks` | 任务列表(含子任务与列信息) | @@ -60,7 +60,7 @@ | T-VW-03 | `prefs.js` → `viewMode[projectId]` | 按项目 id 分键存储,A/B 项目互不影响 | | T-VW-04 | `ProjectView` 挂载时读 `prefs.getViewMode(projectId)` | 有值用之;无值走 T-VW-05 默认 | | T-VW-05 | 默认值 `'list'` | 新项目首次进入为列表 | -| T-VW-06 | `localStorage.iboard_task_prefs` | **不落库、不同步**;换设备不继承(与 T-FL-15 的项目级配置区分) | +| T-VW-06 | `localStorage.iboard_task_prefs` | **不落库、不同步**;换设备不继承 | | T-VW-07 | `prefs.getLastProjectId()` / `setLastProjectId()` | 进入 `/task` 时读取;无记录或记录的项目已不可见 ⇒ 回落个人默认项目 | | T-VW-08 | `TaskPage` 首屏上部 `RecentProjects` 条 | 取最近访问的 3 个项目(按设备端访问时间倒序),末尾「更多项目」入口 → `/task/list`(完整项目列表 `ProjectList`,从「更多」进入) | | T-VW-09 | `TaskPage` 首屏下部直接内嵌 `ProjectView` | 当前项目 = 上次访问项目(无记录 ⇒ 个人默认项目);点首屏项目卡片切换当前项目,下部整体切换,不跳转页面 | @@ -109,32 +109,22 @@ | T-BD-09 | 初始化默认值 | `Plan=todo`、`Doing=doing`、`Done=done`、`Action=null` | | T-BD-10 | 项目级配置 | 任一成员可改;不同项目独立 | | T-BD-11 | 配置变更不回溯 | 保存配置时**不触发**任何任务更新语句 | -| T-BD-12 | 保存后返回最新列列表 | 前端整体替换 + 重算可见集合,无需刷新 | +| T-BD-12 | 保存后返回最新列列表 | 前端整体替换,无需刷新 | | T-BD-13 | `resolveStatus()` 首行判 `parentId` | 子任务直接跳过全部联动 | --- -## 4. 已完成任务显示控制(T-FL) +## 4. 已完成任务显示控制(T-FL,已移除) -| 编号 | 设计落点 | 实现要点 | -| --- | --- | --- | -| T-FL-01 | `BoardColumn.retention` | 每列独立字段 | -| T-FL-02 | `ColumnSettings` 下拉 6 项 | 固定枚举,无自由输入 | -| T-FL-03 | `retention='forever'` | 判定恒为可见 | -| T-FL-04 | `retention='never'` | 已完成任务恒不可见 | -| T-FL-05 | `retention='today'` | `doneAt >= 今天 00:00` | -| T-FL-06 | `retention='3d'` | `doneAt >= 今天-2天 00:00` | -| T-FL-07 | `retention='week'` | `doneAt >= 本周一 00:00` | -| T-FL-08 | `retention='month'` | `doneAt >= 本月1日 00:00` | -| T-FL-09 | `task-domain/retention.js` + 前端定时重算 | 超出窗口自动隐藏,无需操作 | -| T-FL-10 | 判定字段 `Task.doneAt` | 与当前列无关;取消完成时 `doneAt=null` | -| T-FL-11 | 前端可见性统一走同一函数 | 列表与看板共用 `isTaskVisible(task, column)`;列表不分组但归属不变 | -| T-FL-12 | 判定前置 `status !== 'done'` | 未完成任务恒可见 | -| T-FL-13 | 纯展示层过滤 | 不修改任何字段;接口返回全量,前端过滤(或服务端按需过滤但不改数据) | -| T-FL-14 | 列计数取过滤后集合长度 | 列头计数与卡片数同源,杜绝不一致 | -| T-FL-15 | 存在 `BoardColumn` 上 | 项目级共享,非个人偏好(对比 T-VW-06 设备端) | - -> **实现细节**:保留判定在前后端各有一份实现(服务端用于分页/统计,前端用于即时过滤),但**必须共用同一口径常量**,跨度边界由 `date.js` 单测覆盖。 +> **2026-11-10 变更**:取消「隐藏」这一中间状态。任务只有两种状态: +> **显示**(在主列表/看板中)或**归档**(在已归档列表中)。 +> +> - 不再按看板列的保留窗口隐藏已完成任务; +> - `BoardColumn.retention` 字段仅为兼容旧数据保留,**不再参与任何显示或归档判定**; +> - 已完成任务在手动归档或项目自动归档到期前,始终留在主列表/看板; +> - 原 T-FL-01 ~ 15 全部废止,不再有前后端保留窗口过滤实现。 +> +> 归档规则见 §15。子任务完全跟随父任务:要么一起显示,要么一起归档。 --- @@ -314,21 +304,21 @@ | T-EX-15 | 拖到自身或后代 | 拒绝 + Toast | | T-EX-16 | 删除有任务的列 | 二次确认 + 迁移到指定列(默认相邻)+ 重新裁决 | | T-EX-17 | 删除最后一列 | 400「至少保留一列」 | -| T-EX-18 | 跨周期当场消失 | 定时重算 + 退场动画 + 重排 | +| T-EX-18 | 跨周期不再隐藏(原保留窗口需求已移除) | 已完成任务保持显示,直到手动归档或自动归档到期 | | T-EX-19 | 达到列数上限 | 拒绝新增 + 提示 | --- ## 15. 任务归档(T-ARC) -> 归档与 T-FL 显示控制的分层:**隐藏 = 展示过滤(可逆、不落库)**;**归档 = 移出主工作集(落库、可恢复)**。 -> 自动归档的倒计时起点是任务**被隐藏的时刻**(由 `doneAt + 列保留窗口` 推导,不新增 hiddenAt 字段)。 +> 任务只有「显示」与「归档」两种状态,不再存在「隐藏」中间态。 +> 自动归档倒计时起点是任务的**完成时刻** `doneAt`,到期后整棵任务树一起归档。 ### 15.1 数据 | 落点 | 说明 | | --- | --- | -| `Project.autoArchive VARCHAR(8) NOT NULL DEFAULT 'never'` | 自动归档档位:`3d` / `1w` / `1m` / `never`;项目级共享(同 T-FL-12 口径) | +| `Project.autoArchive VARCHAR(8) NOT NULL DEFAULT 'never'` | 自动归档档位:`3d` / `1w` / `1m` / `never`;项目级共享 | | `Task.archived BOOLEAN NOT NULL DEFAULT false` | 归档标记;归档**不迁移表**,主查询统一追加 `archived = false` | | `Task.archivedAt DATETIME(3) NULL` | 归档时刻(查询过滤与排序键) | | `Task.archiveSource VARCHAR(8) NULL` | `'manual'` / `'auto'`,溯源用 | @@ -339,7 +329,7 @@ | 接口 | 说明 | | --- | --- | -| `POST /api/tasks/:id/archive` | 手动归档:父任务连带全部后代置 `archived/archivedAt/archiveSource='manual'`;幂等(已归档直接返回当前态) | +| `POST /api/tasks/:id/archive` | 手动归档:以整棵树为单位置 `archived/archivedAt/archiveSource='manual'`;传子任务 id 也提升到根父任务;幂等 | | `POST /api/tasks/:id/unarchive` | 恢复:父任务连带后代清除归档三字段;原列因 `ON DELETE RESTRICT` 必存在,状态保持不变 | | `GET /api/projects/:id/tasks/archived?keyword&kind&columnId&from&to&cursor&limit` | 归档查询:`keyword` 标题 LIKE、`kind`、`columnId`、`from/to`(归档日期,含边界);`limit` 默认 20、≤100;游标 = `archivedAt+id` 复合降序,返回 `{ items, nextCursor }`,`nextCursor` 为空即到底 | | `PUT /api/projects/:id` | 增接受 `autoArchive`,枚举校验(非法 400) | @@ -347,19 +337,20 @@ ### 15.3 自动归档执行 -- **归档到期判定**:`due = 隐藏时刻 + autoArchive 档位`;隐藏时刻 = `doneAt + 列保留窗口`(复用 `retentionStart` 口径,含 `today/3d/week/month`;`forever` 列任务永不隐藏、不进入自动归档)。 +- **归档到期判定**:`due = doneAt + autoArchive 档位`(`3d` = 72 小时、`1w` = 7 天、`1m` = 上海日历月 +1;`never` = 不自动归档)。 +- **判定范围**:只看**顶层任务**(`parentId IS NULL`)且 `status = 'done'` / `doneAt` 非空 / 未归档。子任务不单独判定。 +- **整树归档**:顶层任务到期后,该任务与其全部子任务一起置 `archived/archivedAt/archiveSource='auto'`,不会出现父任务归档而子任务仍显示的情况。 - **执行时机(双保险)**: - 1. **读时惰性**:加载项目任务时对该项目先执行一轮到期归档(保证用户看到的数据与判定一致,刚到期的任务当场消失); - 2. **每小时 cron 兜底**:扫描全部 `autoArchive != 'never'` 项目的到期 done 任务批量归档(`archiveSource='auto'`)。 -- **幂等**:`archived = false` 且 `status = 'done'` 且 `doneAt` 非空才进入判定;重复执行无副作用。 -- **批量写**:cron 用 `updateMany`(按到期集合),读时惰性用单项目范围 `updateMany`。 + 1. **读时惰性**:加载项目任务时对该项目先执行一轮到期归档; + 2. **每小时 cron 兜底**:扫描全部项目的到期顶层任务批量归档,并修复历史父子归档状态不一致。 +- **幂等**:重复执行无副作用;批量写使用 `updateMany`。 ### 15.4 前端 | 落点 | 说明 | | --- | --- | -| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 | -| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 | +| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);子任务上点归档会提升到根父任务,整棵树一起处理 | +| 项目管理 | 「归档设置」分区:完成后自动归档四档(3 天后/一周后/一月后/永不),保存即生效 | | 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(**关键词 / 看板列 / 归档时段**;时段含「本周 / 本月 / 本季度」快捷项与自定义区间;条件编辑后点「筛选」统一提交,另有「重置」;**不提供类型过滤**)+ 已应用条件标签(可单独撤销)与命中条数 + 分页「加载更多」+ 行内「恢复 / 删除」 | | 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 | @@ -367,9 +358,10 @@ | 场景 | 处理 | | --- | --- | -| 归档父任务时新增/恢复子任务 | 子任务随父判定:父 archived 则子不单独出现在主列表(查询按父排除) | +| 子任务单独归档/恢复 | 不允许单独变化:接口与前端都会提升到根父任务,整棵树一起归档或恢复 | +| 历史数据父子不一致 | 服务端归档扫描时执行 `normalizeArchiveTree`,子任务归档态强制跟随父任务 | | 自动归档期间用户正在编辑该任务 | 详情弹层保存正常(归档不锁写);保存后任务仍在归档集 | -| 恢复后任务再次到期隐藏 | 重新进入自动归档倒计时(`doneAt` 未变则可能立即再次到期,属预期) | +| 恢复后任务再次到期 | 重新进入自动归档倒计时(`doneAt` 未变则可能立即再次到期,属预期) | | 「永不」档位 | 不做任何自动归档;手动归档不受影响 | | 拷贝任务 | 拷贝件不继承归档态(`copyTask` 不复制 archived 三字段) | @@ -382,7 +374,7 @@ | 视图体系 | T-VW-01 ~ 10 | 10 | §1 | | 看板列管理 | T-COL-01 ~ 10 | 10 | §2 | | 列-状态绑定 | T-BD-01 ~ 16 | 16 | §3 | -| 已完成显示控制 | T-FL-01 ~ 15 | 15 | §4 | +| 已完成显示控制(已移除) | T-FL-01 ~ 15 | 15(废止) | §4 | | 任务字段体系 | T-TK-01 ~ 13 | 13 | §5 | | 负责人 & 成员 | T-AS-01 ~ 07 | 7 | §6 | | 子任务体系 | T-ST-01 ~ 15 | 15 | §7 | @@ -396,4 +388,4 @@ | 任务归档 | T-ARC-01 ~ 08 | 8 | §15 | | **合计** | — | **157** | — | -> 需求文档共 **157 条**需求点,本文逐条给出设计落点,覆盖率 **157/157**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11`、`T-ARC-*` 为评审后新增,编号不连续,非笔误。) +> 需求文档共 **157 条**需求点(其中 `T-FL-01~15` 已于 2026-11-10 废止,不再有隐藏中间态),本文逐条给出设计落点。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11`、`T-ARC-*` 为评审后新增,编号不连续,非笔误。) diff --git a/docs/V0.2/任务模块/任务模块数据库设计文档.md b/docs/V0.2/任务模块/任务模块数据库设计文档.md index 9c28732..34ea078 100644 --- a/docs/V0.2/任务模块/任务模块数据库设计文档.md +++ b/docs/V0.2/任务模块/任务模块数据库设计文档.md @@ -20,7 +20,7 @@ | --- | --- | --- | | `Project` | 项目(隔离与权限单位) | 项目域 | | `ProjectMember` | 项目成员(权限关系) | 项目域 | -| `BoardColumn` | 看板列(名称 / 顺序 / 状态绑定 / 保留选项) | 看板域 | +| `BoardColumn` | 看板列(名称 / 顺序 / 状态绑定;`retention` 字段已废弃) | 看板域 | | `Task` | 任务与子任务(自关联,一层) | 任务域 | | `TaskMember` | 任务成员(多人标记) | 任务域 | | `TaskTag` | 任务标签 | 任务域 | @@ -29,7 +29,7 @@ 1. **列是实体,不是枚举。** 看板列允许增删改与排序(T-COL),若仍用 `plan/doing/action/done` 枚举,则"改名"无从表达、"排序"只能堆在 JSON 里、"删除列"会留下悬空字符串。因此独立建 `BoardColumn` 表,`Task` 用 `columnId` 外键引用。 2. **状态与列双落库,互不推导。** `Task.status`(三值)与 `Task.columnId` 是两个正交维度;未绑定的列(默认 `Action`)必须允许二者不一致(T-BD-08、T-BD-16),任何"由状态反推列"或"由列反推状态"的写法都会丢失信息。联动只在写接口中按项目配置即时裁决。 -3. **配置不下沉到任务。** 绑定与保留选项只存在 `BoardColumn` 上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。 +3. **配置不下沉到任务。** 列-状态绑定只存在 `BoardColumn` 上,自动归档档位只存在 `Project` 上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。 4. **按项目隔离。** 所有业务表都可通过 `projectId` 归集,权限校验统一查 `ProjectMember`。 5. **软删除优先。** 项目用 `archived`;任务删除为级联物理删除(T-ST-11),但删除前有二次确认。 6. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移。 @@ -131,7 +131,7 @@ ### 3.3 `BoardColumn` — 看板列 -**本模块新增的核心实体**,承载三件事:显示顺序、状态绑定、已完成保留选项。 +**本模块新增的核心实体**,承载两件事:显示顺序、状态绑定。`retention` 字段仅为兼容旧数据保留,已不参与业务判定。 | 字段 | 类型 | 空 | 默认 | 说明 | | --- | --- | --- | --- | --- | @@ -140,7 +140,7 @@ | `name` | `VARCHAR(20)` | 否 | — | 列名,项目内唯一(T-COL-07) | | `sortOrder` | `INT` | 否 | `0` | 显示顺序;**决定自动归位取"第一个匹配列"**(T-BD-15) | | `boundStatus` | `VARCHAR(8)` | **是** | `NULL` | `NULL`=不绑定;或 `todo`/`doing`/`done`(T-BD-02) | -| `retention` | `VARCHAR(8)` | 否 | `'forever'` | `never`/`today`/`3d`/`week`/`month`/`forever`(T-FL-02) | +| `retention` | `VARCHAR(8)` | 否 | `'forever'` | **已废弃**:仅为兼容旧数据保留,不再参与显示/归档判定 | | `createdAt` | `DATETIME(3)` | 否 | `now()` | — | | `updatedAt` | `DATETIME(3)` | 否 | — | — | @@ -153,14 +153,14 @@ **默认数据**(新建项目时由应用层在事务内写入,T-COL-01、T-BD-09): -| sortOrder | name | boundStatus | retention | +| sortOrder | name | boundStatus | retention(废弃) | | --- | --- | --- | --- | | 0 | `Plan` | `todo` | `forever` | | 1 | `Doing` | `doing` | `forever` | | 2 | `Action` | `NULL` | `forever` | | 3 | `Done` | `done` | `forever` | -> **为什么保留选项默认为 `forever` 而不是 `never`**:默认应"看得见",避免用户误以为数据丢失;隐藏是主动行为。 +> **保留字段说明**:`retention` 默认仍为 `forever` 仅为兼容旧库;2026-11-10 起不再按列隐藏已完成任务,归档由 `Project.autoArchive` 控制。 **约束与校验** @@ -170,7 +170,7 @@ | 至少保留 1 列(T-EX-17) | 应用层:删除前 count > 1 | | 删列必须指定迁移目标(T-EX-16) | 应用层:接口必须带 `moveToColumnId` | | `boundStatus` 取值合法 | 应用层白名单校验 | -| `retention` 取值合法 | 应用层白名单校验 | +| `retention` 字段 | 已废弃,不再校验/使用 | ### 3.4 `Task` — 任务 / 子任务 @@ -185,7 +185,7 @@ | `kind` | `VARCHAR(20)` | **是** | `NULL` | 任务类型标签,取值见 `Project.taskKinds` | | `markDate` | `DATETIME(3)` | **是** | `NULL` | 时间标记,完整日期存储、展示 `MM-dd`(T-TK-15;迁移 20261025120000) | | `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) | -| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) | +| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**自动归档判定依据** | | `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) | | `creatorId` | `INT` | 否 | — | 创建人;拷贝时为操作者(T-CP-04) | | `extras` | `JSON` | 否 | `[]` | 扩展信息卡片 `[{label,value,url?}]`(T-TK-04/05) | @@ -208,7 +208,7 @@ | `idx_task_project_column` | `(projectId, columnId)` | — | 看板按列查询 | | `idx_task_project_status` | `(projectId, status)` | — | 列表/统计 | | `idx_task_parent` | `(parentId)` | — | 取子任务 | -| `idx_task_done_at` | `(projectId, doneAt)` | — | 保留窗口计算 | +| `idx_task_done_at` | `(projectId, doneAt)` | — | 自动归档到期计算 | | `idx_task_project_archived` | `(projectId, archived, archivedAt)` | — | 已归档查询(T-ARC-04) | | `idx_task_project_status_arch` | `(projectId, status, archived)` | — | 主列表/看板排除归档(T-ARC-03) | @@ -292,7 +292,7 @@ model BoardColumn { name String @db.VarChar(20) sortOrder Int @default(0) boundStatus String? @db.VarChar(8) // null | todo | doing | done - retention String @default("forever") @db.VarChar(8) + retention String @default("forever") @db.VarChar(8) // 已废弃: 兼容旧数据 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -384,7 +384,7 @@ model User { | 项目详情 + 列 | `SELECT * FROM BoardColumn WHERE projectId=? ORDER BY sortOrder` | | 看板任务(含子任务) | 顶层:`WHERE projectId=? AND parentId IS NULL`;子任务:`WHERE parentId IN (...)` 一次取回后在内存分组(避免 N+1) | | 权限校验 | `SELECT 1 FROM ProjectMember WHERE projectId=? AND userId=?` | -| 保留窗口过滤 | 服务端可选:`status <> 'done' OR (doneAt >= ?)`;前端另有一份同口径实现 | +| 自动归档到期 | 顶层任务:`parentId IS NULL AND archived=0 AND status='done' AND doneAt <= now - autoArchive`;到期后整棵树一起归档 | | 自动归位选列 | 内存中对已加载的 `columns`(按 sortOrder)`find(c => c.boundStatus === status)` | | 已归档查询(T-ARC-04) | `WHERE projectId=? AND archived=1 [AND kind=?] [AND columnId=?] [AND archivedAt BETWEEN ? AND ?] [AND title LIKE ?] ORDER BY archivedAt DESC, id DESC LIMIT ?+1`;多取 1 条判定 `nextCursor` | diff --git a/docs/V0.2/任务模块/任务模块界面设计.html b/docs/V0.2/任务模块/任务模块界面设计.html index 5f4d15d..704b3ff 100644 --- a/docs/V0.2/任务模块/任务模块界面设计.html +++ b/docs/V0.2/任务模块/任务模块界面设计.html @@ -257,13 +257,19 @@

iBoard V0.2 界面设计 · 任务模块

-

轻量任务 / 项目管理 —— 列表与看板双视图 · 列-状态绑定 · 按列保留选项 · 一级子任务

+

轻量任务 / 项目管理 —— 列表与看板双视图 · 列-状态绑定 · 自动归档 · 一级子任务

版本 V0.2视口 420 × 820移动端优先 风格沿用《界面设计.html》需求编号 T-*
+
+ 2026-11-10 更新:取消「隐藏」中间态,任务只有「显示」与「归档」两种状态。 + 本页中涉及「按列保留选项 / 当场消失」的演示仅作历史参考,不再作为实现依据; + 自动归档见需求与设计文档 §3.15。 +
+

1 设计规范 沿用主文档 token,仅新增任务模块语义色

任务模块不引入新主色,全部复用 V0.2 既有色板;只在「任务状态」「列绑定」两处新增语义映射。

diff --git a/docs/V0.2/任务模块/任务模块设计文档-框架设计.md b/docs/V0.2/任务模块/任务模块设计文档-框架设计.md index 543e3aa..a872b3d 100644 --- a/docs/V0.2/任务模块/任务模块设计文档-框架设计.md +++ b/docs/V0.2/任务模块/任务模块设计文档-框架设计.md @@ -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) | --- diff --git a/docs/V0.2/任务模块/任务模块需求文档.md b/docs/V0.2/任务模块/任务模块需求文档.md index 126d0a5..1f650d3 100644 --- a/docs/V0.2/任务模块/任务模块需求文档.md +++ b/docs/V0.2/任务模块/任务模块需求文档.md @@ -10,7 +10,7 @@ ### 1.1 一句话描述 -在 iBoard 现有 APP 内新增一个**轻量级的项目与任务管理模块**:界面交互参考 Todoist,以**项目**为隔离单位组织任务,提供**列表 / 看板**双视图,并允许**每个项目独立配置看板列与任务状态的对应关系、以及每列已完成任务的保留选项**,用最小认知成本覆盖「个人琐事记录」与「多人协作推进」两类场景。 +在 iBoard 现有 APP 内新增一个**轻量级的项目与任务管理模块**:界面交互参考 Todoist,以**项目**为隔离单位组织任务,提供**列表 / 看板**双视图,并允许**每个项目独立配置看板列与任务状态的对应关系、以及已完成任务的自动归档档位**,用最小认知成本覆盖「个人琐事记录」与「多人协作推进」两类场景。 **「轻量」的含义**:不做需求管理、工时、甘特图、工作流引擎、审批流;字段克制、结构最多两层(任务 + 一级子任务)、权限不做细分角色。目标是用最少的配置让用户"打开就能记、记完就能推进"。 @@ -31,7 +31,7 @@ | **任务状态(Status)** | 任务的处理阶段,取值为 `未开始` / `执行中` / `完成`。**与看板列是两个独立概念**,二者的对应关系由项目配置决定 | | **列-状态绑定(Column Status Binding)** | 项目级配置:**每一列独立**选择"是否绑定任务状态"以及"绑定到哪个状态"。绑定后列与状态双向联动;不绑定时二者完全独立 | | **已完成但未绑定状态** | 任务状态为「完成」、但其所在看板列未绑定到「完成」的情形——此时列保持不变 | -| **看板列保留选项(Column Retention)** | 项目级配置:**每一列独立**设置已完成任务的保留时长,取值为「永不显示」/「当天」/「3 天」/「本周」/「本月」/「永久显示」六档,用于控制该列中已完成任务的可见性 | +| **自动归档档位(Auto Archive)** | 项目级配置:任务**完成后**经过 `3 天 / 一周 / 一月 / 永不` 即自动归档;归档后从主列表/看板移入已归档列表。子任务完全跟随父任务一起显示或一起归档 | | **视图记忆** | 设备端按项目独立保存"上次选择的视图(列表 / 看板)",进入项目自动恢复 | | **上下文** | 用户当前所处的视图位置(某个项目 / 某个父任务的子任务列表 / 某个列表分区),用于解释"本次修改"相对哪些任务生效 | @@ -46,7 +46,7 @@ - **默认**:`Plan` 绑定「未开始」、`Doing` 绑定「执行中」、`Done` 绑定「完成」、`Action` **不绑定**。 - 配置是**项目级**的,项目内任意成员均可修改,不同项目互不影响。 - **子任务不受列-状态绑定约束**,其状态可与父任务不一致。 -3. **已完成任务显示控制**:**按看板列独立配置保留选项**——`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示` 六选一;超出保留选项的已完成任务在该列中自动隐藏。列表视图按其所属列的配置执行同样的显示控制。 +3. **已完成任务归档**:任务只有「显示」与「归档」两种状态,不再有「隐藏」中间态。已完成任务默认继续显示;项目可配置「完成后 3 天 / 一周 / 一月 / 永不」自动归档,到期后任务整棵树移入已归档列表。子任务与父任务完全同步。 4. **任务字段体系**:基础字段(标题必填,负责人默认本人)+ 内置可折叠**扩展信息卡片**(默认收起、全非必填);负责人(单人)与任务成员(多人)均为信息标记。 5. **负责人 & 成员展示规则**:负责人大字号突出、成员小字号弱化并置于下方;二者只能从**当前项目成员**中选择;不改变项目统一读写权限。 6. **子任务体系**:仅一级、必须同项目、父子完成状态互不联动;列表视图支持缩进与折叠展开;**看板卡片内以简洁形式嵌套展示子任务并可直接勾选**,完整详情在卡片弹窗内编辑;支持**长按拖拽把任务降为另一个任务的一级子任务**。 @@ -73,8 +73,8 @@ **场景 D · 跨项目复用** 把「APP 改版」里的某任务跨项目拷贝到「APP 二期」→ 生成一条**全新任务**,字段、扩展信息、子任务、负责人、成员、状态、看板列、标签全部复制,创建人记为执行拷贝的用户;此后两任务互不影响。 -**场景 E · 按列控制已完成任务显示** -某项目的 `Done` 列已完成任务过多影响阅读 → 把该列保留选项设为「当天」→ 更早的已完成任务自动隐藏;同时把 `Action` 列设为「永不显示」,该列不再展示已完成任务;`Plan` 列保持「永久显示」不受影响。列表视图同样按各任务所属列的配置隐藏它们,与看板一致。切到另一个项目时,该项目各列配置保持自己的设置(互不干扰)。 +**场景 E · 完成后自动归档** +某项目的已完成任务长期堆积影响阅读 → 在项目管理中把「完成后自动归档」设为「一周后」→ 任务完成满一周后自动移入已归档列表;子任务随父任务一起归档。若设为「永不」则不会自动归档,只能手动归档。切到另一个项目时,该项目保持自己的归档档位(互不干扰)。 **场景 F · 拖拽调整层级** 列表视图中长按任务卡片,向上拖动到另一个任务上并停留 → 该任务成为目标任务的**一级子任务**(缩进显示)。若目标任务本身已是子任务,或被拖任务已带子任务,则拒绝并给出提示(仅支持一级)。降为子任务后,它原有的看板列与状态保持不变。 @@ -86,7 +86,7 @@ 底部导航顺序为「打卡 → **任务** → 宏观 → 监控 → 我」→ 点击「任务」进入首屏:**上部**是最近使用的 3 个项目卡片 + 「更多项目」入口(点卡片即切换当前项目,长按卡片可调整顺序);**下部**直接是当前项目(上次访问的,记录在设备端;无记录则回到个人默认项目)的任务管理页,无需先进项目列表。点「更多项目」才进入完整的项目列表页。 **场景 I · 项目管理** -在项目页头部点「项目管理」→ 弹出统一管理页,内含三个分区:**成员**(查看 / 添加 / 移除)、**列设置**(列管理 / 状态绑定 / 保留选项)、**项目信息**(改名 / 归档 / 删除 / 退出,个人默认项目不出现删除与退出)。 +在项目页头部点「项目管理」→ 弹出统一管理页,内含三个分区:**成员**(查看 / 添加 / 移除)、**列设置**(列管理 / 状态绑定)、**项目信息**(改名 / 归档 / 删除 / 退出,个人默认项目不出现删除与退出)。 ### 1.5 不在本期范围(明确排除) @@ -114,7 +114,7 @@ | **看板列(column)** | 项目级可配置的列实体,默认 `Plan` / `Doing` / `Action` / `Done` 四列;可增删改、可排序,数量不固定 | | **列-状态绑定(columnBindings)** | 项目级配置:每列独立声明「是否绑定」及「绑定到哪个状态」;默认 plan→todo、doing→doing、done→done、action→不绑定 | | **已完成但未绑定状态** | 状态为 `done` 但所在列未绑定到 `done`;此时拖拽/勾选只改状态不改列(见 T-EX-04) | -| **看板列保留选项(columnRetention)** | 项目级配置:每列独立设置已完成任务的保留时长,`never` 永不显示 / `today` 当天 / `3d` 3 天 / `week` 本周 / `month` 本月 / `forever` 永久显示 | +| **自动归档档位(autoArchive)** | 项目级配置:任务完成后经过 `3d` / `1w` / `1m` / `never` 自动归档;归档后从主列表/看板移入已归档列表 | | **视图记忆(viewMode)** | `list` / `board`,**按项目独立记忆、保存在设备端** | | **扩展信息卡片** | 任务的折叠信息区,存任意 `[{label, value, url?}]` 条目,默认收起、全部非必填 | | **一级子任务** | `parentId` 非空的任务;其父任务必须为顶层任务,且同项目 | @@ -132,7 +132,7 @@ | `VW` | 视图体系 | 第 1 条 | | `COL` | 看板列管理 | 第 2 条 | | `BD` | 看板与列-状态绑定 | 第 2 条 | -| `FL` | 已完成任务显示控制(按列保留选项) | 第 3 条 | +| `FL` | 已完成任务显示控制(已移除,任务只有显示/归档两态) | 第 3 条 | | `TK` | 任务字段体系(含扩展信息卡片) | 第 4 条 | | `AS` | 负责人与成员 | 第 5 条 | | `ST` | 子任务体系 | 第 6 条 | @@ -165,7 +165,7 @@ ### 3.2 看板列管理(T-COL) -> 看板列是**项目级可配置实体**:默认 4 列,允许增删改与排序。列与「状态绑定」「保留选项」是三条正交的配置。 +> 看板列是**项目级可配置实体**:默认 4 列,允许增删改与排序。列与「状态绑定」是两条正交的配置。 | 编号 | 需求描述 | 验收要点 | 优先级 | | --- | --- | --- | --- | @@ -201,27 +201,16 @@ | T-BD-12 | 绑定配置变更后,看板与列表**即时一致**,无需手动刷新 | 保存配置后,看板列徽标与完成态立即反映新规则 | P1 | | T-BD-13 | **子任务不受看板列-状态绑定影响**:子任务状态不因所在列而改写,也不触发「状态 → 移入列」 | 父任务在绑定「完成」的列,其子任务状态仍可为「执行中」且不被改写;修改子任务状态不使其离开当前列 | P0 | -### 3.4 已完成任务显示控制(T-FL) +### 3.4 已完成任务显示控制(T-FL,已移除) -> 本节的「保留选项」**以看板列为单位**配置,取代原先的「项目整体 5 档过滤」。 - -| 编号 | 需求描述 | 验收要点 | 优先级 | -| --- | --- | --- | --- | -| T-FL-01 | **每一列独立**配置已完成任务的保留选项,配置项属于项目级 | 各列(含新增列)都有独立配置入口,修改一列不影响其他列 | P0 | -| T-FL-02 | 保留选项为**固定 6 档**:`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示` | 下拉仅此六项,不提供自由输入天数 | P0 | -| T-FL-03 | 「永久显示」:该列已完成任务**始终展示**,不受时间限制 | 设为永久显示后,历史任意时间完成的任务均可见 | P0 | -| T-FL-04 | 「永不显示」:该列已完成任务**始终不展示** | 设为永不显示后,该列无已完成任务卡片 | P0 | -| T-FL-05 | 「当天」:仅展示**完成于今天(自然日)**的已完成任务 | 今天完成的可见;昨天完成的次日即不显示 | P0 | -| T-FL-06 | 「3 天」:展示**完成于最近 3 个自然日(含今天)**的已完成任务 | 今天、昨天、前天完成可见,更早不显示 | P0 | -| T-FL-07 | 「本周」:展示**完成于本周(周一 00:00 起,至今天)**的已完成任务 | 周一起算;下周一进来看,上周完成的任务不再显示 | P0 | -| T-FL-08 | 「本月」:展示**完成于本月(当月 1 日起,至今天)**的已完成任务 | 当月 1 日起算;进入次月后,上月完成的任务不再显示 | P0 | -| T-FL-09 | 超出所选档位范围的已完成任务**自动隐藏**(无需手动操作) | 某列设为「当天」,次日进入该列,昨日完成的任务自动消失 | P0 | -| T-FL-10 | 保留判定以「该任务的**完成时间**」为准,与任务当前所在列无关 | 任务完成后再移动列,可见性仍按其完成时间与新列档位判定 | P0 | -| T-FL-11 | **列表视图与看板视图行为一致**:列表只是不按看板列分组展示,任务归属哪一列仍是哪一列,显示控制同样按**其所属列**的配置执行 | 看板中某列已隐藏的已完成任务,在列表视图中同样不出现;两视图可见条数一致 | P0 | -| T-FL-12 | 该配置仅影响**已完成任务**;未完成任务始终展示,不受保留选项影响 | 未完成任务在任何保留选项配置下均可见 | P0 | -| T-FL-13 | 显示控制为**展示层过滤**,不改变任务数据与状态 | 修改保留选项后,任务数据、状态、计数口径不发生变化 | P0 | -| T-FL-14 | 过滤后每列的计数与展示条数一致 | 列头显示 N 条时,该列实际可见卡片同为 N 条 | P1 | -| T-FL-15 | 保留选项配置在**项目内所有成员间共享**(属项目配置,非个人偏好) | 成员 A 修改后,成员 B 进入即按新配置显示 | P0 | +> **2026-11-10 变更**:取消「隐藏」中间态,任务只有**显示**与**归档**两种状态。 +> +> - 不再按看板列的保留窗口隐藏已完成任务;原 T-FL-01 ~ 15 全部废止; +> - `BoardColumn.retention` 字段仅为兼容旧数据保留,不再参与显示或归档判定; +> - 已完成任务在手动归档或自动归档到期前始终显示; +> - 子任务完全跟随父任务:要么一起显示,要么一起归档。 +> +> 自动归档规则见 §3.15。 ### 3.5 任务字段体系(T-TK) @@ -299,7 +288,7 @@ | 编号 | 需求描述 | 验收要点 | 优先级 | | --- | --- | --- | --- | | T-PM-01 | 权限**以项目为隔离单位**:用户只能看到自己是成员的项目及其任务 | 非成员访问某项目接口返回 403 / 404,列表不出现该项目 | P0 | -| T-PM-02 | 项目内**所有成员权限均等**,具备**全读写**能力 | 任意成员均可新增 / 编辑 / 删除任务、管理成员、修改列-状态绑定与保留选项配置 | P0 | +| T-PM-02 | 项目内**所有成员权限均等**,具备**全读写**能力 | 任意成员均可新增 / 编辑 / 删除任务、管理成员、修改列-状态绑定与自动归档配置 | P0 | | T-PM-03 | 预留**权限分级入口**,本期**不实现**细分角色 | 数据结构或接口层保留扩展位;界面无角色分配功能 | P1 | | T-PM-04 | 项目内**所有成员都可以归档项目** | 任意成员执行归档均成功 | P0 | | T-PM-05 | 复用现有项目框架的登录态与鉴权,未登录访问被拒 | 无有效登录态访问返回 401 | P0 | @@ -340,9 +329,9 @@ | 编号 | 需求描述 | 验收要点 | 优先级 | | --- | --- | --- | --- | -| T-DT-01 | 新增 Prisma 模型:`Project`(含 `isPersonal`、列-状态绑定配置、列保留选项配置、`archived` 等)、`ProjectMember` | 字段见附录 A | P0 | +| T-DT-01 | 新增 Prisma 模型:`Project`(含 `isPersonal`、列-状态绑定配置、自动归档档位、`archived` 等)、`ProjectMember` | 字段见附录 A | P0 | | T-DT-02 | 列-状态绑定配置**按列存储**,每列独立保存「是否绑定 + 绑定状态」,支持多列指向同一状态 | 任意数量列的配置可独立读写;两列绑定同一状态可正确落库 | P0 | -| T-DT-03 | 列保留选项配置**按列存储**,取值覆盖六档:`never` / `today` / `3d` / `week` / `month` / `forever` | 三档取值均可落库并正确读回 | P0 | +| T-DT-03 | 自动归档档位**按项目存储**,取值 `3d` / `1w` / `1m` / `never` | 四档取值均可落库并正确读回 | P0 | | T-DT-04 | 新增 Prisma 模型:`Task`(含 `parentId` 自关联、`assigneeId`、`status`、看板列、扩展信息卡片、`sortOrder`) | 字段见附录 A | P0 | | T-DT-05 | 新增 Prisma 模型:`TaskMember`(任务成员关联)、扩展信息卡片结构、标签结构 | 字段见附录 A | P0 | | T-DT-06 | 视图记忆按**设备端**存储,不要求落库 | 换设备登录不要求视图一致 | P0 | @@ -373,7 +362,7 @@ | T-EX-07 | 删除父任务 | 级联删除其子任务,确认弹窗提示「将一并删除 N 条子任务」 | P0 | | T-EX-08 | 跨项目拷贝时负责人 / 成员不属于目标项目 | 仅保留属于目标项目成员者,其余丢弃并在结果中提示 | P1 | | T-EX-09 | 个人默认项目尝试添加成员 / 尝试删除 | 接口返回明确错误,前端不提供入口 | P0 | -| T-EX-10 | 某列设为「当天」等档位且该列无符合条件的已完成任务 | 展示该列空态,不报错 | P0 | +| T-EX-10 | 已归档列表无符合条件的任务 | 展示空态,不报错 | P0 | | T-EX-11 | 项目成员清空后的项目 | 静默保留,前端不报错、不特殊提示 | P0 | | T-EX-12 | 子任务所属项目与父任务不一致(绕过前端直接调用) | 后端拒绝并返回错误 | P0 | | T-EX-13 | 拖拽成为子任务时,子任务的**看板列与状态**如何取值 | 保持自身原值不变,仅改变父子归属 | P1 | @@ -382,21 +371,20 @@ | T-EX-16 | 删除仍有任务的列 | 二次确认后,该列任务迁移至**相邻列**(或由用户指定目标列),不产生无归属任务 | P0 | | T-EX-17 | 删除唯一剩余的列 | **拒绝操作**并提示「至少保留一列」 | P0 | | T-EX-19 | 新增列时已达列数上限 | 拒绝新增并提示已达上限,不产生空列 | P1 | -| T-EX-18 | 已完成任务跨周期(周一零点 / 月初零点)后应隐藏 | **当场消失**并带移除动画,其余任务平滑重排,不留空白 | P1 | +| T-EX-18 | 已完成任务跨周期不再隐藏(原保留窗口需求已移除) | 已完成任务保持显示,直到手动归档或自动归档到期 | P1 | ### 3.15 任务归档(T-ARC) -> 与「已完成显示控制(T-FL)」的关系:T-FL 决定任务**看不看得见**(可逆的展示过滤), -> 归档决定任务**在不在主工作集**(数据层面的收纳)。隐藏是自动归档的前置条件,归档移出主列表, -> 可通过「已归档查询」恢复。 +> 任务只有「显示」与「归档」两种状态,不再存在「隐藏」中间态。 +> 自动归档倒计时起点是任务的**完成时刻** `doneAt`;到期后整棵任务树一起归档,可通过「已归档查询」恢复。 | 编号 | 需求描述 | 验收要点 | 优先级 | | --- | --- | --- | --- | -| T-ARC-01 | **手动归档**:任务 `⋯` 菜单增加「归档」;任意未归档任务(不限状态)均可归档;父任务归档时**连带全部子任务**一并归档;操作需二次确认(提示连带条数) | 归档后任务从列表/看板消失;子任务同步消失;确认弹窗条数正确 | P0 | -| T-ARC-02 | **自动归档**:项目级配置「**隐藏的任务**多久归档」,可选 `3 天后 / 一周后 / 一月后 / 永不`(默认永不);已隐藏(T-FL 保留窗口过期)的 done 任务到期后由系统自动归档 | 配置保存后生效;隐藏任务到期被自动归档;「永不」时永不自动归档;未隐藏的任务不受影响 | P1 | +| T-ARC-01 | **手动归档**:任务 `⋯` 菜单增加「归档」;任意未归档任务(不限状态)均可归档;父任务归档时**连带全部子任务**一并归档;在子任务上归档会提升到根父任务;操作需二次确认(提示连带条数) | 归档后任务从列表/看板消失;子任务同步消失;确认弹窗条数正确 | P0 | +| T-ARC-02 | **自动归档**:项目级配置「**完成后**多久归档」,可选 `3 天后 / 一周后 / 一月后 / 永不`(默认永不);顶层任务完成满该时长后,任务及其全部子任务由系统自动归档 | 配置保存后生效;到期任务整树自动归档;「永不」时永不自动归档;未完成的任务不受影响 | P1 | | T-ARC-03 | **归档排除**:已归档任务从任务列表、看板、列计数、项目任务数统计中排除 | 归档后各视图与计数立即减少;恢复后回到原位 | P0 | | T-ARC-04 | **已归档查询**:独立入口查看本项目已归档任务;支持关键词(标题)、任务类型、看板列、归档时间范围过滤;**游标分页**支撑大量数据(千级) | 过滤条件生效;分页可连续加载到底;千级数据下查询流畅 | P0 | -| T-ARC-05 | **恢复**:已归档任务可取消归档,回到原看板列(列被删除的场景因外键 `ON DELETE RESTRICT` 不存在),状态保持不变;父任务恢复连带子任务 | 恢复后任务与子任务重新出现在列表/看板原列 | P0 | +| T-ARC-05 | **恢复**:已归档任务可取消归档,回到原看板列(列被删除的场景因外键 `ON DELETE RESTRICT` 不存在),状态保持不变;父任务恢复连带子任务,在子任务上恢复会提升到根父任务 | 恢复后任务与子任务重新出现在列表/看板原列 | P0 | | T-ARC-06 | **彻底删除**:已归档任务可物理删除(含子任务),二次确认 | 删除后不再出现在已归档查询 | P1 | | T-ARC-07 | **配置入口**:项目管理新增「归档设置」分区,展示与修改自动归档档位 | 修改后即时保存并对全项目生效 | P1 | | T-ARC-08 | **权限口径**:项目成员均可归档/恢复/删除归档任务(权限均等口径同 T-PM-04) | 与其他写操作权限一致,无额外限制 | P1 | @@ -405,8 +393,8 @@ ## 4. 优先级汇总(本期交付范围) -- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-FL-01~13、15;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17;T-ARC-01、03、04、05。 -- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-FL-14;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19;T-ARC-02、06、07、08。 +- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17;T-ARC-01、03、04、05。 +- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19;T-ARC-02、06、07、08。 --- @@ -417,34 +405,34 @@ | # | 问题 | 决策 | | --- | --- | --- | | 1 | 列-状态绑定是单列还是多列? | **每列独立配置**,允许**多列绑定同一状态**;默认 plan→未开始、doing→执行中、done→完成、action→不绑定(T-BD-02、03、09) | -| 2 | 保留档位的时间边界按自然日还是滚动小时? | **自然日**(Asia/Shanghai,含今天 00:00 起);本周按周一起算、本月按 1 日起算 | -| 3 | 视图记忆 / 显示配置存在设备端还是服务端? | 视图记忆**设备端**、不跨设备;保留选项**项目级共享**(T-VW-06、T-FL-12、T-DT-06) | +| 2 | 自动归档档位的时间边界? | `3d` / `1w` 按完成时刻起算的绝对时长(72 小时 / 7 天);`1m` 按上海日历月 +1 | +| 3 | 视图记忆 / 自动归档配置存在设备端还是服务端? | 视图记忆**设备端**、不跨设备;自动归档档位**项目级共享**(T-VW-06、T-DT-06) | | 4 | 删除父任务时子任务如何处理? | **级联删除**,弹窗提示条数(T-EX-07) | -| 5 | 已完成任务在看板中的呈现? | 由**该列的保留选项**控制显示与隐藏(T-FL-01~10) | +| 5 | 已完成任务在看板中的呈现? | **保持显示**,直到手动归档或自动归档到期(不再有隐藏中间态) | | 6 | 看板拖拽的移动端交互? | **长按拖拽** + 「移动到」兜底;**允许拖拽降为子任务**(T-UI-07、T-ST-12) | | 7 | 项目成员清空后是否需要本模块的管理入口? | **本期不承担**,交后台;前端静默(T-PJ-08) | | 8 | 扩展信息组的具体字段清单? | **不固定字段**,改为**任意扩展信息卡片**(T-TK-04、05) | -| 9 | 已完成显示策略的粒度? | 改为**按看板列独立的保留选项**(T-FL-01、02) | +| 9 | 已完成显示策略的粒度? | **已移除按列保留选项**;任务只有显示/归档两种状态,子任务跟随父任务 | | 10 | 是否单独要求 WebView / 登录态 / 返回键? | **不单列**,沿用现有项目框架(T-NF-04、T-PM-05) | | 11 | 同一状态被多列绑定,状态变化时进哪一列? | **按当前列顺序取第一个匹配**(T-BD-06、15、B-3) | | 12 | **从绑定列拖出**时状态是否回退? | **不设「拖出即回退」规则**。只看**目标列**:目标列绑定了状态就按目标列改,未绑定则**保留原状态**(T-BD-07、B-4) | -| 13 | 列表视图如何处理「按列」的显示控制? | 列表只是**不按列分组**,任务归属仍是原看板列,行为与看板**完全一致**(T-FL-11、B-6) | +| 13 | 列表视图与看板的显示是否一致? | 一致:两视图都不做隐藏过滤,任务只会在归档后从主工作集移除(T-ARC) | | 14 | 看板卡片如何展示子任务? | **在卡片内嵌套简洁展示**,可直接勾选;完整详情在卡片弹窗(T-ST-06、07) | | 15 | 子任务是否受列-状态绑定影响? | **不受**。子任务状态可与父任务不一致,拖入列不改写其状态(T-BD-13、T-ST-08、B-7) | | 16 | 父任务能否整体挂到别的任务下? | **不能**。含子任务的父任务不可被拖拽为子任务(T-ST-14) | -| 17 | 保留选项的取值范围? | 固定 **6 档**:`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示`,不做自由输入(T-FL-02、B-5) | +| 17 | 自动归档档位的取值范围? | 固定 **4 档**:`3 天后` / `一周后` / `一月后` / `永不`(T-ARC-02) | | 18 | 任务状态与所在列绑定不一致时怎么处理? | **触发自动归位**:查找绑定该状态的列并移入,按列序取第一个;**无匹配列则不移动**(T-BD-14~16、B-4b) | | 19 | 卡片内嵌套的子任务是否显示自身状态? | **显示小状态标记**,以区分与父任务不一致的状态(T-ST-09) | | 20 | 任务模块入口放在哪里? | 作为**顶级 Tab**,位置在**「打卡」之后**(T-PM-06) | | 21 | 5 个底部 Tab 是否影响既有布局与默认首页? | **暂无障碍**;默认首页**仍为打卡**(T-PM-06) | -| 22 | 任务状态为「完成」但所有列都未绑定「完成」时,列表视图按哪一列过滤? | **保留在当前列**,按该列保留选项过滤。**不存在「已完成统一用 Done 列」的强制规则**,那只是默认配置下的自然效果,用户可完全自定义(T-FL-11) | -| 23 | 「本周 / 本月」跨周跨月时是否要求实时隐藏? | **当场消失并带移除动画,其余任务平滑重排**;以刷新方式实现也可接受(T-EX-18) | +| 22 | 任务状态为「完成」但所有列都未绑定「完成」时如何显示? | **保持当前列并继续显示**,直到归档。**不存在「已完成统一用 Done 列」的强制规则** | +| 23 | 已完成任务跨周跨月是否要求实时隐藏? | **不再隐藏**;由自动归档档位在到期后整树移出主列表(T-ARC-02、T-EX-18) | | 24 | 「上次访问的项目」记录在哪?无记录时如何? | 记录在**设备端**;**无记录一律回个人默认项目**(T-VW-07、T-PM-07) | | 25 | 看板列是否固定 4 列? | **非固定**:默认 4 列,支持增删改与排序,数量可变,至少保留 1 列(T-COL-01~07) | | 26 | 是否允许删除最后一个列? | **不允许**。删除唯一剩余列时拒绝并提示,项目至少保留 1 列(T-COL-06、T-EX-17) | -| 27 | 新增列的默认设置? | 按**默认值**:保留选项 `永久显示`、不绑定状态,**不继承**相邻列设置(T-COL-02、B-18) | +| 27 | 新增列的默认设置? | 按**默认值**:不绑定状态,**不继承**相邻列设置;`retention` 字段已废弃,不再写入业务含义(T-COL-02、B-18) | | 28 | 看板列数量上限? | 暂定**上限 10 列**(`待确认`,见下) | | 29 | 任务首屏结构? | **上部** = 最近使用的 3 个项目卡片 + 「更多项目」入口;**下部** = 当前选中项目的任务管理视图,默认上次访问的项目(T-VW-08/09) | | 30 | 首屏「任务卡片可以排序」的指代? | 指**最近项目卡片**可长按拖动排序,结果存设备端;任务卡片的列内排序维持既有口径(T-VW-10) | @@ -490,7 +478,7 @@ model BoardColumn { name String // 列名, 项目内唯一 sortOrder Int @default(0) // 显示顺序, 决定自动归位时的"第一个匹配列" boundStatus String? // null = 不绑定; todo | doing | done - retention String @default("forever") // never | today | 3d | week | month | forever + retention String @default("forever") // 已废弃: 仅为兼容旧数据保留, 不参与显示/归档判定 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -573,7 +561,7 @@ model TaskTag { > 说明: > 1. `status` 与 `columnId` **两个字段都落库**(而非互相推导),因为未绑定的列需要二者彼此独立;联动仅在项目配置了绑定的列/状态之间发生,由服务端统一裁决。 -> 2. 保留选项的判定依赖「在该列的完成时间」,建议在任务上记录完成时间或按列迁移时间计算,具体口径在设计阶段确定。 +> 2. 自动归档判定依赖任务的**完成时间** `Task.doneAt`:顶层任务完成后按项目 `autoArchive` 档位到期,整棵树一起归档。 > 3. 视图记忆存设备端(localStorage),不落库。 --- @@ -587,9 +575,9 @@ model TaskTag { | B-3 | **多列绑定同一状态时的目标列** | 移入列时按**当前列顺序**取**第一个**绑定该状态的列(列顺序可被用户调整) | | B-4 | **列决定状态** | 拖动任务时只看**目标列**:目标列绑定了状态 ⇒ 状态 = 所绑定状态(含从任意列拖入);目标列未绑定 ⇒ 状态保持原值。**不存在「从某列拖出即回退」的规则** | | B-4b | **状态与列不一致时的自动归位** | 状态改变后,若当前所在列未绑定该状态 ⇒ 按列序取第一个绑定该状态的列并移入;**无匹配列则不移动**,状态照常改变 | -| B-5 | **列保留选项** | `columnRetention` 为「列 → 选项」映射,选项为固定 6 档:`never`(永不显示)/ `today`(当天)/ `3d`(3 天)/ `week`(本周)/ `month`(本月)/ `forever`(永久显示) | -| B-5b | **各档位口径** | 均按 Asia/Shanghai 自然日:`today` = 今天;`3d` = 含今天在内的最近 3 天;`week` = 本周一 00:00 起;`month` = 本月 1 日 00:00 起;判定依据为任务的**完成时间** | -| B-6 | **显示控制作用域** | 列表视图与看板视图**同样**按任务**当前所属列**的保留选项执行过滤;列表只是不按列分组,归属不变;未完成任务不受影响 | +| B-5 | **自动归档档位** | `Project.autoArchive` 为项目级配置,固定 4 档:`3d`(完成后 3 天)/ `1w`(一周后)/ `1m`(一月后)/ `never`(永不) | +| B-5b | **档位口径** | `3d` = 完成时刻 + 72 小时;`1w` = 完成时刻 + 7 天;`1m` = 完成时刻 + 1 个上海日历月;判定依据为顶层任务的**完成时间** `doneAt` | +| B-6 | **归档作用域** | 只有顶层任务参与自动归档判定;到期后该任务与全部子任务一起归档,列表与看板同步移出,未完成任务不参与 | | B-7 | **子任务与绑定无关** | 子任务状态**不受**看板列-状态绑定影响:拖入列不改写子任务状态,修改子任务状态也不触发「移入列」 | | B-8 | **子任务约束** | `parentId` 非空时:父任务 `parentId` 必须为空(禁止二级);`projectId` 必须与父任务一致;禁止指向自身或后代 | | B-9 | **拖拽降为子任务** | 长按拖动任务 A 到任务 B 上释放 ⇒ A.parentId = B.id;要求 B 为顶层任务且 A 本身无子任务,否则拒绝 | @@ -598,8 +586,8 @@ model TaskTag { | B-12 | **成员清空** | 移除最后一名成员时不删除 `Project`,仅删除 `ProjectMember` 记录;前端不特判 | | B-13 | **跨项目拷贝** | 新建任务(新 id)+ 复制全部字段、扩展卡片与子任务;`creatorId` = 操作者;负责人 / 成员按 T-EX-08 过滤;状态与列按 T-EX-14 沿用 | | B-14 | **权限校验** | 所有项目 / 任务接口均先校验「请求者为该项目成员」,非成员返回 403 | -| B-15 | **偏好存储** | 视图记忆与「上次访问的项目」存设备端;列定义、列-状态绑定、列保留选项属项目配置,存服务端、项目内共享 | -| B-16 | **看板列实体** | 列为 `BoardColumn`(name / sortOrder / boundStatus / retention);默认 4 列由新建项目时初始化;至少保留 1 列;列名项目内唯一 | +| B-15 | **偏好存储** | 视图记忆与「上次访问的项目」存设备端;列定义、列-状态绑定、自动归档档位属项目配置,存服务端、项目内共享 | +| B-16 | **看板列实体** | 列为 `BoardColumn`(name / sortOrder / boundStatus);默认 4 列由新建项目时初始化;至少保留 1 列;列名项目内唯一;`retention` 字段已废弃 | | B-17 | **删除列的迁移** | 删除列前需二次确认;列内任务迁移至指定列(默认相邻列),迁移后任务状态按目标列绑定重新裁决;**唯一剩余列不可删除** | -| B-18 | **新增列默认值** | 新增列取默认值:`retention = forever`、`boundStatus = null`,**不继承**相邻列配置;`sortOrder` 追加到末尾 | +| B-18 | **新增列默认值** | 新增列取默认值:`boundStatus = null`,**不继承**相邻列配置;`sortOrder` 追加到末尾;不再写入 `retention` 业务含义 | | B-19 | **列数上限** | 至少 1 列、至多 10 列(上限待确认) | diff --git a/server/mcp/tools/tasks.js b/server/mcp/tools/tasks.js index 2c258d4..d07f43f 100644 --- a/server/mcp/tools/tasks.js +++ b/server/mcp/tools/tasks.js @@ -17,7 +17,6 @@ import { tool, reqStr, parseMarkDate } from '../helpers.js' const MAX_NAME = 50 const MAX_COLUMN_NAME = 20 const STATUSES = ['todo', 'doing', 'done'] -const RETENTIONS = ['never', 'today', '3d', 'week', 'month', 'forever'] const TASK_INCLUDE = { column: true, @@ -294,16 +293,14 @@ function registerColumnTools(server, ctx) { projectId: z.coerce.number().int().positive().describe('项目 ID'), name: z.string().describe('列名, 最多 20 字'), boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态 todo/doing/done, null 表示不联动'), - retention: z.enum(RETENTIONS).optional().describe('完成保留选项, 默认 forever'), }, - }, async ({ projectId, name, boundStatus = null, retention = 'forever' }) => { + }, async ({ projectId, name, boundStatus = null }) => { const project = await requireProject(projectId, userId) const cleanName = reqStr(name, '列名', MAX_COLUMN_NAME) const cols = await loadColumns(project.id) if (cols.length >= MAX_COLUMNS) throw new Error(`最多 ${MAX_COLUMNS} 列`) if (cols.some((c) => c.name === cleanName)) throw new Error('列名已存在') if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法') - if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法') const row = await prisma.boardColumn.create({ data: { @@ -312,7 +309,6 @@ function registerColumnTools(server, ctx) { name: cleanName, sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0, boundStatus, - retention, }, }) return { item: row } @@ -320,14 +316,13 @@ function registerColumnTools(server, ctx) { tool(server, 'update_column', { title: '修改看板列', - description: '修改列名、绑定状态或完成保留选项。', + description: '修改列名或绑定状态。', inputSchema: { columnId: z.coerce.number().int().positive().describe('看板列 ID'), name: z.string().optional().describe('新列名, 最多 20 字'), boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态, null 表示取消绑定'), - retention: z.enum(RETENTIONS).optional().describe('完成保留选项'), }, - }, async ({ columnId, name, boundStatus, retention }) => { + }, async ({ columnId, name, boundStatus }) => { const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } }) if (!col) throw new Error('列不存在') const project = await requireProject(col.projectId, userId) @@ -345,10 +340,6 @@ function registerColumnTools(server, ctx) { if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法') data.boundStatus = boundStatus } - if (retention !== undefined) { - if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法') - data.retention = retention - } const row = await prisma.boardColumn.update({ where: { id: col.id }, data }) return { item: row } }) diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 019b15a..68166bc 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -6,7 +6,6 @@ import { loadTaskForUser, loadProjectForUser, loadColumns, loadMemberIds, } from '../task-domain/access.js' import { resolveTaskWrite } from '../task-domain/resolveStatus.js' -import { filterVisible } from '../task-domain/retention.js' import { copyTask } from '../task-domain/copyTask.js' import { runAutoArchiveForProject } from '../task-domain/archive.js' import { genUuid, withUuid, withTaskNumber } from '../task-domain/identity.js' @@ -65,24 +64,31 @@ router.get('/projects/:id/tasks', async (req, res, next) => { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }) const shaped = rows.map(shape) - // 保留选项过滤 (T-FL-*): 已完成任务按所属列配置隐藏 - const visible = filterVisible(shaped, columns) - res.json({ items: visible, columns, total: shaped.length }) + res.json({ items: shaped, columns, total: shaped.length }) } catch (e) { next(e) } }) // ---------- 归档 (T-ARC) ---------- +/** + * 归档/恢复以整棵树为单位: 子任务不单独变化。 + * 即使调用方传的是子任务 id, 也提升到根父任务处理。 + */ +async function loadTreeIds(task) { + const rootId = task.parentId ? task.parentId : task.id + const children = await prisma.task.findMany({ where: { parentId: rootId }, select: { id: true } }) + return [rootId, ...children.map((c) => c.id)] +} + // POST /api/tasks/:id/archive 手动归档: 连带全部子任务, 幂等 router.post('/tasks/:id/archive', async (req, res, next) => { try { const userId = req.user.sub const found = await loadTaskForUser(req.params.id, userId) if (!found) return res.status(404).json({ error: '任务不存在或无权限' }) - const { task } = found - const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } }) + const ids = await loadTreeIds(found.task) const r = await prisma.task.updateMany({ - where: { id: { in: [task.id, ...children.map((c) => c.id)] } }, + where: { id: { in: ids } }, data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' }, }) res.json({ ok: true, count: r.count }) @@ -95,10 +101,9 @@ router.post('/tasks/:id/unarchive', async (req, res, next) => { const userId = req.user.sub const found = await loadTaskForUser(req.params.id, userId) if (!found) return res.status(404).json({ error: '任务不存在或无权限' }) - const { task } = found - const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } }) + const ids = await loadTreeIds(found.task) const r = await prisma.task.updateMany({ - where: { id: { in: [task.id, ...children.map((c) => c.id)] } }, + where: { id: { in: ids } }, data: { archived: false, archivedAt: null, archiveSource: null }, }) res.json({ ok: true, count: r.count }) @@ -135,7 +140,12 @@ router.get('/projects/:id/tasks/archived', async (req, res, next) => { const rows = await prisma.task.findMany({ where, - include: TASK_INCLUDE, + // 已归档任务整树归档: 子任务也是 archived=true, 这里要一并取出, + // 否则「彻底删除」弹窗无法提示连带条数 + include: { + ...TASK_INCLUDE, + children: { ...TASK_INCLUDE.children, where: { archived: true } }, + }, orderBy: [{ archivedAt: 'desc' }, { id: 'desc' }], take: limit + 1, }) diff --git a/server/task-domain/archive.js b/server/task-domain/archive.js index aa84760..5d97bfc 100644 --- a/server/task-domain/archive.js +++ b/server/task-domain/archive.js @@ -1,105 +1,110 @@ // 任务归档领域逻辑 (T-ARC) -// 自动归档判定: 倒计时起点 = 任务"被隐藏的时刻"(与 date.js isVisibleByRetention -// 精确同口径推导): 完成日 + 保留窗口后的上海零点; 加上项目 autoArchive 档位即到期时刻。 +// 自动归档: 顶层任务完成后, 经过项目 autoArchive 档位即到期; 到期时把该任务 +// 及其全部子任务作为一个整体归档。子任务不单独判定, 完全跟随父任务。 // 执行双保险: 项目任务加载时惰性执行 (runAutoArchiveForProject) + 每小时 cron 兜底。 import { prisma } from '../db.js' -import { toDateStr, addDays, startOfWeek, startOfMonth } from './date.js' export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never'] const DAY_MS = 24 * 3600 * 1000 const SHANGHAI_OFFSET_MS = 8 * 3600 * 1000 -/** 上海日历日 "YYYY-MM-DD" 的零点时刻 (UTC 表示) */ -function shanghaiMidnight(dayStr) { - const [y, m, d] = dayStr.split('-').map(Number) - return new Date(Date.UTC(y, m - 1, d) - SHANGHAI_OFFSET_MS) +/** 按上海日历月加 n 个月 (保留时刻), 用于 1m 档 */ +function addMonthsShanghai(date, n) { + const shifted = new Date(date.getTime() + SHANGHAI_OFFSET_MS) + shifted.setUTCMonth(shifted.getUTCMonth() + n) + return new Date(shifted.getTime() - SHANGHAI_OFFSET_MS) } -/** 上海日历日 + n 个月后的零点 (月末溢出由 Date 归一化) */ -function addMonths(dayStr, n) { - const [y, m, d] = dayStr.split('-').map(Number) - return new Date(Date.UTC(y, m - 1 + n, d) - SHANGHAI_OFFSET_MS) -} - -/** - * 任务被隐藏的时刻: doneAt 所在完成日之后, 保留窗口结束的上海零点。 - * 与 date.js isVisibleByRetention 的可见判定精确同口径: - * never → doneAt 即刻隐藏; today → 完成日次日零点; 3d → 完成日+3 零点; - * week → 完成周次下一周一零点; month → 次月 1 日零点; forever → 永不隐藏 (null) - */ -export function hiddenAtOf(doneAt, option) { - if (!doneAt) return null - const day = toDateStr(doneAt) - switch (option) { - case 'never': return new Date(doneAt) - case 'today': return shanghaiMidnight(addDays(day, 1)) - case '3d': return shanghaiMidnight(addDays(day, 3)) - case 'week': return shanghaiMidnight(addDays(startOfWeek(day), 7)) - case 'month': return shanghaiMidnight(String(addDays(day, 32)).slice(0, 8) + '01') - default: return null // forever: 永不隐藏 - } -} - -/** 自动归档到期时刻: 隐藏时刻 + 档位; 'never' 或无法隐藏 → null */ -export function autoArchiveDue(autoArchive, doneAt, columnRetention) { - if (!autoArchive || autoArchive === 'never') return null - const hidden = hiddenAtOf(doneAt, columnRetention) - if (!hidden) return null +/** 自动归档到期时刻: 完成时刻 + 档位; 'never' 或未完成 -> null */ +export function autoArchiveDue(autoArchive, doneAt) { + if (!autoArchive || autoArchive === 'never' || !doneAt) return null + const at = doneAt instanceof Date ? doneAt : new Date(doneAt) + if (Number.isNaN(at.getTime())) return null switch (autoArchive) { - case '3d': return new Date(hidden.getTime() + 3 * DAY_MS) - case '1w': return new Date(hidden.getTime() + 7 * DAY_MS) - case '1m': { - const d = new Date(hidden) - d.setUTCMonth(d.getUTCMonth() + 1) - return d - } + case '3d': return new Date(at.getTime() + 3 * DAY_MS) + case '1w': return new Date(at.getTime() + 7 * DAY_MS) + case '1m': return addMonthsShanghai(at, 1) default: return null } } +/** + * 修复历史数据里父子归档状态不一致: 子任务完全跟随父任务。 + * 只支持一级子任务; 返回被修正的条数。 + */ +export async function normalizeArchiveTree(projectId) { + const tasks = await prisma.task.findMany({ + where: { projectId }, + select: { id: true, parentId: true, archived: true, archivedAt: true, archiveSource: true }, + }) + const byId = new Map(tasks.map((t) => [t.id, t])) + const fixes = [] + for (const t of tasks) { + if (!t.parentId) continue + const parent = byId.get(t.parentId) + if (!parent || parent.archived === t.archived) continue + fixes.push({ + id: t.id, + archived: parent.archived, + archivedAt: parent.archived ? (parent.archivedAt || new Date()) : null, + archiveSource: parent.archived ? (parent.archiveSource || 'manual') : null, + }) + } + if (!fixes.length) return 0 + await prisma.$transaction(fixes.map((f) => prisma.task.update({ + where: { id: f.id }, + data: { + archived: f.archived, + archivedAt: f.archivedAt, + archiveSource: f.archiveSource, + }, + }))) + return fixes.length +} + /** * 对单个项目执行一轮到期自动归档 (T-ARC-02)。 - * 只处理 status=done / doneAt 非空 / 未归档 的任务; 幂等。 + * 只看顶层任务: 完成 + 档位到期 -> 该任务与全部子任务一起归档。幂等。 * @returns {number} 本次归档条数 */ export async function runAutoArchiveForProject(projectId) { + // 先修复历史父子不一致, 保证要么都显示、要么都归档 + await normalizeArchiveTree(projectId) + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { autoArchive: true }, }) if (!project || !AUTO_ARCHIVE_OPTIONS.includes(project.autoArchive) || project.autoArchive === 'never') return 0 - const columns = await prisma.boardColumn.findMany({ - where: { projectId }, - select: { id: true, retention: true }, - }) - const retentionById = new Map(columns.map((c) => [c.id, c.retention || 'forever'])) - - const tasks = await prisma.task.findMany({ - where: { projectId, archived: false, status: 'done', doneAt: { not: null } }, - select: { id: true, doneAt: true, columnId: true }, + const roots = await prisma.task.findMany({ + where: { projectId, parentId: null, archived: false, status: 'done', doneAt: { not: null } }, + select: { id: true, doneAt: true }, }) const now = new Date() - const dueIds = [] - for (const t of tasks) { - const due = autoArchiveDue(project.autoArchive, t.doneAt, retentionById.get(t.columnId)) - if (due && due <= now) dueIds.push(t.id) + const dueRootIds = [] + for (const t of roots) { + const due = autoArchiveDue(project.autoArchive, t.doneAt) + if (due && due <= now) dueRootIds.push(t.id) } - if (!dueIds.length) return 0 + if (!dueRootIds.length) return 0 + // 整棵树一起归档: 子任务不单独判定, 避免父任务归档而子任务仍留在看板 + const children = await prisma.task.findMany({ + where: { parentId: { in: dueRootIds } }, + select: { id: true }, + }) + const ids = [...dueRootIds, ...children.map((c) => c.id)] const r = await prisma.task.updateMany({ - where: { id: { in: dueIds } }, + where: { id: { in: ids } }, data: { archived: true, archivedAt: now, archiveSource: 'auto' }, }) return r.count } -/** 每小时 cron 兜底: 扫描所有启用了自动归档的项目 */ +/** 每小时 cron 兜底: 修复所有项目的父子归档一致性, 并执行到期自动归档 */ export async function runAutoArchiveSweep() { - const projects = await prisma.project.findMany({ - where: { autoArchive: { not: 'never' } }, - select: { id: true }, - }) + const projects = await prisma.project.findMany({ select: { id: true } }) let total = 0 for (const p of projects) { try { diff --git a/server/task-domain/date.js b/server/task-domain/date.js deleted file mode 100644 index 7c56be8..0000000 --- a/server/task-domain/date.js +++ /dev/null @@ -1,93 +0,0 @@ -// 任务模块: 自然日边界工具 (Asia/Shanghai) -// 与前端 src/pages/task/date.js 保持同一口径, 改动需同步 -// -// 保留选项口径 (T-FL-02): -// never 永不显示 -// today 当天 -> 今天 00:00 -// 3d 3 天 -> 今天-2 天 00:00 (含今天共 3 个自然日) -// week 本周 -> 本周一 00:00 -// month 本月 -> 本月 1 日 00:00 -// forever 永久显示 -> 无下界 - -export const TZ = 'Asia/Shanghai' - -export const RETENTION = ['never', 'today', '3d', 'week', 'month', 'forever'] -export const RETENTION_LABEL = { - never: '永不显示', - today: '当天', - '3d': '3 天', - week: '本周', - month: '本月', - forever: '永久显示', -} - -export const STATUSES = ['todo', 'doing', 'done'] -export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' } - -// 当前时刻在 Asia/Shanghai 的 "YYYY-MM-DD" 与星期几 -const FMT = new Intl.DateTimeFormat('en-CA', { - timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', -}) - -/** 任意时刻 -> 本地日历日 "YYYY-MM-DD" */ -export function toDateStr(d = new Date()) { - return FMT.format(d instanceof Date ? d : new Date(d)) -} - -/** "YYYY-MM-DD" -> UTC 零点 Date (仅做日期运算) */ -function toUTC(s) { - const [y, m, d] = String(s).split('-').map(Number) - return new Date(Date.UTC(y, (m || 1) - 1, d || 1)) -} - -function fromUTC(dt) { - return dt.toISOString().slice(0, 10) -} - -export function addDays(s, n) { - const d = toUTC(s) - d.setUTCDate(d.getUTCDate() + n) - return fromUTC(d) -} - -/** 周一为一周第一天 */ -export function startOfWeek(s) { - const dow = (toUTC(s).getUTCDay() + 6) % 7 - return addDays(s, -dow) -} - -export function startOfMonth(s) { - return String(s).slice(0, 8) + '01' -} - -/** - * 保留选项 -> 起始时间(不含时区偏移的 Date, 以 UTC 表示当日零点) - * 返回 null 表示"全可见"(forever); 返回 'never' 表示"全隐藏" - */ -export function retentionStart(option, now = new Date()) { - const today = toDateStr(now) - switch (option) { - case 'never': return { mode: 'never' } - case 'forever': return { mode: 'all' } - case 'today': return { mode: 'since', date: today } - case '3d': return { mode: 'since', date: addDays(today, -2) } - case 'week': return { mode: 'since', date: startOfWeek(today) } - case 'month': return { mode: 'since', date: startOfMonth(today) } - default: return { mode: 'all' } - } -} - -/** - * 判断一条已完成任务在给定保留选项下是否可见。 - * 依据任务的完成时刻 doneAt (T-FL-10); 未完成任务恒可见 (T-FL-12)。 - */ -export function isVisibleByRetention(task, option, now = new Date()) { - if (task.status !== 'done') return true - const win = retentionStart(option, now) - if (win.mode === 'never') return false - if (win.mode === 'all') return true - if (!task.doneAt) return false - // doneAt 是绝对时刻, 转成本地日历日再与窗口起点比较 - const doneDay = toDateStr(task.doneAt) - return doneDay >= win.date -} diff --git a/server/task-domain/retention.js b/server/task-domain/retention.js deleted file mode 100644 index 819b219..0000000 --- a/server/task-domain/retention.js +++ /dev/null @@ -1,31 +0,0 @@ -// 任务模块: 保留选项的可见性过滤 (T-FL-*) -// 前端 src/pages/task/date.js 有一份同口径实现, 用于即时过滤; 后端用于统计/兜底 -import { isVisibleByRetention } from './date.js' - -/** - * 按"任务所属列"的保留选项过滤任务列表 (T-FL-11) - * @param {object[]} tasks 任务(需含 status / doneAt / columnId) - * @param {object[]} columns 列(需含 id / retention) - */ -export function filterVisible(tasks, columns, now = new Date()) { - const retentionById = new Map((columns || []).map((c) => [c.id, c.retention || 'forever'])) - return (tasks || []).filter((t) => { - const opt = retentionById.get(t.columnId) || 'forever' - return isVisibleByRetention(t, opt, now) - }) -} - -/** 单条判定, 供详情接口复用 */ -export function isTaskVisible(task, columns, now = new Date()) { - const col = (columns || []).find((c) => c.id === task.columnId) - return isVisibleByRetention(task, col?.retention || 'forever', now) -} - -/** 列内计数: 过滤后条数 (T-FL-14 计数与展示同源) */ -export function countByColumn(tasks, columns, now = new Date()) { - const visible = filterVisible(tasks, columns, now) - const out = {} - for (const c of columns || []) out[c.id] = 0 - for (const t of visible) out[t.columnId] = (out[t.columnId] || 0) + 1 - return out -} diff --git a/src/api/task.js b/src/api/task.js index b4190a6..a6418e0 100644 --- a/src/api/task.js +++ b/src/api/task.js @@ -5,7 +5,6 @@ // 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md import { getPowersyncDb } from '../lib/powersync/db.js' import { api, getCurrentUserId } from './client.js' -import { filterVisible } from '../pages/task/date.js' const db = () => getPowersyncDb() @@ -380,10 +379,6 @@ export const columnApi = { if (body.boundStatus !== null && !['todo', 'doing', 'done'].includes(body.boundStatus)) throw new Error('绑定状态取值非法') data.boundStatus = body.boundStatus } - if (body?.retention !== undefined) { - if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(body.retention)) throw new Error('保留选项取值非法') - data.retention = body.retention - } const sets = Object.keys(data).map((k) => `"${k}" = ?`) if (sets.length) { await db().execute(`UPDATE BoardColumn SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`, [...Object.values(data), now(), String(id)]) @@ -452,17 +447,16 @@ export const taskApi = { ) const shape = await shapeTasks(rows, columns) const shaped = rows.map(shape) - // 子任务挂载 (与服务端 TASK_INCLUDE.children 同构) + // 子任务挂载 (与服务端 TASK_INCLUDE.children 同构); 子任务完全跟随父任务 for (const t of shaped) { const childRows = await db().getAll( - 'SELECT * FROM Task WHERE parentId = ? ORDER BY sortOrder, id', + 'SELECT * FROM Task WHERE parentId = ? AND archived = 0 ORDER BY sortOrder, id', [String(t.id)], ) const childShape = await shapeTasks(childRows, columns) t.children = childRows.map(childShape) } - const visible = filterVisible(shaped, columns) - return { items: visible, columns, total: shaped.length } + return { items: shaped, columns, total: shaped.length } }, create: async (projectId, body) => { @@ -711,16 +705,20 @@ export const taskApi = { }, // ---------- 归档 (T-ARC, 本地实现: 写本地库 -> 上行) ---------- - /** 手动归档: 连带全部子任务, 幂等 (T-ARC-01) */ + /** 手动归档: 以整棵树为单位 (子任务跟随父任务), 幂等 (T-ARC-01) */ archive: async (id) => { const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) if (!task) throw new Error('任务不存在或无权限') + // 子任务被单独归档时, 提升到根父任务, 避免父子状态不一致 + const root = task.parentId + ? (await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(task.parentId)])) || task + : task // 不能写 const now = now(): 同名 const 会遮蔽模块级 now, 右侧求值落入 TDZ, // 压缩后报 "Cannot access 'a' before initialization" const ts = now() await db().writeTransaction(async (tx) => { - const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)]) - const ids = [String(task.id), ...kids.map((k) => String(k.id))] + const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(root.id)]) + const ids = [String(root.id), ...kids.map((k) => String(k.id))] const ph = ids.map(() => '?').join(',') await tx.execute( `UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`, @@ -730,14 +728,17 @@ export const taskApi = { return { ok: true, count: 1 } }, - /** 恢复归档: 回原列, 状态不变, 连带子任务 (T-ARC-03) */ + /** 恢复归档: 回原列, 状态不变, 以整棵树为单位 (T-ARC-03) */ unarchive: async (id) => { const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) if (!task) throw new Error('任务不存在或无权限') + const root = task.parentId + ? (await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(task.parentId)])) || task + : task const ts = now() await db().writeTransaction(async (tx) => { - const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)]) - const ids = [String(task.id), ...kids.map((k) => String(k.id))] + const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(root.id)]) + const ids = [String(root.id), ...kids.map((k) => String(k.id))] const ph = ids.map(() => '?').join(',') await tx.execute( `UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`, diff --git a/src/pages/task/ColumnSettings.jsx b/src/pages/task/ColumnSettings.jsx index 19be367..bed9e41 100644 --- a/src/pages/task/ColumnSettings.jsx +++ b/src/pages/task/ColumnSettings.jsx @@ -1,14 +1,13 @@ import { useState } from 'react' import { Dialog, Toast } from 'antd-mobile' import { columnApi } from '../../api/task.js' -import { RETENTION_OPTIONS, RETENTION_LABEL, STATUS_OPTIONS, STATUS_LABEL } from './date.js' +import { STATUS_OPTIONS, STATUS_LABEL } from './date.js' const MAX_COLUMNS = 10 -// 列设置 (T-COL-*, T-BD-02/09/10, T-FL-01/02/15) +// 列设置 (T-COL-*, T-BD-02/09/10) // 1) 列管理: 改名 / 新增 / 删除 / 排序 // 2) 状态绑定: 每列独立选择 不绑定 / 未开始 / 执行中 / 完成 -// 3) 保留选项: 每列独立 6 档 export default function ColumnSettings({ projectId, columns, onClose, onSaved }) { const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder) const [list, setList] = useState(ordered) @@ -28,7 +27,6 @@ export default function ColumnSettings({ projectId, columns, onClose, onSaved }) const body = {} if (orig.name !== c.name) body.name = c.name if (orig.boundStatus !== c.boundStatus) body.boundStatus = c.boundStatus - if (orig.retention !== c.retention) body.retention = c.retention if (Object.keys(body).length) await columnApi.update(c.id, body) } Toast.show({ content: '已保存' }) @@ -129,30 +127,6 @@ export default function ColumnSettings({ projectId, columns, onClose, onSaved }) 当前 {list.length} / {MAX_COLUMNS} 列
-
已完成任务保留
- - {list.map((col) => ( -
- {col.name} -
- -
-
- ))} - -
- 保留选项:永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示。
- 仅影响已完成任务;未完成任务始终展示。列表与看板按同一规则过滤。 -
-
{pending ? '保存中…' : '保存'}
取消
diff --git a/src/pages/task/ProjectManage.jsx b/src/pages/task/ProjectManage.jsx index aaa2ba3..9920727 100644 --- a/src/pages/task/ProjectManage.jsx +++ b/src/pages/task/ProjectManage.jsx @@ -249,7 +249,7 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
归档设置
setArchivePick(true)}> - 隐藏的任务自动归档 + 完成后自动归档 {AUTO_ARCHIVE_LABEL[project.autoArchive] || '永不'} › diff --git a/src/pages/task/ProjectView.jsx b/src/pages/task/ProjectView.jsx index 701a584..e46d7ee 100644 --- a/src/pages/task/ProjectView.jsx +++ b/src/pages/task/ProjectView.jsx @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile' import { projectApi, taskApi, TASK_TABLES } from '../../api/task.js' import useDbSignal from '../../lib/powersync/useDbSignal.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' -import { filterVisible, msToNextBoundary } from './date.js' import { sameId } from './ids.js' import { getViewMode, setViewMode } from './prefs.js' import { parseTimePrefix, splitTitle } from './quickAdd.js' @@ -25,7 +24,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit const [openTask, setOpenTask] = useState(null) const [showManage, setShowManage] = useState(false) const [adding, setAdding] = useState(false) - const [tick, setTick] = useState(0) // 保留窗口重算触发 const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝 const [archivedOpen, setArchivedOpen] = useState(false) // 已归档任务查询 @@ -70,23 +68,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit setViewMode(projectId, next) } - // 保留窗口到点重算 (T-EX-18) - useEffect(() => { - const options = (columns || []).map((c) => c.retention) - const near = options - .map((o) => msToNextBoundary(o)) - .filter((v) => v != null) - .sort((a, b) => a - b)[0] - const t = setTimeout(() => setTick((v) => v + 1), near ? Math.min(near, 60_000) : 60_000) - return () => clearTimeout(t) - }, [columns, tick]) - - const visibleTasks = useMemo( - () => filterVisible(tasks || [], columns), - // eslint-disable-next-line react-hooks/exhaustive-deps - [tasks, columns, tick], - ) - // ---- 乐观更新 ---- const patchTask = (id, patch) => setTasks((prev) => (prev || []).map((t) => { if (t.id === id) return { ...t, ...patch } @@ -214,14 +195,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit handler.close?.() if (item.key === 'edit') setOpenTask(task) else if (item.key === 'archive') { - const subs = task.children?.length || 0 + // 子任务不能单独归档: 提升到根父任务, 整棵树一起处理 + const root = task.parentId + ? (tasks || []).find((t) => sameId(t.id, task.parentId)) || task + : task + const subs = root.children?.length || 0 + const extra = root.id !== task.id ? '(子任务随父任务一起归档)' : '' const ok = await Dialog.confirm({ - content: subs ? `归档「${task.title}」?将连带归档 ${subs} 条子任务。` : `归档「${task.title}」?`, + content: subs + ? `归档「${root.title}」?将连带归档 ${subs} 条子任务。` + : `归档「${root.title}」?${extra}`, confirmText: '归档', }) if (!ok) return try { - await taskApi.archive(task.id) + await taskApi.archive(root.id) Toast.show({ content: '已归档' }) load() } catch (e) { @@ -308,7 +296,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit {mode === 'list' ? ( setOpenTask(t)} @@ -319,7 +307,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit /> ) : ( setOpenTask(t)} diff --git a/src/pages/task/date.js b/src/pages/task/date.js index fad7b9f..ce003ee 100644 --- a/src/pages/task/date.js +++ b/src/pages/task/date.js @@ -1,18 +1,6 @@ -// 任务模块前端: 自然日边界 + 保留选项可见性 -// 与后端 server/task-domain/date.js 保持同一口径, 改动需同步 +// 任务模块前端: 日期工具 + 任务编号/状态 export const TZ = 'Asia/Shanghai' -export const RETENTION_LABEL = { - never: '永不显示', - today: '当天', - '3d': '3 天', - week: '本周', - month: '本月', - forever: '永久显示', -} - -export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'forever'] - export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' } export const STATUS_OPTIONS = ['todo', 'doing', 'done'] @@ -66,49 +54,3 @@ export function startOfMonth(s) { return String(s).slice(0, 8) + '01' } -/** 保留选项 -> 窗口起点 ("YYYY-MM-DD") 或 特殊标记 */ -export function retentionStart(option, now = new Date()) { - const today = toDateStr(now) - switch (option) { - case 'never': return { mode: 'never' } - case 'forever': return { mode: 'all' } - case 'today': return { mode: 'since', date: today } - case '3d': return { mode: 'since', date: addDays(today, -2) } - case 'week': return { mode: 'since', date: startOfWeek(today) } - case 'month': return { mode: 'since', date: startOfMonth(today) } - default: return { mode: 'all' } - } -} - -/** 单条任务在某保留选项下是否可见 */ -export function isVisibleByRetention(task, option, now = new Date()) { - if (task.status !== 'done') return true - const win = retentionStart(option, now) - if (win.mode === 'never') return false - if (win.mode === 'all') return true - if (!task.doneAt) return false - return toDateStr(task.doneAt) >= win.date -} - -/** 按任务所属列的保留选项过滤任务 */ -export function filterVisible(tasks, columns, now = new Date()) { - const map = new Map((columns || []).map((c) => [c.id, c.retention || 'forever'])) - return (tasks || []).filter((t) => isVisibleByRetention(t, map.get(t.columnId) || 'forever', now)) -} - -/** 距离下一个自然日/周/月边界的毫秒数, 用于定时重算 (T-EX-18) */ -export function msToNextBoundary(option, now = new Date()) { - if (!['today', '3d', 'week', 'month'].includes(option)) return null - const today = toDateStr(now) - let next - if (option === 'today' || option === '3d') next = addDays(today, 1) - else if (option === 'week') next = addDays(startOfWeek(today), 7) - else { - const [y, m] = today.split('-').map(Number) - const nm = new Date(Date.UTC(y, m, 1)) - next = nm.toISOString().slice(0, 10) - } - // 本地零点转北京时间零点: 用 Date 构造 "next 00:00 +08:00" - const target = new Date(next + 'T00:00:00+08:00').getTime() - return Math.max(1000, target - now.getTime()) -}