Compare commits
10
Commits
e57b7902be
...
6153b9db99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6153b9db99 | ||
|
|
6c9cd3fd99 | ||
|
|
42acf72f1e | ||
|
|
6a48b7311b | ||
|
|
3250b94bc6 | ||
|
|
a150706cc4 | ||
|
|
2ea0da758f | ||
|
|
2f55865092 | ||
|
|
d1f36b3b8f | ||
|
|
95b9985e8b |
@@ -47,3 +47,6 @@ cache/apizero/
|
|||||||
server/mihome/.cache/
|
server/mihome/.cache/
|
||||||
# 工作区记忆/会话数据 (本地私有, 不入库)
|
# 工作区记忆/会话数据 (本地私有, 不入库)
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
|
|
||||||
|
# 本地 pnpm 安装产物 (项目使用 package-lock.json)
|
||||||
|
/.pnpm-store
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# iBoard V0.2 任务模块 功能设计
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
|
||||||
|
| 文档状态 | **设计基线**(待评审) |
|
||||||
|
| 上游文档 | `任务模块需求文档.md`(**142 条需求点**,编号 `T-*`) |
|
||||||
|
| 配套文档 | `任务模块设计文档-框架设计.md`、`任务模块数据库设计文档.md`、`任务模块界面设计.html` |
|
||||||
|
| 对应关系 | **本文与需求点一一对应**:每条需求点均给出「设计落点(功能/组件/接口/表字段)」与「实现要点」 |
|
||||||
|
|
||||||
|
> **阅读方式**
|
||||||
|
> 1. 本文按需求文档的模块码组织(`VW` / `COL` / `BD` / `FL` / `TK` / `AS` / `ST` / `PJ` / `PM` / `CP` / `UI` / `DT` / `NF` / `EX`)。
|
||||||
|
> 2. 每条需求点一行:**编号 → 设计落点 → 实现要点**。设计落点中的组件名、接口路径、表字段均与《任务模块数据库设计文档》《任务模块界面设计》一致。
|
||||||
|
> 3. 文末 §15 给出**覆盖自检表**,确保 142 条无遗漏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 通用约定
|
||||||
|
|
||||||
|
### 0.1 契约命名
|
||||||
|
|
||||||
|
| 类别 | 约定 | 示例 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 状态枚举 | `todo` / `doing` / `done` | 未开始 / 执行中 / 完成 |
|
||||||
|
| 列 | `BoardColumn` 实体,按 `sortOrder` 升序 | 默认 `Plan/Doing/Action/Done` |
|
||||||
|
| 保留选项 | `never` / `today` / `3d` / `week` / `month` / `forever` | 永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示 |
|
||||||
|
| 视图 | `list` / `board` | 设备端记忆 |
|
||||||
|
| 设备端键 | `iboard_task_prefs` | `{ lastProjectId, viewMode: { [projectId]: 'list'\|'board' } }` |
|
||||||
|
|
||||||
|
### 0.2 接口总览
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/projects` | 我参与的项目(含个人默认项目、归档标记) |
|
||||||
|
| POST | `/api/projects` | 新建普通项目(同时初始化 4 个默认列) |
|
||||||
|
| GET | `/api/projects/:id` | 项目详情(含成员、列配置) |
|
||||||
|
| PUT | `/api/projects/:id` | 改名 / 归档 |
|
||||||
|
| DELETE | `/api/projects/:id` | 删除项目(个人项目 400) |
|
||||||
|
| POST | `/api/projects/:id/members` | 加成员(个人项目 400) |
|
||||||
|
| DELETE | `/api/projects/:id/members/:uid` | 移成员 / 自己退出 |
|
||||||
|
| GET/POST | `/api/projects/:id/columns` | 列列表 / 新增列 |
|
||||||
|
| PUT | `/api/columns/:id` | 改名 / 改绑定 / 改保留选项 |
|
||||||
|
| DELETE | `/api/columns/:id` | 删列(需 `moveToColumnId`,最后列 400) |
|
||||||
|
| PUT | `/api/projects/:id/columns/order` | 列排序 |
|
||||||
|
| GET | `/api/projects/:id/tasks` | 任务列表(含子任务与列信息) |
|
||||||
|
| POST | `/api/projects/:id/tasks` | 新建任务 / 子任务 |
|
||||||
|
| GET | `/api/tasks/:id` | 任务详情 |
|
||||||
|
| PUT | `/api/tasks/:id` | 改状态 / 换列 / 改字段(触发裁决) |
|
||||||
|
| DELETE | `/api/tasks/:id` | 删除任务(级联子任务) |
|
||||||
|
| POST | `/api/tasks/:id/copy` | 跨项目拷贝 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 视图体系(T-VW)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-VW-01 | `ProjectView.jsx` 内 `viewMode` 分支渲染 `ListView` / `BoardView` | 两视图共用 `TaskStore` 的同一份任务数据,仅渲染方式不同 |
|
||||||
|
| T-VW-02 | `SwipeTabs`/分段控件切换 `viewMode` | 纯本地状态切换,不触发路由跳转、不重新拉取数据(满足"无整页刷新") |
|
||||||
|
| 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-07 | `prefs.getLastProjectId()` / `setLastProjectId()` | 进入 `/task` 时读取;无记录或记录的项目已不可见 ⇒ 回落个人默认项目 |
|
||||||
|
|
||||||
|
> **实现细节**:`lastProjectId` 指向的项目若已被退出/删除,视为"无记录",静默回落到个人默认项目,不弹错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 看板列管理(T-COL)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-COL-01 | `POST /api/projects` 内部初始化 4 列 | 事务内创建 `Plan(todo, sort 0)`、`Doing(doing,1)`、`Action(null,2)`、`Done(done,3)` |
|
||||||
|
| T-COL-02 | `ColumnSettings` → `POST /api/projects/:id/columns` | `sortOrder = max+1` 追加末尾;`boundStatus=null`、`retention='forever'`(B-18) |
|
||||||
|
| T-COL-03 | `PUT /api/columns/:id` | 改 `name`;前端 `TaskStore` 整体替换列列表后重渲染 |
|
||||||
|
| T-COL-04 | `DELETE /api/columns/:id?moveToColumnId=` | 二次确认弹窗;见 T-EX-16 |
|
||||||
|
| T-COL-05 | `PUT /api/projects/:id/columns/order` | 传完整 `[columnId…]` 数组,服务端按索引重写 `sortOrder` |
|
||||||
|
| T-COL-06 | 服务端校验总量 | `1 <= count <= 10`;达上限拒绝新增(T-EX-19)、仅剩 1 列拒绝删除(T-EX-17) |
|
||||||
|
| T-COL-07 | `BoardColumn.name` 校验 | trim 后非空、长度 ≤ 12;`@@unique([projectId,name])` 兜底重名 409 |
|
||||||
|
| T-COL-08 | 列删除即绑定消失 | 绑定存在列记录上,列删则解除,无需额外清理 |
|
||||||
|
| T-COL-09 | `requireProjectMember` | 项目级配置,任一成员可改;不影响其他项目 |
|
||||||
|
| T-COL-10 | `ListView` 不分组但仍显示列信息 | 任务行内以小标签显示所属列名(可关闭),归属始终正确 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 看板与列-状态绑定(T-BD)
|
||||||
|
|
||||||
|
> 联动的**唯一实现**在 `server/task-domain/resolveStatus.js`,前端只做乐观更新。判定矩阵见《框架设计》§2.3。
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-BD-01 | `BoardView` 按 `columns` 数组渲染列 | 列来自项目配置,可增删改排序 |
|
||||||
|
| T-BD-02 | `ColumnSettings` 每列一组「绑定开关 + 状态下拉」 | 写入 `BoardColumn.boundStatus`(`null`/`todo`/`doing`/`done`) |
|
||||||
|
| T-BD-03 | 无唯一约束限制多列绑定同状态 | 允许 `done` 与 `action` 同为 `done` |
|
||||||
|
| T-BD-04 | `onColumnChange()` | 目标列 `boundStatus != null` ⇒ `status = boundStatus` |
|
||||||
|
| T-BD-05 | `onStatusChange()` | 状态变更后若当前列已匹配则不动作,否则走归位 |
|
||||||
|
| T-BD-06 | 归位取列序首个匹配 | `columns` 按 `sortOrder` 排序后 `find()` 首个 |
|
||||||
|
| T-BD-07 | **只看目标列** | 目标列不绑定 ⇒ 保留原状态;**无"拖出回退"规则** |
|
||||||
|
| T-BD-08 | 不绑定列完全正交 | 拖入/拖出、改状态均不改该列以外语义 |
|
||||||
|
| T-BD-14 | `resolveColumn(task, newStatus)` | 当前列 `boundStatus !== newStatus` ⇒ 触发归位 |
|
||||||
|
| T-BD-15 | 按当前 `sortOrder` 取首个 | 用户调序后归位目标随之改变 |
|
||||||
|
| T-BD-16 | 无匹配返回 `null` | `null` ⇒ 不改 `columnId`,状态照常写库 |
|
||||||
|
| T-BD-09 | 初始化默认值 | `Plan=todo`、`Doing=doing`、`Done=done`、`Action=null` |
|
||||||
|
| T-BD-10 | 项目级配置 | 任一成员可改;不同项目独立 |
|
||||||
|
| T-BD-11 | 配置变更不回溯 | 保存配置时**不触发**任何任务更新语句 |
|
||||||
|
| T-BD-12 | 保存后返回最新列列表 | 前端整体替换 + 重算可见集合,无需刷新 |
|
||||||
|
| T-BD-13 | `resolveStatus()` 首行判 `parentId` | 子任务直接跳过全部联动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` 单测覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 任务字段体系(T-TK)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-TK-01 | `Task` 表 | 字段:`title`/`description`/`projectId`/`columnId`/`status`/`assigneeId`/`creatorId`/`doneAt`/`sortOrder` + 关联成员/标签/扩展 |
|
||||||
|
| T-TK-02 | `title` 非空校验 | 其余字段允许空;trim 后为空 ⇒ 400 |
|
||||||
|
| T-TK-03 | `status` 枚举三值 | DB 层用 `VARCHAR(8)` + 应用层校验(避免 ENUM 改值困难) |
|
||||||
|
| T-TK-04 | `TaskDetail` → `ExtrasCard` 组件 | 任意条目增删改,无字段上限(建议 ≤ 20 条) |
|
||||||
|
| T-TK-05 | `Task.extras` JSON | 结构 `[{label, value, url?}]`;`value` 支持纯文本或 URL |
|
||||||
|
| T-TK-06 | `ExtrasCard` 默认 `collapsed=true` | 首次打开详情为收起态 |
|
||||||
|
| T-TK-07 | 无必填校验 | 全空可保存 |
|
||||||
|
| T-TK-08 | `Task.assigneeId` 单值 | 重复设置以最后一次为准 |
|
||||||
|
| T-TK-09 | 新建时服务端默认赋值 | 未显式传 `assigneeId` ⇒ 取 `req.user.id`(B-10) |
|
||||||
|
| T-TK-10 | `assigneeId` 允许 `null` | 显式传 `null` 即清空,UI 显示「未指派」 |
|
||||||
|
| T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 |
|
||||||
|
| T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 |
|
||||||
|
| T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 负责人 & 成员(T-AS)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-AS-01 | `TaskCard` 负责人区 | 字号 13px/600 + 头像,位于标题下方主信息位 |
|
||||||
|
| T-AS-02 | `TaskCard` 成员区 | 字号 11px/400、色 `#999`,位于卡片底部 |
|
||||||
|
| T-AS-03 | `UserPicker` 数据源 = 项目成员 | 只列 `ProjectMember` 中的用户 |
|
||||||
|
| T-AS-04 | 同上(多选模式) | 不可选项目外用户 |
|
||||||
|
| T-AS-05 | `assigneeId === null` 时渲染「未指派」 | 占位样式弱化,可点击再次指派 |
|
||||||
|
| T-AS-06 | 渲染时 `LEFT JOIN ProjectMember` 判定 | 非成员 ⇒ 显示「已退出」灰标;见 T-EX-06 |
|
||||||
|
| T-AS-07 | 权限模型 | 无"负责人可编辑"概念,所有成员全读写(T-PM-02) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 子任务体系(T-ST)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-ST-01 | `Task.parentId` 自关联 | 创建子任务时校验:父任务 `parentId IS NULL`,否则 400 |
|
||||||
|
| T-ST-02 | 一致性校验 | 子任务 `projectId` 必须等于父任务 `projectId`,否则 400(T-EX-12) |
|
||||||
|
| T-ST-03 | 状态独立 | 父/子状态字段各自独立,无级联更新 |
|
||||||
|
| T-ST-04 | `SubtaskRow` 缩进 | 左侧 28px 缩进 + 层级线 |
|
||||||
|
| T-ST-05 | 折叠/展开按钮 | 组件内存状态,折叠时不渲染子项 |
|
||||||
|
| T-ST-06 | `TaskCard` 内嵌 `SubtaskRow` 列表 | 简洁单行:复选框 + 标题,最多显示 3 条 + 「查看全部」 |
|
||||||
|
| T-ST-07 | 卡片内直接勾选 | 勾选调 `PUT /api/tasks/:id {status}`;完整编辑走 `TaskDetail` 弹窗 |
|
||||||
|
| T-ST-08 | 子任务跳过绑定 | 见 T-BD-13;父任务完成时子任务状态不变 |
|
||||||
|
| T-ST-09 | `SubtaskRow` 带 `StatusDot` | 三色小圆点:灰=未开始 / 蓝=执行中 / 绿=完成 |
|
||||||
|
| T-ST-10 | 看板只渲染顶层任务 | 看板查询 `WHERE parentId IS NULL` |
|
||||||
|
| T-ST-11 | `Task.parentId` 级联删除 | `onDelete: Cascade`(T-EX-07) |
|
||||||
|
| T-ST-12 | `useLongPressDrag` 落点为目标顶层任务 | 设置 `parentId` |
|
||||||
|
| T-ST-13 | 目标为子任务 ⇒ 拒绝 | Toast「仅支持一级子任务」 |
|
||||||
|
| T-ST-14 | 被拖任务有子任务 ⇒ 拒绝 | 避免二级嵌套 |
|
||||||
|
| T-ST-15 | 保持原列与状态 | 仅更新 `parentId`,不触发状态裁决 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 项目体系(T-PJ)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-PJ-01 | 用户首次访问自动创建 | 按 `isPersonal=true` 且 `ownerId=userId` 幂等创建 |
|
||||||
|
| T-PJ-02 | `DELETE /api/projects/:id` 校验 `isPersonal` | 为真 ⇒ 400(T-EX-09) |
|
||||||
|
| T-PJ-03 | `POST .../members` 校验 `isPersonal` | 为真 ⇒ 400 |
|
||||||
|
| T-PJ-04 | `name` 初始 `${user.name}的任务` | 允许 `PUT` 改名,`isPersonal` 不变 |
|
||||||
|
| T-PJ-05 | `POST /api/projects` | 创建者自动写入 `ProjectMember` |
|
||||||
|
| T-PJ-06 | 成员接口 | 增/删成员,即时影响可见性 |
|
||||||
|
| T-PJ-07 | `DELETE .../members/:uid` 允许 `uid == ownerId` | 创建人退出后不再是成员 ⇒ 不可见 |
|
||||||
|
| T-PJ-08 | 删除最后成员不删项目 | 仅删 `ProjectMember`(B-11);见 T-EX-11 |
|
||||||
|
| T-PJ-09 | `Project.archived` | 列表分「进行中 / 已归档」 |
|
||||||
|
| T-PJ-10 | 列表查询按成员关系过滤 | `WHERE EXISTS ProjectMember(userId)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 项目权限与入口(T-PM)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-PM-01 | `requireProjectMember` 中间件 | 非成员 403;列表查询天然过滤 |
|
||||||
|
| T-PM-02 | 无角色区分 | 所有成员对任务/列/成员均有全读写 |
|
||||||
|
| T-PM-03 | `ProjectMember.permission` 字段 | 本期恒为 `member`,预留分级扩展位 |
|
||||||
|
| T-PM-04 | 归档接口无权限限制 | 任意成员可归档(`PUT /api/projects/:id {archived}`) |
|
||||||
|
| T-PM-05 | 复用 `authRequired` | 未登录 401 |
|
||||||
|
| T-PM-06 | `BottomNav.TABS` 插入 `{ key:'/task', label:'任务' }` | 位于 `打卡` 之后;`HomePath` 仍为 `/checkin` |
|
||||||
|
| T-PM-07 | `prefs.lastProjectId` | 设备端;无记录 ⇒ 个人默认项目 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 跨项目拷贝(T-CP)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-CP-01 | `POST /api/tasks/:id/copy` | 复制而非移动,源任务零改动 |
|
||||||
|
| T-CP-02 | `copyTask()` 字段映射 | 标题/描述/状态/列/扩展/标签/成员 全量复制 |
|
||||||
|
| T-CP-03 | 递归复制子任务 | 只复制一层(子任务无更深层级) |
|
||||||
|
| T-CP-04 | `creatorId = req.user.id` | 覆盖源创建人 |
|
||||||
|
| T-CP-05 | 无任何关联字段 | 拷贝后两条独立记录 |
|
||||||
|
| T-CP-06 | 校验目标项目成员身份 | 非成员 403 |
|
||||||
|
| T-CP-07 | 人员交集过滤 | 保留属于目标项目成员者,返回 `dropped` 名单(T-EX-08) |
|
||||||
|
| T-CP-08 | 子任务 `projectId` 同步改写 | 与父任务一致 |
|
||||||
|
| T-CP-09 | 状态与列沿用不纠正 | 见 T-EX-14 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. UI 交互规范(T-UI)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-UI-01 | 列表条目结构 / 快速新增 / 子任务折叠 | 参考 Todoist:底部输入框回车即建 |
|
||||||
|
| T-UI-02 | `TaskCard` 精简版 | 仅标题、状态点、负责人、子任务摘要;无边框装饰 |
|
||||||
|
| T-UI-03 | 单 `TaskStore` | 任一视图修改写入 store,另一视图读取同一数据 |
|
||||||
|
| T-UI-04 | 视图切换纯本地 | 不重新请求,不重载页面 |
|
||||||
|
| T-UI-05 | 复用 `.card` 与安全区变量 | 375~430px 无横向滚动;底部预留 TabBar 高度 |
|
||||||
|
| T-UI-06 | 勾选即时变色 | 乐观更新,先改 UI 再发请求 |
|
||||||
|
| T-UI-07 | `useLongPressDrag` | 长按 300ms 拖拽 + 「⋯」菜单「移动到」兜底 |
|
||||||
|
| T-UI-08 | `EmptyState` 组件 | 无项目 / 无任务 / 无子任务三档空态 + CTA |
|
||||||
|
| T-UI-09 | `LoadingState` 骨架屏 | 首屏与切换视图 |
|
||||||
|
| T-UI-10 | `ErrorState` 组件 | 展示错误 + 重试按钮 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 数据需求(T-DT)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-DT-01 | `Project` / `ProjectMember` | 见《数据库设计文档》 |
|
||||||
|
| T-DT-02 | `BoardColumn.boundStatus` | 每列独立字段,允许多列同值 |
|
||||||
|
| T-DT-03 | `BoardColumn.retention` | 六档枚举字符串 |
|
||||||
|
| T-DT-04 | `Task` | 含 `parentId` 自关联、`assigneeId`、`status`、`columnId`、`extras` |
|
||||||
|
| T-DT-05 | `TaskMember` / `TaskTag` | 关联表;`extras` 为 JSON |
|
||||||
|
| T-DT-06 | 设备端 | 不落库 |
|
||||||
|
| T-DT-07 | 索引 | `(projectId, columnId)`、`(projectId, status)`、`(parentId)` |
|
||||||
|
| T-DT-08 | 迁移文件 | `prisma migrate deploy` 流程 |
|
||||||
|
| T-DT-09 | `Project.archived` / 任务无物理删 | 归档保留历史 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 非功能需求(T-NF)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-NF-01 | 单请求聚合 + 乐观更新 | 见《框架设计》§4.2 |
|
||||||
|
| T-NF-02 | 不依赖桌面专有 API | 触控事件统一 `touch` / `pointer` 兼容 |
|
||||||
|
| T-NF-03 | `src/pages/task/*` + `server/routes/*.js` | 与既有模块结构一致 |
|
||||||
|
| T-NF-04 | 路由挂载于 `server/index.js` | 沿用既有组织方式 |
|
||||||
|
| T-NF-05 | 千级任务可用 | 索引 + 分页/虚拟滚动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 异常与边界(T-EX)
|
||||||
|
|
||||||
|
| 编号 | 设计落点 | 实现要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T-EX-01 | 前端 500ms 防抖 + 服务端幂等判定 | 相同终态请求直接返回当前任务 |
|
||||||
|
| T-EX-02 | 乐观更新快照回滚 | 失败恢复快照 + Toast + 重试按钮 |
|
||||||
|
| T-EX-03 | 列配置改为不绑定 | 已有任务保持不动 |
|
||||||
|
| T-EX-04 | 状态与列不匹配 | 走 T-BD-14~16 归位 |
|
||||||
|
| T-EX-05 | 拖到不绑定列 | 状态保留,不回退 |
|
||||||
|
| T-EX-06 | 非成员渲染「已退出」 | 见 T-AS-06 |
|
||||||
|
| T-EX-07 | 级联删除子任务 | 确认弹窗提示条数 |
|
||||||
|
| T-EX-08 | 拷贝丢弃人员 | 返回 `dropped` 并提示 |
|
||||||
|
| T-EX-09 | 个人项目两个保护接口 | 400 + 明确文案 |
|
||||||
|
| T-EX-10 | 列空态 | 展示该列空态,不报错 |
|
||||||
|
| T-EX-11 | 无成员项目 | 静默不可见,无特判报错 |
|
||||||
|
| T-EX-12 | 子任务跨项目 | 服务端 400 |
|
||||||
|
| T-EX-13 | 拖拽降级 | 保持原列与状态 |
|
||||||
|
| T-EX-14 | 拷贝状态/列无法对应 | 沿用源值,不纠正 |
|
||||||
|
| T-EX-15 | 拖到自身或后代 | 拒绝 + Toast |
|
||||||
|
| T-EX-16 | 删除有任务的列 | 二次确认 + 迁移到指定列(默认相邻)+ 重新裁决 |
|
||||||
|
| T-EX-17 | 删除最后一列 | 400「至少保留一列」 |
|
||||||
|
| T-EX-18 | 跨周期当场消失 | 定时重算 + 退场动画 + 重排 |
|
||||||
|
| T-EX-19 | 达到列数上限 | 拒绝新增 + 提示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 覆盖自检表
|
||||||
|
|
||||||
|
| 模块 | 编号范围 | 条数 | 本文对应章节 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 视图体系 | T-VW-01 ~ 07 | 7 | §1 |
|
||||||
|
| 看板列管理 | T-COL-01 ~ 10 | 10 | §2 |
|
||||||
|
| 列-状态绑定 | T-BD-01 ~ 16 | 16 | §3 |
|
||||||
|
| 已完成显示控制 | T-FL-01 ~ 15 | 15 | §4 |
|
||||||
|
| 任务字段体系 | T-TK-01 ~ 13 | 13 | §5 |
|
||||||
|
| 负责人 & 成员 | T-AS-01 ~ 07 | 7 | §6 |
|
||||||
|
| 子任务体系 | T-ST-01 ~ 15 | 15 | §7 |
|
||||||
|
| 项目体系 | T-PJ-01 ~ 10 | 10 | §8 |
|
||||||
|
| 项目权限与入口 | T-PM-01 ~ 07 | 7 | §9 |
|
||||||
|
| 跨项目拷贝 | T-CP-01 ~ 09 | 9 | §10 |
|
||||||
|
| UI 交互规范 | T-UI-01 ~ 10 | 10 | §11 |
|
||||||
|
| 数据需求 | T-DT-01 ~ 09 | 9 | §12 |
|
||||||
|
| 非功能需求 | T-NF-01 ~ 05 | 5 | §13 |
|
||||||
|
| 异常与边界 | T-EX-01 ~ 19 | 19 | §14 |
|
||||||
|
| **合计** | — | **142** | — |
|
||||||
|
|
||||||
|
> 需求文档共 **142 条**需求点,本文逐条给出设计落点,覆盖率 **142/142**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19` 为评审后新增,编号不连续,非笔误。)
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
# iBoard V0.2 任务模块 数据库设计文档
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
|
||||||
|
| 文档状态 | **设计基线**(待评审,未落库) |
|
||||||
|
| 数据库 | MariaDB / MySQL 8(Prisma `provider = "mysql"`) |
|
||||||
|
| 字符集 | `utf8mb4 / utf8mb4_unicode_ci` |
|
||||||
|
| 时区约定 | 应用层统一 `Asia/Shanghai`,库内 `DATETIME` 存 UTC |
|
||||||
|
| 相关文件 | `prisma/schema.prisma`、`prisma/migrations/20261024xxxxxx_add_task_module/migration.sql` |
|
||||||
|
| 关联文档 | `任务模块需求文档.md`、`任务模块设计文档-框架设计.md`、`任务模块功能设计.md`、`任务模块界面设计.html` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
任务模块新增 **6 张表**,并把既有 `User` 表补上反向关系(不改 `User` 结构):
|
||||||
|
|
||||||
|
| 表 | 作用 | 归属 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Project` | 项目(隔离与权限单位) | 项目域 |
|
||||||
|
| `ProjectMember` | 项目成员(权限关系) | 项目域 |
|
||||||
|
| `BoardColumn` | 看板列(名称 / 顺序 / 状态绑定 / 保留选项) | 看板域 |
|
||||||
|
| `Task` | 任务与子任务(自关联,一层) | 任务域 |
|
||||||
|
| `TaskMember` | 任务成员(多人标记) | 任务域 |
|
||||||
|
| `TaskTag` | 任务标签 | 任务域 |
|
||||||
|
|
||||||
|
### 1.1 设计原则
|
||||||
|
|
||||||
|
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` 上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。
|
||||||
|
4. **按项目隔离。** 所有业务表都可通过 `projectId` 归集,权限校验统一查 `ProjectMember`。
|
||||||
|
5. **软删除优先。** 项目用 `archived`;任务删除为级联物理删除(T-ST-11),但删除前有二次确认。
|
||||||
|
6. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 概念模型(ER)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────┐
|
||||||
|
│ User │ (既有表, 仅补反向关系)
|
||||||
|
└───┬────────┬───┘
|
||||||
|
│ │
|
||||||
|
┌──────────▼──┐ ┌──▼──────────────┐
|
||||||
|
│ProjectMember│ │ TaskMember │
|
||||||
|
│(权限关系) │ │ (任务成员) │
|
||||||
|
└──────┬──────┘ └──▲──────────────┘
|
||||||
|
│ * │ *
|
||||||
|
│ │
|
||||||
|
┌───▼──────┐ ┌─┴──────┐
|
||||||
|
│ Project │1─*│ Task │*─1┌────────────┐
|
||||||
|
└───┬──────┘ └───┬────┘ │ BoardColumn│
|
||||||
|
│ * │ * └────────────┘
|
||||||
|
┌────▼───────┐ │ ▲
|
||||||
|
│BoardColumn │1────*┘ │
|
||||||
|
└────────────┘ (columnId) │
|
||||||
|
│
|
||||||
|
┌────────┐ │
|
||||||
|
│TaskTag │*──────────────────┘
|
||||||
|
└────────┘
|
||||||
|
|
||||||
|
Task 自关联: parentId ── 子任务(一层, 仅顶层任务可作父)
|
||||||
|
```
|
||||||
|
|
||||||
|
基数说明:
|
||||||
|
|
||||||
|
| 关系 | 基数 | 级联 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| User → ProjectMember | 1 : N | Cascade |
|
||||||
|
| Project → ProjectMember | 1 : N | Cascade |
|
||||||
|
| Project → BoardColumn | 1 : N | Cascade |
|
||||||
|
| Project → Task | 1 : N | Cascade |
|
||||||
|
| BoardColumn → Task | 1 : N | **RESTRICT**(有任务的列不可直接删) |
|
||||||
|
| Task → Task(parent) | 1 : N | Cascade |
|
||||||
|
| Task → TaskMember | 1 : N | Cascade |
|
||||||
|
| Task → TaskTag | 1 : N | Cascade |
|
||||||
|
| User → Task(assignee / creator) | 1 : N | SetNull / Cascade(见下) |
|
||||||
|
|
||||||
|
> `BoardColumn → Task` 使用 `RESTRICT` 而非 `Cascade`:防止误删列时静默丢任务。删除列必须先在其接口内把任务迁移走(事务内改 `columnId`),迁移完成后再删列。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 表结构
|
||||||
|
|
||||||
|
### 3.1 `Project` — 项目
|
||||||
|
|
||||||
|
| 字段 | 类型 | 空 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | `INT` PK AI | 否 | — | 主键 |
|
||||||
|
| `name` | `VARCHAR(50)` | 否 | — | 项目名 |
|
||||||
|
| `ownerId` | `INT` | 否 | — | 创建人(可退出项目,故与成员表分离) |
|
||||||
|
| `isPersonal` | `BOOLEAN` | 否 | `false` | 个人默认项目:不可删、不可加成员(T-PJ-02/03) |
|
||||||
|
| `archived` | `BOOLEAN` | 否 | `false` | 归档(T-PJ-09) |
|
||||||
|
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
|
||||||
|
| `updatedAt` | `DATETIME(3)` | 否 | — | `@updatedAt` |
|
||||||
|
|
||||||
|
索引:
|
||||||
|
|
||||||
|
| 名称 | 字段 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `idx_project_owner` | `(ownerId)` | 查"我的项目" |
|
||||||
|
| `idx_project_archived` | `(archived)` | 归档分区 |
|
||||||
|
|
||||||
|
**约束说明**
|
||||||
|
- `isPersonal` 唯一性由应用层保证(每个用户至多一个):`UNIQUE(ownerId, isPersonal)` 不可用(普通项目也是 `false`),改为在创建时查询判重。
|
||||||
|
|
||||||
|
### 3.2 `ProjectMember` — 项目成员
|
||||||
|
|
||||||
|
| 字段 | 类型 | 空 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | `INT` PK AI | 否 | — | — |
|
||||||
|
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
|
||||||
|
| `userId` | `INT` | 否 | — | FK → `User.id` `ON DELETE CASCADE` |
|
||||||
|
| `permission` | `VARCHAR(16)` | 否 | `'member'` | 预留权限分级(T-PM-03),本期恒为 `member` |
|
||||||
|
| `createdAt` | `DATETIME(3)` | 否 | `now()` | 加入时间 |
|
||||||
|
|
||||||
|
索引:
|
||||||
|
|
||||||
|
| 名称 | 字段 | 唯一 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `uk_member_project_user` | `(projectId, userId)` | ✔ | 防重复加入;权限查询 |
|
||||||
|
| `idx_member_user` | `(userId)` | — | "我参与的项目" |
|
||||||
|
|
||||||
|
> **注意**:视图记忆与上次项目**不在此表**。视图记忆存设备端(T-VW-06、T-DT-06),早期草案把它放在 `ProjectMember` 上,已在评审中否决。
|
||||||
|
|
||||||
|
### 3.3 `BoardColumn` — 看板列
|
||||||
|
|
||||||
|
**本模块新增的核心实体**,承载三件事:显示顺序、状态绑定、已完成保留选项。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 空 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | `INT` PK AI | 否 | — | — |
|
||||||
|
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
|
||||||
|
| `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) |
|
||||||
|
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
|
||||||
|
| `updatedAt` | `DATETIME(3)` | 否 | — | — |
|
||||||
|
|
||||||
|
索引:
|
||||||
|
|
||||||
|
| 名称 | 字段 | 唯一 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `uk_column_project_name` | `(projectId, name)` | ✔ | 列名不重名(T-COL-07) |
|
||||||
|
| `idx_column_project_sort` | `(projectId, sortOrder)` | — | 看板渲染与归位查找 |
|
||||||
|
|
||||||
|
**默认数据**(新建项目时由应用层在事务内写入,T-COL-01、T-BD-09):
|
||||||
|
|
||||||
|
| sortOrder | name | boundStatus | retention |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | `Plan` | `todo` | `forever` |
|
||||||
|
| 1 | `Doing` | `doing` | `forever` |
|
||||||
|
| 2 | `Action` | `NULL` | `forever` |
|
||||||
|
| 3 | `Done` | `done` | `forever` |
|
||||||
|
|
||||||
|
> **为什么保留选项默认为 `forever` 而不是 `never`**:默认应"看得见",避免用户误以为数据丢失;隐藏是主动行为。
|
||||||
|
|
||||||
|
**约束与校验**
|
||||||
|
|
||||||
|
| 规则 | 实现层 |
|
||||||
|
| --- | --- |
|
||||||
|
| 列数 1~10(上限待确认,T-COL-06) | 应用层:新增/删除前计数校验 |
|
||||||
|
| 至少保留 1 列(T-EX-17) | 应用层:删除前 count > 1 |
|
||||||
|
| 删列必须指定迁移目标(T-EX-16) | 应用层:接口必须带 `moveToColumnId` |
|
||||||
|
| `boundStatus` 取值合法 | 应用层白名单校验 |
|
||||||
|
| `retention` 取值合法 | 应用层白名单校验 |
|
||||||
|
|
||||||
|
### 3.4 `Task` — 任务 / 子任务
|
||||||
|
|
||||||
|
| 字段 | 类型 | 空 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | `INT` PK AI | 否 | — | — |
|
||||||
|
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
|
||||||
|
| `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) |
|
||||||
|
| `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) |
|
||||||
|
| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02) |
|
||||||
|
| `description` | `TEXT` | 否 | `''` | — |
|
||||||
|
| `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) |
|
||||||
|
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
|
||||||
|
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
|
||||||
|
| `creatorId` | `INT` | 否 | — | 创建人;拷贝时为操作者(T-CP-04) |
|
||||||
|
| `extras` | `JSON` | 否 | `[]` | 扩展信息卡片 `[{label,value,url?}]`(T-TK-04/05) |
|
||||||
|
| `sortOrder` | `INT` | 否 | `0` | 列内/列表内排序 |
|
||||||
|
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
|
||||||
|
| `updatedAt` | `DATETIME(3)` | 否 | — | — |
|
||||||
|
|
||||||
|
外键与索引:
|
||||||
|
|
||||||
|
| 名称 | 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `fk_task_project` | `(projectId)` | Cascade | 删项目连带删任务 |
|
||||||
|
| `fk_task_column` | `(columnId)` | **Restrict** | 有任务的列不可直接删(见 §2 说明) |
|
||||||
|
| `fk_task_parent` | `(parentId)` | Cascade | 删父任务连带删子任务(T-EX-07) |
|
||||||
|
| `fk_task_assignee` | `(assigneeId)` | SetNull | 用户删除时清空负责人 |
|
||||||
|
| `fk_task_creator` | `(creatorId)` | Cascade | 用户删除时连带删其创建的任务* |
|
||||||
|
| `idx_task_project_column` | `(projectId, columnId)` | — | 看板按列查询 |
|
||||||
|
| `idx_task_project_status` | `(projectId, status)` | — | 列表/统计 |
|
||||||
|
| `idx_task_parent` | `(parentId)` | — | 取子任务 |
|
||||||
|
| `idx_task_done_at` | `(projectId, doneAt)` | — | 保留窗口计算 |
|
||||||
|
|
||||||
|
> \* `creatorId` 用 Cascade 会丢失"他人创建的任务"的历史;更稳妥的做法是 `Restrict` 并禁止删除有任务的用户。**建议改为 `Restrict`**,与既有 `User` 删除行为保持一致(本期系统无删除用户入口)。
|
||||||
|
|
||||||
|
**校验规则**
|
||||||
|
|
||||||
|
| 规则 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` trim 后非空且 ≤ 200 | 应用层 |
|
||||||
|
| `parentId` 非空时,其父任务 `parentId` 必须为空(禁止二级,T-ST-01) | 应用层 |
|
||||||
|
| 子任务 `projectId` == 父任务 `projectId`(T-EX-12) | 应用层 + 事务内校验 |
|
||||||
|
| `parentId` 不得指向自身或其后代(T-EX-15) | 应用层 |
|
||||||
|
| `status='done'` 时写 `doneAt=now()`;退出 `done` 时置 `NULL` | 应用层(领域统一裁决) |
|
||||||
|
| `assigneeId` 必须是 `ProjectMember` 中的用户(T-AS-03) | 应用层 |
|
||||||
|
|
||||||
|
### 3.5 `TaskMember` — 任务成员
|
||||||
|
|
||||||
|
| 字段 | 类型 | 空 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | `INT` PK AI | 否 | — | — |
|
||||||
|
| `taskId` | `INT` | 否 | — | FK → `Task.id` `ON DELETE CASCADE` |
|
||||||
|
| `userId` | `INT` | 否 | — | FK → `User.id` `ON DELETE CASCADE` |
|
||||||
|
|
||||||
|
索引:`uk_task_member (taskId, userId)` 唯一。
|
||||||
|
|
||||||
|
### 3.6 `TaskTag` — 任务标签
|
||||||
|
|
||||||
|
| 字段 | 类型 | 空 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | `INT` PK AI | 否 | — | — |
|
||||||
|
| `taskId` | `INT` | 否 | — | FK → `Task.id` `ON DELETE CASCADE` |
|
||||||
|
| `name` | `VARCHAR(20)` | 否 | — | 标签名 |
|
||||||
|
|
||||||
|
索引:`idx_tag_task (taskId)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Prisma Schema(草案)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Project {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
ownerId Int
|
||||||
|
isPersonal Boolean @default(false)
|
||||||
|
archived Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
owner User @relation("ProjectOwner", fields: [ownerId], references: [id])
|
||||||
|
members ProjectMember[]
|
||||||
|
columns BoardColumn[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([ownerId])
|
||||||
|
@@index([archived])
|
||||||
|
@@map("Project")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProjectMember {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
userId Int
|
||||||
|
permission String @default("member") @db.VarChar(16)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([projectId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("ProjectMember")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BoardColumn {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
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)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@unique([projectId, name])
|
||||||
|
@@index([projectId, sortOrder])
|
||||||
|
@@map("BoardColumn")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
columnId Int
|
||||||
|
parentId Int?
|
||||||
|
title String @db.VarChar(200)
|
||||||
|
description String @default("") @db.Text
|
||||||
|
status String @default("todo") @db.VarChar(8)
|
||||||
|
doneAt DateTime?
|
||||||
|
assigneeId Int?
|
||||||
|
creatorId Int
|
||||||
|
extras Json @default("[]")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
column BoardColumn @relation(fields: [columnId], references: [id], onDelete: Restrict)
|
||||||
|
parent Task? @relation("Subtask", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Task[] @relation("Subtask")
|
||||||
|
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||||
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
members TaskMember[]
|
||||||
|
tags TaskTag[]
|
||||||
|
|
||||||
|
@@index([projectId, columnId])
|
||||||
|
@@index([projectId, status])
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([projectId, doneAt])
|
||||||
|
@@map("Task")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskMember {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
taskId Int
|
||||||
|
userId Int
|
||||||
|
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([taskId, userId])
|
||||||
|
@@map("TaskMember")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskTag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
taskId Int
|
||||||
|
name String @db.VarChar(20)
|
||||||
|
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([taskId])
|
||||||
|
@@map("TaskTag")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`User` 需补反向关系(不改字段):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model User {
|
||||||
|
// …既有字段…
|
||||||
|
ownedProjects Project[] @relation("ProjectOwner")
|
||||||
|
projectMembers ProjectMember[]
|
||||||
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
|
taskMembers TaskMember[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 关键查询
|
||||||
|
|
||||||
|
| 场景 | 查询 |
|
||||||
|
| --- | --- |
|
||||||
|
| 我的项目 | `SELECT p.* FROM Project p JOIN ProjectMember m ON m.projectId=p.id WHERE m.userId=? AND NOT p.archived` |
|
||||||
|
| 项目详情 + 列 | `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 >= ?)`;前端另有一份同口径实现 |
|
||||||
|
| 自动归位选列 | 内存中对已加载的 `columns`(按 sortOrder)`find(c => c.boundStatus === status)` |
|
||||||
|
|
||||||
|
**防 N+1**:一次请求返回 `{ project, columns, members, tasks }`,子任务与父任务同批返回,由前端按 `parentId` 分组。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 迁移与初始化
|
||||||
|
|
||||||
|
### 6.1 迁移文件
|
||||||
|
|
||||||
|
`prisma/migrations/20261024xxxxxx_add_task_module/migration.sql`:
|
||||||
|
|
||||||
|
1. 建 `Project`、`ProjectMember`、`BoardColumn`、`Task`、`TaskMember`、`TaskTag` 六张表;
|
||||||
|
2. 建外键(注意 `Task.columnId` 为 `RESTRICT`);
|
||||||
|
3. 建唯一索引与普通索引;
|
||||||
|
4. **不做数据回填**(本模块为新功能,无历史数据)。
|
||||||
|
|
||||||
|
### 6.2 个人默认项目初始化
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 应用层逻辑(伪 SQL),首次访问 /api/projects 时执行
|
||||||
|
INSERT INTO Project (name, ownerId, isPersonal, createdAt, updatedAt)
|
||||||
|
SELECT CONCAT(u.name, '的任务'), u.id, 1, NOW(3), NOW(3)
|
||||||
|
FROM User u
|
||||||
|
WHERE u.id = ?
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM Project p WHERE p.ownerId = u.id AND p.isPersonal = 1);
|
||||||
|
|
||||||
|
-- 随后为该项目的 4 个默认列插入 BoardColumn(同事务)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 新建普通项目
|
||||||
|
|
||||||
|
同事务内:建 `Project`(`isPersonal=0`)→ 写 `ProjectMember(owner)` → 写 4 个默认 `BoardColumn`。**任一步失败整体回滚**,避免出现"无列项目"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 容量与性能估算
|
||||||
|
|
||||||
|
| 项 | 估算 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单项目任务 | 千级 / 年 | T-NF-05 |
|
||||||
|
| 单项目列 | ≤ 10 | T-COL-06 |
|
||||||
|
| `Task` 行宽 | ~400 B + JSON | 含 `extras` |
|
||||||
|
| 单项目年增量 | ≈ 400 KB | 1k 任务 × 400B |
|
||||||
|
| 索引开销 | 约表大小 30% | 4 个二级索引 |
|
||||||
|
|
||||||
|
**无需分表**;按 `projectId` 的查询均可走索引。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 与既有模块的关系
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `User` 表 | 仅补反向关系,**不改结构**,不影响打卡/宏观/监控模块 |
|
||||||
|
| 打卡模块(`Habit`/`CheckIn`) | 完全独立,同为"按用户隔离";任务模块改为"按项目隔离" |
|
||||||
|
| 迁移顺序 | 在 `20261022120000_add_checkin_checklist` 之后追加,互不依赖 |
|
||||||
|
| 时区 | 沿用 Asia/Shanghai 应用层口径;`doneAt` 用 `DATETIME(3)` 存 UTC |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 未决事项
|
||||||
|
|
||||||
|
| # | 事项 | 当前处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 列数上限是否 10 | 按 10 设计(T-COL-06 标注"待确认"),若定其他值只改常量 |
|
||||||
|
| 2 | `Task.creatorId` 外键行为 | 草案建议 `Restrict`(见 §3.4 注),需与"用户删除策略"一并确认 |
|
||||||
|
| 3 | 孤儿项目(无成员)的维护 | 本期不处理(T-PJ-08),由后台流程清理;数据库层保留数据 |
|
||||||
|
| 4 | `extras` 条目数量上限 | 建议 ≤ 20,未在需求中约束,设计预留 |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,422 @@
|
|||||||
|
# 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) |
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
# iBoard V0.2 任务模块 需求文档 — 轻量任务 / 项目管理
|
||||||
|
|
||||||
|
> 版本:V0.2 · 模块:任务(Task) · 日期:2026-10-24
|
||||||
|
> 适用范围:iBoard APP 内的任务模块(沿用现有项目框架、路由体系与登录态实现)
|
||||||
|
> 关联文档:《设计文档》《数据库设计文档》《界面设计》——接口清单在**设计阶段**重新设计,本文档不含接口定义
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体需求
|
||||||
|
|
||||||
|
### 1.1 一句话描述
|
||||||
|
|
||||||
|
在 iBoard 现有 APP 内新增一个**轻量级的项目与任务管理模块**:界面交互参考 Todoist,以**项目**为隔离单位组织任务,提供**列表 / 看板**双视图,并允许**每个项目独立配置看板列与任务状态的对应关系、以及每列已完成任务的保留选项**,用最小认知成本覆盖「个人琐事记录」与「多人协作推进」两类场景。
|
||||||
|
|
||||||
|
**「轻量」的含义**:不做需求管理、工时、甘特图、工作流引擎、审批流;字段克制、结构最多两层(任务 + 一级子任务)、权限不做细分角色。目标是用最少的配置让用户"打开就能记、记完就能推进"。
|
||||||
|
|
||||||
|
### 1.2 核心概念
|
||||||
|
|
||||||
|
| 概念 | 定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| **项目(Project)** | 任务归属的隔离单位,**也是权限单位**。用户只能看到自己是成员的项目;项目内成员权限目前均等 |
|
||||||
|
| **个人默认项目(Personal Project)** | 每个用户自动拥有且仅有一个的私有项目。**不可删除、不可添加其他成员**,名称以用户名开头且可自定义,用于个人琐碎事务 |
|
||||||
|
| **普通协作项目(Shared Project)** | 用户新建的项目,可增删成员、可归档 |
|
||||||
|
| **项目成员(Member)** | 用户与项目的多对多关联。成员的**存在**决定权限,「负责人 / 任务成员」字段只决定**展示** |
|
||||||
|
| **任务(Task)** | 一条事务记录。归属唯一项目;含基础字段 + 内置可折叠的**扩展信息卡片**;特例:子任务 |
|
||||||
|
| **一级子任务(Subtask)** | 挂在父任务下的任务。**最多一层**,不可再嵌套;必须与父任务同项目;完成状态与父任务**相互独立** |
|
||||||
|
| **负责人(Assignee)** | 任务上的**单人**标记,字号更大、视觉突出。**新建任务时默认填入创建者本人**,可清空 |
|
||||||
|
| **任务成员(Task Members)** | 任务上的**多人**标记,字号更小、弱化展示,位于卡片 / 条目下方 |
|
||||||
|
| **扩展信息卡片** | 任务内置的可折叠信息区,承载**任意名称 / 值 / 链接**的扩展信息(来源不限、字段不固定),**默认收起、全部非必填** |
|
||||||
|
| **看板列(Column)** | 项目级可配置的列,**默认 4 列** `Plan → Doing → Action → Done`;**可增删改、可调整顺序**,数量不固定 |
|
||||||
|
| **任务状态(Status)** | 任务的处理阶段,取值为 `未开始` / `执行中` / `完成`。**与看板列是两个独立概念**,二者的对应关系由项目配置决定 |
|
||||||
|
| **列-状态绑定(Column Status Binding)** | 项目级配置:**每一列独立**选择"是否绑定任务状态"以及"绑定到哪个状态"。绑定后列与状态双向联动;不绑定时二者完全独立 |
|
||||||
|
| **已完成但未绑定状态** | 任务状态为「完成」、但其所在看板列未绑定到「完成」的情形——此时列保持不变 |
|
||||||
|
| **看板列保留选项(Column Retention)** | 项目级配置:**每一列独立**设置已完成任务的保留时长,取值为「永不显示」/「当天」/「3 天」/「本周」/「本月」/「永久显示」六档,用于控制该列中已完成任务的可见性 |
|
||||||
|
| **视图记忆** | 设备端按项目独立保存"上次选择的视图(列表 / 看板)",进入项目自动恢复 |
|
||||||
|
| **上下文** | 用户当前所处的视图位置(某个项目 / 某个父任务的子任务列表 / 某个列表分区),用于解释"本次修改"相对哪些任务生效 |
|
||||||
|
|
||||||
|
### 1.3 完整功能
|
||||||
|
|
||||||
|
1. **视图体系**:列表视图、看板视图,可随时互相切换,同源数据、实时双向同步;每个项目独立记忆上次使用的视图。列表只是不按列分组,**任务归属的看板列与看板视图完全一致**。
|
||||||
|
2. **看板与任务状态的绑定配置**:
|
||||||
|
- 看板列**默认 4 列** `Plan → Doing → Action → Done`;列**可增删改、可调整顺序**,数量不固定。
|
||||||
|
- **每一列独立**选择是否绑定任务状态,并可选择绑定到 `未开始 / 执行中 / 完成` 中的任一个;**允许多列绑定同一状态**。
|
||||||
|
- **列决定状态**:任务拖入某列时,该列绑定了状态则状态随之改变;该列未绑定则**保留原状态**。
|
||||||
|
- **状态与列不匹配时自动归位**:状态改变后若当前所在列未绑定该状态,则按**当前列顺序**移入第一个绑定该状态的列;**没有匹配列就不移动**,保留当前位置。
|
||||||
|
- **默认**:`Plan` 绑定「未开始」、`Doing` 绑定「执行中」、`Done` 绑定「完成」、`Action` **不绑定**。
|
||||||
|
- 配置是**项目级**的,项目内任意成员均可修改,不同项目互不影响。
|
||||||
|
- **子任务不受列-状态绑定约束**,其状态可与父任务不一致。
|
||||||
|
3. **已完成任务显示控制**:**按看板列独立配置保留选项**——`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示` 六选一;超出保留选项的已完成任务在该列中自动隐藏。列表视图按其所属列的配置执行同样的显示控制。
|
||||||
|
4. **任务字段体系**:基础字段(标题必填,负责人默认本人)+ 内置可折叠**扩展信息卡片**(默认收起、全非必填);负责人(单人)与任务成员(多人)均为信息标记。
|
||||||
|
5. **负责人 & 成员展示规则**:负责人大字号突出、成员小字号弱化并置于下方;二者只能从**当前项目成员**中选择;不改变项目统一读写权限。
|
||||||
|
6. **子任务体系**:仅一级、必须同项目、父子完成状态互不联动;列表视图支持缩进与折叠展开;**看板卡片内以简洁形式嵌套展示子任务并可直接勾选**,完整详情在卡片弹窗内编辑;支持**长按拖拽把任务降为另一个任务的一级子任务**。
|
||||||
|
7. **项目体系**:个人默认项目(不可删、不可加成员、名称以用户名开头且可改名);普通协作项目(可增删成员);创建人可主动退出;成员清空后静默保留,交后台维护。
|
||||||
|
8. **项目权限**:以项目为隔离单位,只能看到自己是成员的项目;项目内成员权限均等、全读写;预留权限分级入口但不实现细分角色;任意成员均可归档项目。
|
||||||
|
9. **跨项目拷贝**:是**拷贝**不是移动;完整复制全部字段、扩展信息、子任务、负责人、成员、状态、看板列、标签;新任务创建人为执行拷贝者;拷贝后新旧任务完全独立。
|
||||||
|
10. **UI 交互规范**:参考 Todoist 的列表交互与子任务折叠风格;看板卡片轻量化;列表与看板同源、切换无刷新、数据一致;具备空状态 / 加载状态 / 数据异常状态。
|
||||||
|
11. **模块入口**:任务模块作为**顶级 Tab** 接入底部导航,位于**「打卡」之后**(顺序:打卡 → 任务 → 宏观 → 监控 → 我)。
|
||||||
|
|
||||||
|
### 1.4 典型用法
|
||||||
|
|
||||||
|
**场景 A · 个人琐事(个人默认项目)**
|
||||||
|
用户打开模块 → 进入自己的个人默认项目(如「张三的琐事」)→ 列表视图输入标题回车新增,**负责人自动是本人**,也可清空 → 需要推进时切到看板,把任务从 `Plan` 拖到 `Doing`。
|
||||||
|
因个人项目**不能加成员**,负责人 / 成员的可选范围只有自己,无需任何权限配置。
|
||||||
|
|
||||||
|
**场景 B · 多人协作与列-状态绑定**
|
||||||
|
用户新建普通项目「APP 改版」并加入 3 名同事。默认配置下:任务在 `Plan`=未开始、`Doing`=执行中、`Done`=完成;`Action` 不绑定状态,任务放进去后状态保持不变。
|
||||||
|
成员 A 把任务拖入 `Done` ⇒ **状态自动变为「完成」**;成员 B 在列表视图把某任务状态改为「完成」⇒ 任务**自动出现在 `Done` 列**。
|
||||||
|
若任务停留在 `Action`(不绑定)里被改成「完成」,因当前列与状态不匹配,系统**自动归位**到第一个绑定「完成」的列(默认即 `Done`);若该项目把「完成」的绑定全部取消,则**无匹配列,任务留在 `Action` 不动**。若把 `Action` 也设为绑定「完成」,则按列序仍先进 `Done`——这是**项目级**配置,不影响其他项目。
|
||||||
|
|
||||||
|
**场景 C · 扩展信息卡片**
|
||||||
|
新增任务「登录接口返回 500」→ 展开默认收起的**扩展信息卡片** → 按需添加任意条目,如「仓库 = gitlab/app」「分支 = fix/login-500」或一条相关链接(全部非必填,不填不影响任务本身)。
|
||||||
|
|
||||||
|
**场景 D · 跨项目复用**
|
||||||
|
把「APP 改版」里的某任务跨项目拷贝到「APP 二期」→ 生成一条**全新任务**,字段、扩展信息、子任务、负责人、成员、状态、看板列、标签全部复制,创建人记为执行拷贝的用户;此后两任务互不影响。
|
||||||
|
|
||||||
|
**场景 E · 按列控制已完成任务显示**
|
||||||
|
某项目的 `Done` 列已完成任务过多影响阅读 → 把该列保留选项设为「当天」→ 更早的已完成任务自动隐藏;同时把 `Action` 列设为「永不显示」,该列不再展示已完成任务;`Plan` 列保持「永久显示」不受影响。列表视图同样按各任务所属列的配置隐藏它们,与看板一致。切到另一个项目时,该项目各列配置保持自己的设置(互不干扰)。
|
||||||
|
|
||||||
|
**场景 F · 拖拽调整层级**
|
||||||
|
列表视图中长按任务卡片,向上拖动到另一个任务上并停留 → 该任务成为目标任务的**一级子任务**(缩进显示)。若目标任务本身已是子任务,或被拖任务已带子任务,则拒绝并给出提示(仅支持一级)。降为子任务后,它原有的看板列与状态保持不变。
|
||||||
|
|
||||||
|
**场景 G · 看板卡片内的子任务**
|
||||||
|
看板中某任务卡片下方以简洁样式嵌套显示其子任务 → 直接在卡片内勾选子任务完成,**父任务状态不受影响**,子任务状态也不受该列绑定规则约束;每个嵌套子任务带**自身状态小标记**,便于识别与父任务不一致的状态;需要编辑子任务标题或负责人时,点开卡片弹窗操作。
|
||||||
|
|
||||||
|
**场景 H · 从底部 Tab 进入**
|
||||||
|
底部导航顺序为「打卡 → **任务** → 宏观 → 监控 → 我」→ 点击「任务」即进入任务模块,落在上次访问的项目(记录在设备端;无记录则回到个人默认项目)。
|
||||||
|
|
||||||
|
### 1.5 不在本期范围(明确排除)
|
||||||
|
|
||||||
|
- 多层子任务(仅一级)。
|
||||||
|
- 角色 / 权限分级(只预留入口,本期全部成员权限均等)。
|
||||||
|
- 任务评论、@提及、附件上传、提醒通知、日历同步。
|
||||||
|
- 列内拖拽排序的精细规则(本期为简单位置值)。
|
||||||
|
- 甘特图、工时、报表统计。
|
||||||
|
- 项目成员清空后的数据清理(由后台维护,前端不做特殊处理)。
|
||||||
|
- 独立的 WebView / 登录态 / 返回键适配(**沿用现有项目框架**,不单独提出要求)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 术语与概念定义
|
||||||
|
|
||||||
|
| 术语 | 定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| **项目(Project)** | 任务与权限的隔离单位;用户只能看到自己为成员的项目 |
|
||||||
|
| **个人默认项目** | 每个用户唯一、自动创建、不可删除、不可添加成员的私有项目;`isPersonal = true` |
|
||||||
|
| **普通协作项目** | 可增删成员、可归档的项目;`isPersonal = false` |
|
||||||
|
| **项目成员(Member)** | `ProjectMember` 记录,决定**读写权限** |
|
||||||
|
| **负责人(Assignee)** | 任务上的单人标记(须为项目成员),决定列表 / 卡片视觉上的突出位置;新建时默认为创建者,可清空 |
|
||||||
|
| **任务成员(TaskMember)** | 任务上的多成员标记(须为项目成员),弱化展示,可为空 |
|
||||||
|
| **任务状态(status)** | `todo`(未开始)/ `doing`(执行中)/ `done`(完成),三值枚举 |
|
||||||
|
| **看板列(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` 永久显示 |
|
||||||
|
| **视图记忆(viewMode)** | `list` / `board`,**按项目独立记忆、保存在设备端** |
|
||||||
|
| **扩展信息卡片** | 任务的折叠信息区,存任意 `[{label, value, url?}]` 条目,默认收起、全部非必填 |
|
||||||
|
| **一级子任务** | `parentId` 非空的任务;其父任务必须为顶层任务,且同项目 |
|
||||||
|
| **上下文** | 本次操作所处位置(项目 / 父任务 / 列表分区),用于界定"从该列拖出"等相对语义的作用范围 |
|
||||||
|
| **拷贝(Copy)** | 跨项目生成新任务并复制全部内容;原任务不受影响 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 需求点
|
||||||
|
|
||||||
|
**编号规则**:`T-模块-序号`。前缀 `T` 表示本模块(Task / 任务)。模块:
|
||||||
|
|
||||||
|
| 模块码 | 模块名 | 对应 §1.3 条目 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `VW` | 视图体系 | 第 1 条 |
|
||||||
|
| `COL` | 看板列管理 | 第 2 条 |
|
||||||
|
| `BD` | 看板与列-状态绑定 | 第 2 条 |
|
||||||
|
| `FL` | 已完成任务显示控制(按列保留选项) | 第 3 条 |
|
||||||
|
| `TK` | 任务字段体系(含扩展信息卡片) | 第 4 条 |
|
||||||
|
| `AS` | 负责人与成员 | 第 5 条 |
|
||||||
|
| `ST` | 子任务体系 | 第 6 条 |
|
||||||
|
| `PJ` | 项目体系 | 第 7 条 |
|
||||||
|
| `PM` | 项目权限与模块入口 | 第 8 条、第 11 条 |
|
||||||
|
| `CP` | 跨项目拷贝 | 第 9 条 |
|
||||||
|
| `UI` | UI 交互规范 | 第 10 条 |
|
||||||
|
| `DT` | 数据需求 | — |
|
||||||
|
| `NF` | 非功能需求 | — |
|
||||||
|
| `EX` | 异常与边界 | — |
|
||||||
|
|
||||||
|
**优先级**:`P0` = 本期必须有;`P1` = 本期应该有;`P2` = 有则更好(可延后)。
|
||||||
|
|
||||||
|
**验收要点写法**:统一采用「给什么输入 / 做什么操作 → 期望得到什么可观察结果」,可直接转写为测试用例。
|
||||||
|
|
||||||
|
### 3.1 视图体系(T-VW)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-VW-01 | 项目内提供**列表视图**与**看板视图**两种呈现方式 | 同一项目的同一批任务,两种视图均能完整呈现,条数一致 | P0 |
|
||||||
|
| T-VW-02 | 可在列表 / 看板之间**互相切换**,切换过程**无整页刷新** | 切换不触发页面重载、不出现白屏,切换后滚动位置或回到顶部行为明确 | P0 |
|
||||||
|
| T-VW-03 | **视图记忆按单个项目独立保存**:A 项目选列表、B 项目选看板互不影响 | 项目 A 切到看板 → 进入项目 B → B 仍为 B 上次选择的视图;回到 A → 仍为看板 | P0 |
|
||||||
|
| T-VW-04 | 进入项目时**自动恢复**该项目上次选择的视图 | 退出模块后重新进入某项目,视图与上次退出时一致 | P0 |
|
||||||
|
| T-VW-05 | 从未选择过视图的新项目,**默认使用列表视图** | 新建项目首次进入为列表视图 | P1 |
|
||||||
|
| T-VW-06 | 视图记忆保存在**设备端**,不要求跨设备同步;本机跨会话保持 | 杀进程重进某项目,视图仍为上次选择;换设备登录不要求一致 | P0 |
|
||||||
|
| T-VW-07 | **上次访问的项目**记录在**设备端**;无记录时**一律回到个人默认项目** | 杀进程重进落在上次项目;首次使用或记录失效则进入个人默认项目 | P0 |
|
||||||
|
|
||||||
|
### 3.2 看板列管理(T-COL)
|
||||||
|
|
||||||
|
> 看板列是**项目级可配置实体**:默认 4 列,允许增删改与排序。列与「状态绑定」「保留选项」是三条正交的配置。
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-COL-01 | 新建项目**默认生成 4 列**:`Plan` / `Doing` / `Action` / `Done`,顺序即此 | 新项目看板渲染 4 列且名称、顺序与默认一致 | P0 |
|
||||||
|
| T-COL-02 | 支持**新增列**(自定义名称),新列默认追加在末尾 | 新增后看板多一列,位于最后一列之后,可正常拖入任务 | P0 |
|
||||||
|
| T-COL-03 | 支持**重命名列** | 改名后看板、列设置、任务归属处同步显示新名称 | P0 |
|
||||||
|
| T-COL-04 | 支持**删除列**;删除前需二次确认,并明确其中任务的处理方式 | 按 T-EX-16 约定迁移任务,不产生无归属任务 | P0 |
|
||||||
|
| T-COL-05 | 支持**调整列顺序**(拖动排序),顺序即看板显示顺序 | 拖动后顺序持久化,重新进入保持新顺序 | P0 |
|
||||||
|
| T-COL-06 | 列数量**不固定**,约束上下限:**至少 1 列、至多 10 列**(上限待确认) | 可增到多于 4 列;删到仅剩 1 列时禁止再删;达 10 列时禁止新增并提示 | P0 |
|
||||||
|
| T-COL-07 | 列名需**非空**且有长度上限,同一项目内**不允许重名** | 提交空名 / 重名时拒绝保存并提示 | P0 |
|
||||||
|
| T-COL-08 | 删除列时,若该列绑定了状态,绑定关系随之解除 | 删除绑定「完成」的列后,该状态不再有该列可归位 | P1 |
|
||||||
|
| T-COL-09 | 列配置属**项目级**,项目内任意成员可修改,不影响其他项目 | 成员 A 改列名,成员 B 同步可见;其他项目列不受影响 | P0 |
|
||||||
|
| T-COL-10 | 列表视图不展示列分组,但任务仍归属某一列(列可被增删后依然成立) | 删除/新增列后,列表视图中任务的列归属正确 | P0 |
|
||||||
|
|
||||||
|
### 3.3 看板与列-状态绑定(T-BD)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-BD-01 | 看板以**列**为单位组织任务,列由项目配置(默认 4 列,支持增删改与排序,详见 T-COL) | 看板按项目当前列配置渲染;调整列后任务归属随之正确呈现 | P0 |
|
||||||
|
| T-BD-02 | **每一列独立**配置是否绑定任务状态;绑定时可选择绑定到 `未开始` / `执行中` / `完成` 中任一个 | 每列都有独立的绑定开关与状态选择器;各列配置独立保存 | P0 |
|
||||||
|
| T-BD-03 | 允许**多列绑定到同一个状态**(如 `Done` 与 `Action` 都绑定「完成」) | 同一项目配置两列均绑定「完成」后,两列均能触发完成联动 | P0 |
|
||||||
|
| T-BD-04 | **绑定列 → 拖入即改状态**:任务落入绑定列时,任务状态改为该列所绑定的状态 | 把「未开始」任务拖入绑定「执行中」的列 → 状态变「执行中」;拖入绑定「完成」的列 → 状态变「完成」 | P0 |
|
||||||
|
| T-BD-05 | **状态 → 自动移入列**:任务状态改为某状态时,任务自动移入绑定该状态的列 | 列表视图把状态改为「完成」→ 切到看板,任务位于绑定「完成」的列 | P0 |
|
||||||
|
| T-BD-06 | 同一状态被多列绑定时,自动移入**按当前列顺序取第一个**匹配列,规则确定且可预期 | 两列均绑定「完成」时,任务状态改为「完成」后仅出现在列序靠前的那个绑定列,不重复出现 | P0 |
|
||||||
|
| T-BD-07 | **列决定状态,不看来源列**:拖动任务时,一律以「**目标列**的绑定配置」决定任务状态——目标列绑定了状态,则状态改为所绑定状态;**目标列未绑定,则任务保留原状态** | ①从绑定「完成」的列拖到绑定「执行中」的列 → 状态变「执行中」;②从任意列拖到**未绑定**的 `Action` → 状态保持不变 | P0 |
|
||||||
|
| T-BD-08 | **不绑定列**与任务状态**完全独立**:拖入、拖出该列以及在其他位置改变状态,均不改变该列归属以外的行为 | 把「执行中」任务拖入未绑定的 `Action` → 状态仍为「执行中」,仅列变化 | P0 |
|
||||||
|
| T-BD-14 | **状态与所在列不匹配时自动归位**:任务状态改变后,若其**当前所在列未绑定该状态**,则自动查找绑定该状态的列并将其移入 | 在未绑定的 `Action` 中把任务改为「完成」→ 任务自动移入绑定「完成」的列 | P0 |
|
||||||
|
| T-BD-15 | 自动归位时**按当前列的实际显示顺序取第一个匹配列**(列顺序可被用户调整) | 若两列都绑定「完成」,状态改为「完成」后移入排在前面那一列 | P0 |
|
||||||
|
| T-BD-16 | **无匹配列时任务不移动**,保留在原列(状态照常改变) | 取消所有列对「完成」的绑定后,把任务改为「完成」→ 状态为完成,列保持不变 | P0 |
|
||||||
|
| T-BD-09 | **默认配置**:`Plan` 绑定「未开始」、`Doing` 绑定「执行中」、`Done` 绑定「完成」、`Action` 不绑定 | 新建项目首次打开看板配置,四列取值与默认完全一致 | P0 |
|
||||||
|
| T-BD-10 | 绑定配置是**项目级**的,项目内**任意成员**均可修改,不同项目互不影响 | 成员 A 修改项目一配置成功;项目二配置不受影响;成员 B 也能修改 | P0 |
|
||||||
|
| T-BD-11 | 修改绑定配置**不批量改写**既有任务的状态与列,仅对之后的操作生效 | 把 `Action` 由不绑定改为绑定「完成」后,已在 `Action` 的任务状态保持原样 | P1 |
|
||||||
|
| T-BD-12 | 绑定配置变更后,看板与列表**即时一致**,无需手动刷新 | 保存配置后,看板列徽标与完成态立即反映新规则 | P1 |
|
||||||
|
| T-BD-13 | **子任务不受看板列-状态绑定影响**:子任务状态不因所在列而改写,也不触发「状态 → 移入列」 | 父任务在绑定「完成」的列,其子任务状态仍可为「执行中」且不被改写;修改子任务状态不使其离开当前列 | P0 |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
### 3.5 任务字段体系(T-TK)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-TK-01 | 任务包含**基础字段**:标题(必填)、描述、所属项目、看板列、任务状态、负责人、任务成员、标签、创建人、创建时间、更新时间 | 新建任务后上述字段均可正确保存与读取 | P0 |
|
||||||
|
| T-TK-02 | 标题为**唯一必填**字段,其余字段均可为空 | 仅填标题即可保存成功;仅清空标题时拒绝保存并提示 | P0 |
|
||||||
|
| T-TK-03 | 任务状态为三值枚举:`未开始` / `执行中` / `完成` | 任一任务的状态只能取这三个值之一,不存在第四种取值 | P0 |
|
||||||
|
| T-TK-04 | 内置**扩展信息卡片**:可增加任意数量的信息条目,承载开发源码等扩展信息 | 可新增 / 编辑 / 删除任意条目,条目数量不受固定字段限制 | P0 |
|
||||||
|
| T-TK-05 | 扩展信息条目支持**任意名称 + 值**,值可为单行文本或链接 | 可录入「仓库 = gitlab/xxx」「分支 = dev」「链接 = https://…」等自由内容 | P0 |
|
||||||
|
| T-TK-06 | 扩展信息卡片**可折叠**且**默认收起** | 首次打开任务详情,扩展区为收起状态;可展开 / 收起 | P0 |
|
||||||
|
| T-TK-07 | 扩展信息卡片**全部非必填**,全部留空不影响任务保存 | 只填标题保存后,扩展区为空仍成功 | P0 |
|
||||||
|
| T-TK-08 | 每条任务有**负责人(单人)** | 任务最多只有一个负责人,重复设置时以最后一次为准 | P0 |
|
||||||
|
| T-TK-09 | 新建任务时**负责人默认填入创建者本人** | 新建任务后未手动修改时,负责人即为创建者 | P0 |
|
||||||
|
| T-TK-10 | 负责人**允许清空为空** | 清空后任务可正常保存,展示为「未指派」 | P0 |
|
||||||
|
| T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 |
|
||||||
|
| T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 |
|
||||||
|
| T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | P1 |
|
||||||
|
|
||||||
|
### 3.6 负责人 & 成员 UI 展示规则(T-AS)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-AS-01 | **负责人**字号更大、视觉突出,位于条目 / 卡片的主要信息区 | 同名负责人与成员并排时,负责人字号明显更大且位于主位 | P0 |
|
||||||
|
| T-AS-02 | **任务成员**字号更小、弱化展示(低对比色),位于卡片 / 条目**下方** | 成员信息渲染在负责人 / 标题之下,视觉层次弱于负责人 | P0 |
|
||||||
|
| T-AS-03 | 负责人只能从**当前项目的成员**中选择 | 选择器只列出本项目成员;项目外用户无法被选中 | P0 |
|
||||||
|
| T-AS-04 | 任务成员只能从**当前项目的成员**中选择 | 选择器只列出本项目成员,不提供项目外用户 | P0 |
|
||||||
|
| T-AS-05 | 负责人为空(已清空)时展示「未指派」占位,正常渲染 | 清空负责人后条目 / 卡片显示「未指派」,可再次指派 | P0 |
|
||||||
|
| T-AS-06 | 人员被移出项目后,其负责人 / 成员标记按约定处理且不报错 | 见 T-EX-06 | P1 |
|
||||||
|
| T-AS-07 | 负责人、任务成员**仅为信息标记**,不改变项目统一读写权限 | 仅为负责人或仅为成员的用户,对项目读写能力与其他成员完全相同 | P0 |
|
||||||
|
|
||||||
|
### 3.7 子任务体系(T-ST)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-ST-01 | **仅支持一级子任务**,不支持多层嵌套 | 子任务上无「添加子任务」入口;后端拒绝以子任务为父任务创建任务 | P0 |
|
||||||
|
| T-ST-02 | 子任务**必须与父任务归属同一项目** | 尝试将子任务置于其他项目时被拒绝(含跨项目拷贝场景) | P0 |
|
||||||
|
| T-ST-03 | 父任务与子任务的**完成状态相互独立**、不自动联动 | 子任务全部完成,父任务状态不变;父任务完成,子任务状态不变 | P0 |
|
||||||
|
| T-ST-04 | **列表视图**支持子任务**缩进**展示 | 子任务在父任务下方缩进渲染,层级一眼可辨 | P0 |
|
||||||
|
| T-ST-05 | **列表视图**支持子任务**折叠 / 展开** | 折叠后子任务不显示且不占位;展开后恢复;折叠状态可感知 | P0 |
|
||||||
|
| T-ST-06 | **看板卡片内嵌套展示子任务**:含子任务的卡片中,子任务以**简洁形式嵌套**显示在任务卡片内 | 卡片内可见子任务列表(如紧凑单行:复选框 + 标题),样式轻量、不喧宾夺主 | P0 |
|
||||||
|
| T-ST-07 | 嵌套子任务可在**卡片内直接勾选完成**;完整详情(编辑标题 / 负责人 / 扩展信息等)在卡片弹窗内查看编辑 | 点击子任务复选框即切换其状态;点击卡片打开弹窗可编辑子任务全部字段 | P0 |
|
||||||
|
| T-ST-08 | **子任务状态与父任务状态可以不一致**,且子任务**不受看板列-状态绑定约束** | 父任务在 `Done`(绑定「完成」)时,其子任务可为「未开始」且不被改写 | P0 |
|
||||||
|
| T-ST-09 | 卡片内嵌套的子任务**显示自身状态小标记**(区分「未开始 / 执行中 / 完成」),以便识别与其父任务不一致的状态 | 父任务为完成、子任务为执行中时,卡片内可见子任务带「执行中」小标记 | P0 |
|
||||||
|
| T-ST-10 | 子任务不可作为看板独立卡片出现,只在其父任务卡片内嵌套 | 看板上一个任务只占一张卡,子任务不会单独成卡 | P1 |
|
||||||
|
| T-ST-11 | 删除父任务时**级联删除**其子任务 | 删除父任务后子任务一并删除,不产生孤儿数据 | P0 |
|
||||||
|
| T-ST-12 | **支持长按拖拽把任务降为另一个任务的一级子任务** | 列表视图长按任务 A 拖到任务 B 上释放 → A 成为 B 的子任务并缩进显示 | P0 |
|
||||||
|
| T-ST-13 | 拖拽成为子任务时,若目标任务**本身已是子任务**,则拒绝操作并提示 | 把任务拖到某个子任务上 → 操作被拒绝,给出「仅支持一级子任务」提示,数据不变 | P0 |
|
||||||
|
| T-ST-14 | 拖拽成为子任务时,若被拖任务是**有子任务的父任务**,则拒绝并提示(父任务不可整体挂到别的任务下) | 拖动含子任务的任务到其他任务上 → 拒绝,避免形成二级嵌套 | P0 |
|
||||||
|
| T-ST-15 | 拖拽成为子任务时,子任务的**看板列与状态保持自身原值**,仅改变父子归属 | 按 T-EX-13 约定处理,保证子任务与父任务同项目且层级合法 | P1 |
|
||||||
|
|
||||||
|
### 3.8 项目体系(T-PJ)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-PJ-01 | 每个用户**自动拥有且仅有一个个人默认项目** | 新用户首次进入模块即已存在个人项目;不可再创建第二个 | P0 |
|
||||||
|
| T-PJ-02 | 个人默认项目**不可删除** | 无删除入口;直接调用删除接口被拒绝 | P0 |
|
||||||
|
| T-PJ-03 | 个人默认项目**不能添加其他成员** | 成员管理入口不存在或不可用;调用添加成员接口被拒绝 | P0 |
|
||||||
|
| T-PJ-04 | 个人默认项目名称**以用户名开头**,且**整体名称可自定义修改** | 初始名称以用户名开头;改名后修改内容生效,仍保持「个人项目」性质 | P0 |
|
||||||
|
| T-PJ-05 | 支持**新建普通协作项目** | 可创建多个普通项目,创建者为成员 | P0 |
|
||||||
|
| T-PJ-06 | 普通项目**可以增删成员** | 添加 / 移除成员后,成员列表与可见性即时变化 | P0 |
|
||||||
|
| T-PJ-07 | **项目创建人可以主动退出项目** | 创建人退出后不再是成员,该项目不再对其可见 | P0 |
|
||||||
|
| T-PJ-08 | 项目**成员清空后静默保留**,交后台后续维护,前端不做特殊处理 | 成员全部退出后,前端不弹特殊提示、不出现错误;不可见 | P0 |
|
||||||
|
| T-PJ-09 | 项目可**归档**,归档项目与进行中项目区分展示 | 归档后从进行中列表移出,可在归档区找回 | P1 |
|
||||||
|
| T-PJ-10 | 项目列表按用户可见范围返回,仅含自己为成员的项目 | 非成员项目不出现在列表中 | P0 |
|
||||||
|
|
||||||
|
### 3.9 项目权限(T-PM)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-PM-01 | 权限**以项目为隔离单位**:用户只能看到自己是成员的项目及其任务 | 非成员访问某项目接口返回 403 / 404,列表不出现该项目 | P0 |
|
||||||
|
| T-PM-02 | 项目内**所有成员权限均等**,具备**全读写**能力 | 任意成员均可新增 / 编辑 / 删除任务、管理成员、修改列-状态绑定与保留选项配置 | P0 |
|
||||||
|
| T-PM-03 | 预留**权限分级入口**,本期**不实现**细分角色 | 数据结构或接口层保留扩展位;界面无角色分配功能 | P1 |
|
||||||
|
| T-PM-04 | 项目内**所有成员都可以归档项目** | 任意成员执行归档均成功 | P0 |
|
||||||
|
| T-PM-05 | 复用现有项目框架的登录态与鉴权,未登录访问被拒 | 无有效登录态访问返回 401 | P0 |
|
||||||
|
| T-PM-06 | 任务模块作为**顶级 Tab** 接入底部导航,位置在**「打卡」之后** | 底部 Tab 顺序为「打卡 → 任务 → 宏观 → 监控 → 我」;点击该 Tab 进入任务模块 | P0 |
|
||||||
|
| T-PM-07 | 进入任务模块后**默认落在上次访问的项目**(记录在**设备端**);**无记录时一律回到个人默认项目** | 首次进入 / 记录失效 → 个人默认项目;再次进入 → 上次离开时的项目;换设备不继承 | P0 |
|
||||||
|
|
||||||
|
### 3.10 跨项目拷贝(T-CP)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-CP-01 | 跨项目操作为**拷贝**,不是移动:源任务保留在原项目 | 拷贝后源任务仍存在于原项目且内容不变 | P0 |
|
||||||
|
| T-CP-02 | 拷贝时**完整复制全部字段** | 标题、描述、负责人、成员、标签、任务状态、看板列、扩展信息卡片逐项比对一致 | P0 |
|
||||||
|
| T-CP-03 | 拷贝时**完整复制子任务** | 子任务条数、标题、状态、列归属均与源任务一致 | P0 |
|
||||||
|
| T-CP-04 | 拷贝后**新任务的创建人**为本次执行拷贝操作的用户 | 新任务创建人 = 操作者,非源任务创建人 | P0 |
|
||||||
|
| T-CP-05 | 拷贝完成后**新旧任务互相独立**,后续修改互不影响 | 修改新任务标题 / 状态,源任务不变;反之亦然 | P0 |
|
||||||
|
| T-CP-06 | 拷贝目标必须是操作者**有权限**的项目 | 目标项目非本人成员时拒绝并提示 | P0 |
|
||||||
|
| T-CP-07 | 拷贝时负责人 / 成员若不属目标项目成员,按约定处理 | 按 T-EX-08 约定处理,不产生「项目外人」脏数据 | P1 |
|
||||||
|
| T-CP-08 | 拷贝包含子任务时,子任务与父任务**同属目标项目** | 拷贝后子任务项目归属与父任务一致 | P0 |
|
||||||
|
| T-CP-09 | 拷贝时若目标任务项目的**列-状态绑定规则不同**,按 T-EX-14 约定处理 | 状态与列不出现互相矛盾的组合 | P1 |
|
||||||
|
|
||||||
|
### 3.11 UI 交互规范(T-UI)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-UI-01 | 整体界面、列表交互、子任务折叠风格**参考 Todoist** | 列表条目结构、快速新增、勾选完成、子任务折叠的交互与 Todoist 同类操作一致 | P0 |
|
||||||
|
| T-UI-02 | **看板卡片轻量化**,样式简洁 | 卡片仅承载必要信息,无多余装饰;信息层级清晰 | P0 |
|
||||||
|
| T-UI-03 | 列表与看板**同源数据**,修改**实时双向同步** | 列表改标题 / 状态 → 切到看板立即可见;看板改 → 切到列表立即可见 | P0 |
|
||||||
|
| T-UI-04 | 视图切换**无刷新**、数据一致 | 切换过程中不发起整页加载,数据不发生丢失或错位 | P0 |
|
||||||
|
| T-UI-05 | 移动端适配:复用现有样式体系与底部导航,安全区不遮挡内容 | 375~430px 视口无横向滚动、无内容被遮挡 | P0 |
|
||||||
|
| T-UI-06 | 勾选完成有即时反馈(视觉状态变化),不阻塞后续操作 | 点击勾选后立即变化,无需等待接口返回才能继续操作 | P1 |
|
||||||
|
| T-UI-07 | 看板拖拽在移动端支持**长按拖拽**,并提供卡片「移动到」快捷项作为兜底 | 长按可拖起卡片;无法拖拽的场景可用「移动到」菜单完成同效果 | P0 |
|
||||||
|
| T-UI-08 | 具备**空状态**展示(无项目 / 无任务 / 无子任务等) | 各层级无数据时展示空态与引导入口,不出现空白页 | P0 |
|
||||||
|
| T-UI-09 | 具备**加载状态**展示 | 首屏与切换视图时展示加载态,不出现闪烁或错乱 | P0 |
|
||||||
|
| T-UI-10 | 具备**数据异常状态**展示,可重试 | 接口失败时展示异常态与重试入口,重试成功后恢复 | P0 |
|
||||||
|
|
||||||
|
### 3.12 数据需求(T-DT)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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-04 | 新增 Prisma 模型:`Task`(含 `parentId` 自关联、`assigneeId`、`status`、看板列、扩展信息卡片、`sortOrder`) | 字段见附录 A | P0 |
|
||||||
|
| T-DT-05 | 新增 Prisma 模型:`TaskMember`(任务成员关联)、扩展信息卡片结构、标签结构 | 字段见附录 A | P0 |
|
||||||
|
| T-DT-06 | 视图记忆按**设备端**存储,不要求落库 | 换设备登录不要求视图一致 | P0 |
|
||||||
|
| T-DT-07 | 建立必要索引:任务按项目查询、按父子关系查询、按状态查询 | 列表 / 看板 / 显示控制查询均走索引 | P0 |
|
||||||
|
| T-DT-08 | 提供迁移 SQL 并纳入 `prisma migrate deploy` 流程 | 与现有迁移流程一致 | P0 |
|
||||||
|
| T-DT-09 | 归档采用软删除语义,历史数据保留 | 归档不物理删除任务与项目数据 | P1 |
|
||||||
|
|
||||||
|
### 3.13 非功能需求(T-NF)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-NF-01 | 项目内任务列表首屏加载 < 1.5s(正常网络),交互响应 < 300ms | 真机可感知流畅 | P0 |
|
||||||
|
| T-NF-02 | 兼容当前 APP 的 WebView 运行环境与移动端浏览器 | 真机验证通过 | P0 |
|
||||||
|
| T-NF-03 | 前端代码组织遵循现有约定:`src/pages/task/*` + `src/components/*` + `server/routes/task*.js` | 结构一致 | P0 |
|
||||||
|
| T-NF-04 | 路由与接口挂载**沿用现有项目框架**的组织方式 | 与现有模块的组织方式一致 | P0 |
|
||||||
|
| T-NF-05 | 数据量按「单项目千级任务 / 年」设计,无需分表 | 千级任务下列表与看板可用 | P1 |
|
||||||
|
|
||||||
|
### 3.14 异常与边界(T-EX)
|
||||||
|
|
||||||
|
| 编号 | 需求描述 | 处理约定 | 优先级 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T-EX-01 | 重复点击勾选 / 重复拖拽 | 前端防抖 + 后端幂等,状态最终一致 | P0 |
|
||||||
|
| T-EX-02 | 接口失败 / 断网 | 乐观更新回滚 + 提示,可重试 | P0 |
|
||||||
|
| T-EX-03 | 绑定配置被改为「不绑定」时,已在该列的已完成任务 | 保持当前列与状态不变,等待用户后续操作 | P0 |
|
||||||
|
| T-EX-04 | 任务状态与所在列绑定不一致(如在不绑定的 `Action` 中改为「完成」) | 触发自动归位:移入第一个绑定该状态的列;**无匹配列则不移动**,保留原列(见 T-BD-14~16) | P0 |
|
||||||
|
| T-EX-05 | 任务被拖到**未绑定状态**的列(如 `Action`),其原状态如何处理 | 原状态**保持不变**,不因离开原列而回退(不存在「拖出即回退」规则) | P0 |
|
||||||
|
| T-EX-06 | 项目成员被移除,其仍担任某任务负责人 / 成员 | 保留历史标记,展示为「已退出」样式;不报错、不清空 | P1 |
|
||||||
|
| T-EX-07 | 删除父任务 | 级联删除其子任务,确认弹窗提示「将一并删除 N 条子任务」 | P0 |
|
||||||
|
| T-EX-08 | 跨项目拷贝时负责人 / 成员不属于目标项目 | 仅保留属于目标项目成员者,其余丢弃并在结果中提示 | P1 |
|
||||||
|
| T-EX-09 | 个人默认项目尝试添加成员 / 尝试删除 | 接口返回明确错误,前端不提供入口 | P0 |
|
||||||
|
| T-EX-10 | 某列设为「当天」等档位且该列无符合条件的已完成任务 | 展示该列空态,不报错 | P0 |
|
||||||
|
| T-EX-11 | 项目成员清空后的项目 | 静默保留,前端不报错、不特殊提示 | P0 |
|
||||||
|
| T-EX-12 | 子任务所属项目与父任务不一致(绕过前端直接调用) | 后端拒绝并返回错误 | P0 |
|
||||||
|
| T-EX-13 | 拖拽成为子任务时,子任务的**看板列与状态**如何取值 | 保持自身原值不变,仅改变父子归属 | P1 |
|
||||||
|
| T-EX-14 | 跨项目拷贝时,源任务的状态 / 列在目标项目绑定规则下无对应关系 | 沿用源值落库,不强行纠正;由后续用户操作自然修正 | P1 |
|
||||||
|
| T-EX-15 | 拖拽将任务放到自身或其后代上 | 拒绝操作并提示,避免形成环形父子关系 | P0 |
|
||||||
|
| T-EX-16 | 删除仍有任务的列 | 二次确认后,该列任务迁移至**相邻列**(或由用户指定目标列),不产生无归属任务 | P0 |
|
||||||
|
| T-EX-17 | 删除唯一剩余的列 | **拒绝操作**并提示「至少保留一列」 | P0 |
|
||||||
|
| T-EX-19 | 新增列时已达列数上限 | 拒绝新增并提示已达上限,不产生空列 | P1 |
|
||||||
|
| T-EX-18 | 已完成任务跨周期(周一零点 / 月初零点)后应隐藏 | **当场消失**并带移除动画,其余任务平滑重排,不留空白 | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 优先级汇总(本期交付范围)
|
||||||
|
|
||||||
|
- **P0(必须)**:T-VW-01~04、T-VW-06、T-VW-07;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;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~10;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17。
|
||||||
|
- **P1(应该)**:T-VW-05;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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 已决策问题(评审记录)
|
||||||
|
|
||||||
|
以下问题已在 2026-10-24 评审中确认,作为实现依据:
|
||||||
|
|
||||||
|
| # | 问题 | 决策 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 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) |
|
||||||
|
| 4 | 删除父任务时子任务如何处理? | **级联删除**,弹窗提示条数(T-EX-07) |
|
||||||
|
| 5 | 已完成任务在看板中的呈现? | 由**该列的保留选项**控制显示与隐藏(T-FL-01~10) |
|
||||||
|
| 6 | 看板拖拽的移动端交互? | **长按拖拽** + 「移动到」兜底;**允许拖拽降为子任务**(T-UI-07、T-ST-12) |
|
||||||
|
| 7 | 项目成员清空后是否需要本模块的管理入口? | **本期不承担**,交后台;前端静默(T-PJ-08) |
|
||||||
|
| 8 | 扩展信息组的具体字段清单? | **不固定字段**,改为**任意扩展信息卡片**(T-TK-04、05) |
|
||||||
|
| 9 | 已完成显示策略的粒度? | 改为**按看板列独立的保留选项**(T-FL-01、02) |
|
||||||
|
| 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) |
|
||||||
|
| 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) |
|
||||||
|
| 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) |
|
||||||
|
| 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) |
|
||||||
|
| 28 | 看板列数量上限? | 暂定**上限 10 列**(`待确认`,见下) |
|
||||||
|
|
||||||
|
**仍待评审确认(影响实现口径):**
|
||||||
|
|
||||||
|
| # | 问题 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | 列数量上限是否按 10 列? | 现按「至少 1 列、至多 10 列」写入(T-COL-06)。若无需上限,可去掉上限约束 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:核心实体草案(供设计阶段参考,非最终表结构)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// 项目
|
||||||
|
model Project {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
ownerId Int // 创建人 (可退出项目, 故与成员表分离)
|
||||||
|
isPersonal Boolean @default(false) // true = 个人默认项目: 不可删、不可加成员
|
||||||
|
archived Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
members ProjectMember[]
|
||||||
|
columns BoardColumn[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([archived])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 看板列 (项目级实体: 默认 4 列, 可增删改与排序)
|
||||||
|
model BoardColumn {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
name String // 列名, 项目内唯一
|
||||||
|
sortOrder Int @default(0) // 显示顺序, 决定自动归位时的"第一个匹配列"
|
||||||
|
boundStatus String? // null = 不绑定; todo | doing | done
|
||||||
|
retention String @default("forever") // never | today | 3d | week | month | forever
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@unique([projectId, name])
|
||||||
|
@@index([projectId, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目成员 (权限单位: 在表内即全读写)
|
||||||
|
model ProjectMember {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
permission String @default("member") // 预留权限分级入口, 本期恒为 member
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([projectId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务 / 子任务 (parentId 为空 = 顶层任务)
|
||||||
|
model Task {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
parentId Int? // 非空 = 一级子任务 (最多一层)
|
||||||
|
parent Task? @relation("Subtask", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Task[] @relation("Subtask")
|
||||||
|
|
||||||
|
title String
|
||||||
|
description String @default("")
|
||||||
|
status String @default("todo") // todo | doing | done
|
||||||
|
columnId Int // 所属看板列 (BoardColumn), 列可增删改故用 id 而非固定名
|
||||||
|
column BoardColumn @relation(fields: [columnId], references: [id])
|
||||||
|
|
||||||
|
assigneeId Int? // 负责人 (可空; 新建默认 = 创建者)
|
||||||
|
assignee User? @relation("Assignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||||
|
creatorId Int // 创建人 (跨项目拷贝时 = 操作者)
|
||||||
|
creator User @relation("Creator", fields: [creatorId], references: [id])
|
||||||
|
|
||||||
|
// 扩展信息卡片: 任意条目, 默认收起
|
||||||
|
extras Json @default("[]") // [{ label: "仓库", value: "gitlab/app", url: "" }]
|
||||||
|
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
members TaskMember[]
|
||||||
|
tags TaskTag[]
|
||||||
|
|
||||||
|
@@index([projectId, columnId])
|
||||||
|
@@index([projectId, status])
|
||||||
|
@@index([parentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务成员 (多人, 须为项目成员)
|
||||||
|
model TaskMember {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
taskId Int
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([taskId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务标签
|
||||||
|
model TaskTag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
taskId Int
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
@@index([taskId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 说明:
|
||||||
|
> 1. `status` 与 `columnId` **两个字段都落库**(而非互相推导),因为未绑定的列需要二者彼此独立;联动仅在项目配置了绑定的列/状态之间发生,由服务端统一裁决。
|
||||||
|
> 2. 保留选项的判定依赖「在该列的完成时间」,建议在任务上记录完成时间或按列迁移时间计算,具体口径在设计阶段确定。
|
||||||
|
> 3. 视图记忆存设备端(localStorage),不落库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 B:实现约定(开发期补充,用于消除口径歧义)
|
||||||
|
|
||||||
|
| # | 约定 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B-1 | **看板列** | 列是项目级实体(`BoardColumn`),含名称与顺序;默认 4 列 `Plan/Doing/Action/Done`;支持增删改与排序,至少保留 1 列 |
|
||||||
|
| B-2 | **列-状态绑定** | `columnBindings` 为「列 → 状态或 null」映射;null 表示不绑定;**允许多列指向同一状态**;状态枚举 `todo` / `doing` / `done` |
|
||||||
|
| 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-7 | **子任务与绑定无关** | 子任务状态**不受**看板列-状态绑定影响:拖入列不改写子任务状态,修改子任务状态也不触发「移入列」 |
|
||||||
|
| B-8 | **子任务约束** | `parentId` 非空时:父任务 `parentId` 必须为空(禁止二级);`projectId` 必须与父任务一致;禁止指向自身或后代 |
|
||||||
|
| B-9 | **拖拽降为子任务** | 长按拖动任务 A 到任务 B 上释放 ⇒ A.parentId = B.id;要求 B 为顶层任务且 A 本身无子任务,否则拒绝 |
|
||||||
|
| B-10 | **负责人默认值** | 新建任务时若未显式指定,`assigneeId` 默认取创建者的 userId;显式传 null 表示清空 |
|
||||||
|
| B-11 | **个人默认项目** | `isPersonal = true`;服务端拒绝「添加成员」「删除项目」;名称初始为 `{用户名}的任务`,可改名 |
|
||||||
|
| 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-17 | **删除列的迁移** | 删除列前需二次确认;列内任务迁移至指定列(默认相邻列),迁移后任务状态按目标列绑定重新裁决;**唯一剩余列不可删除** |
|
||||||
|
| B-18 | **新增列默认值** | 新增列取默认值:`retention = forever`、`boundStatus = null`,**不继承**相邻列配置;`sortOrder` 追加到末尾 |
|
||||||
|
| B-19 | **列数上限** | 至少 1 列、至多 10 列(上限待确认) |
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
-- 任务模块 (V0.2): 项目 / 成员 / 看板列 / 任务 / 任务成员 / 标签
|
||||||
|
-- 设计口径见 docs/V0.2/任务模块/任务模块数据库设计文档.md
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Project` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(50) NOT NULL,
|
||||||
|
`ownerId` INTEGER NOT NULL,
|
||||||
|
`isPersonal` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`archived` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `Project_ownerId_idx`(`ownerId`),
|
||||||
|
INDEX `Project_archived_idx`(`archived`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ProjectMember` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`projectId` INTEGER NOT NULL,
|
||||||
|
`userId` INTEGER NOT NULL,
|
||||||
|
`permission` VARCHAR(191) NOT NULL DEFAULT 'member',
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `ProjectMember_projectId_userId_key`(`projectId`, `userId`),
|
||||||
|
INDEX `ProjectMember_userId_idx`(`userId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BoardColumn` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`projectId` INTEGER NOT NULL,
|
||||||
|
`name` VARCHAR(20) NOT NULL,
|
||||||
|
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`boundStatus` VARCHAR(8) NULL,
|
||||||
|
`retention` VARCHAR(8) NOT NULL DEFAULT 'forever',
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `BoardColumn_projectId_name_key`(`projectId`, `name`),
|
||||||
|
INDEX `BoardColumn_projectId_sortOrder_idx`(`projectId`, `sortOrder`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Task` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`projectId` INTEGER NOT NULL,
|
||||||
|
`columnId` INTEGER NOT NULL,
|
||||||
|
`parentId` INTEGER NULL,
|
||||||
|
`title` VARCHAR(200) NOT NULL,
|
||||||
|
`description` TEXT NOT NULL,
|
||||||
|
`status` VARCHAR(8) NOT NULL DEFAULT 'todo',
|
||||||
|
`doneAt` DATETIME(3) NULL,
|
||||||
|
`assigneeId` INTEGER NULL,
|
||||||
|
`creatorId` INTEGER NOT NULL,
|
||||||
|
`extras` JSON NOT NULL,
|
||||||
|
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `Task_projectId_columnId_idx`(`projectId`, `columnId`),
|
||||||
|
INDEX `Task_projectId_status_idx`(`projectId`, `status`),
|
||||||
|
INDEX `Task_parentId_idx`(`parentId`),
|
||||||
|
INDEX `Task_projectId_doneAt_idx`(`projectId`, `doneAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `TaskMember` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`taskId` INTEGER NOT NULL,
|
||||||
|
`userId` INTEGER NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `TaskMember_taskId_userId_key`(`taskId`, `userId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `TaskTag` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`taskId` INTEGER NOT NULL,
|
||||||
|
`name` VARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `TaskTag_taskId_idx`(`taskId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Project` ADD CONSTRAINT `Project_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ProjectMember` ADD CONSTRAINT `ProjectMember_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ProjectMember` ADD CONSTRAINT `ProjectMember_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BoardColumn` ADD CONSTRAINT `BoardColumn_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Task` ADD CONSTRAINT `Task_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Task` ADD CONSTRAINT `Task_columnId_fkey` FOREIGN KEY (`columnId`) REFERENCES `BoardColumn`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Task` ADD CONSTRAINT `Task_parentId_fkey` FOREIGN KEY (`parentId`) REFERENCES `Task`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Task` ADD CONSTRAINT `Task_assigneeId_fkey` FOREIGN KEY (`assigneeId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Task` ADD CONSTRAINT `Task_creatorId_fkey` FOREIGN KEY (`creatorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `TaskMember` ADD CONSTRAINT `TaskMember_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `Task`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `TaskMember` ADD CONSTRAINT `TaskMember_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `TaskTag` ADD CONSTRAINT `TaskTag_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `Task`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -25,6 +25,13 @@ model User {
|
|||||||
checkIns CheckIn[]
|
checkIns CheckIn[]
|
||||||
checkLists CheckList[]
|
checkLists CheckList[]
|
||||||
templates CheckListTemplate[]
|
templates CheckListTemplate[]
|
||||||
|
|
||||||
|
// V0.2 任务模块
|
||||||
|
ownedProjects Project[] @relation("ProjectOwner")
|
||||||
|
projectMembers ProjectMember[]
|
||||||
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
|
taskMembers TaskMember[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Article {
|
model Article {
|
||||||
@@ -374,4 +381,108 @@ model CheckListTemplate {
|
|||||||
@@index([userId, builtin])
|
@@index([userId, builtin])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================== 任务模块 (V0.2 任务模块需求文档 T-*) ==================
|
||||||
|
// 口径见 docs/V0.2/任务模块/任务模块数据库设计文档.md
|
||||||
|
// 项目 = 隔离 + 权限单位; 看板列是实体(可增删改排序);
|
||||||
|
// 任务状态(todo/doing/done) 与 看板列 是两个正交维度, 由列的 boundStatus 决定是否联动.
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
ownerId Int
|
||||||
|
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
isPersonal Boolean @default(false)
|
||||||
|
archived Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
members ProjectMember[]
|
||||||
|
columns BoardColumn[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([ownerId])
|
||||||
|
@@index([archived])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProjectMember {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
permission String @default("member")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([projectId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BoardColumn {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
name String @db.VarChar(20)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
boundStatus String? @db.VarChar(8)
|
||||||
|
retention String @default("forever") @db.VarChar(8)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@unique([projectId, name])
|
||||||
|
@@index([projectId, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
columnId Int
|
||||||
|
column BoardColumn @relation(fields: [columnId], references: [id], onDelete: Restrict)
|
||||||
|
parentId Int?
|
||||||
|
parent Task? @relation("Subtask", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Task[] @relation("Subtask")
|
||||||
|
|
||||||
|
title String @db.VarChar(200)
|
||||||
|
description String @default("") @db.Text
|
||||||
|
status String @default("todo") @db.VarChar(8)
|
||||||
|
doneAt DateTime?
|
||||||
|
|
||||||
|
assigneeId Int?
|
||||||
|
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||||
|
creatorId Int
|
||||||
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
extras Json @default("[]")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
members TaskMember[]
|
||||||
|
tags TaskTag[]
|
||||||
|
|
||||||
|
@@index([projectId, columnId])
|
||||||
|
@@index([projectId, status])
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([projectId, doneAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskMember {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
taskId Int
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([taskId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskTag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
taskId Int
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
name String @db.VarChar(20)
|
||||||
|
|
||||||
|
@@index([taskId])
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import monitorsRouter from './routes/monitors.js'
|
|||||||
import mihomeRouter from './routes/mihome.js'
|
import mihomeRouter from './routes/mihome.js'
|
||||||
import checkinRouter from './routes/checkin.js'
|
import checkinRouter from './routes/checkin.js'
|
||||||
import checklistsRouter from './routes/checklists.js'
|
import checklistsRouter from './routes/checklists.js'
|
||||||
|
import projectsRouter, { columnRouter } from './routes/projects.js'
|
||||||
|
import tasksRouter from './routes/tasks.js'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3001
|
const PORT = Number(process.env.PORT) || 3001
|
||||||
const DIST_DIR = path.join(process.cwd(), 'dist')
|
const DIST_DIR = path.join(process.cwd(), 'dist')
|
||||||
@@ -41,6 +43,10 @@ app.use('/api/mihome', mihomeRouter)
|
|||||||
// V0.2 打卡 / 日历 / 清单
|
// V0.2 打卡 / 日历 / 清单
|
||||||
app.use('/api/checkin', checkinRouter)
|
app.use('/api/checkin', checkinRouter)
|
||||||
app.use('/api/checklists', checklistsRouter)
|
app.use('/api/checklists', checklistsRouter)
|
||||||
|
// V0.2 任务模块: 项目 / 列 / 任务
|
||||||
|
app.use('/api/projects', projectsRouter)
|
||||||
|
app.use('/api/columns', columnRouter)
|
||||||
|
app.use('/api', tasksRouter)
|
||||||
|
|
||||||
// 静态资源 (Vite build 产物)
|
// 静态资源 (Vite build 产物)
|
||||||
if (HAS_DIST) {
|
if (HAS_DIST) {
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// 任务模块: 项目 / 成员 / 看板列
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
import { authRequired } from '../middleware.js'
|
||||||
|
import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(authRequired)
|
||||||
|
|
||||||
|
// 新建项目时的默认 4 列 (T-COL-01, T-BD-09)
|
||||||
|
export const DEFAULT_COLUMNS = [
|
||||||
|
{ name: 'Plan', sortOrder: 0, boundStatus: 'todo' },
|
||||||
|
{ name: 'Doing', sortOrder: 1, boundStatus: 'doing' },
|
||||||
|
{ name: 'Action', sortOrder: 2, boundStatus: null },
|
||||||
|
{ name: 'Done', sortOrder: 3, boundStatus: 'done' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MAX_COLUMNS = 10
|
||||||
|
const MAX_NAME = 20
|
||||||
|
|
||||||
|
/** 建 4 个默认列 */
|
||||||
|
async function createDefaultColumns(projectId) {
|
||||||
|
await prisma.boardColumn.createMany({
|
||||||
|
data: DEFAULT_COLUMNS.map((c) => ({ ...c, projectId, retention: 'forever' })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保用户有一个个人默认项目 (T-PJ-01), 幂等 */
|
||||||
|
export async function ensurePersonalProject(userId) {
|
||||||
|
const exist = await prisma.project.findFirst({ where: { ownerId: userId, isPersonal: true } })
|
||||||
|
if (exist) return exist
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } })
|
||||||
|
const name = `${user?.name || '我'}的任务`
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { name, ownerId: userId, isPersonal: true, members: { create: { userId } } },
|
||||||
|
})
|
||||||
|
await createDefaultColumns(project.id)
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectSummary(p) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
ownerId: p.ownerId,
|
||||||
|
isPersonal: p.isPersonal,
|
||||||
|
archived: p.archived,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
memberCount: p._count?.members ?? undefined,
|
||||||
|
taskCount: p._count?.tasks ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 项目 ----------
|
||||||
|
|
||||||
|
// GET /api/projects 我参与的项目
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
await ensurePersonalProject(userId)
|
||||||
|
const rows = await prisma.project.findMany({
|
||||||
|
where: { members: { some: { userId } } },
|
||||||
|
include: { _count: { select: { members: true, tasks: true } } },
|
||||||
|
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
|
||||||
|
})
|
||||||
|
res.json({ items: rows.map(projectSummary) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/projects { name }
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const name = String(req.body?.name || '').trim()
|
||||||
|
if (!name) return res.status(400).json({ error: '项目名称不能为空' })
|
||||||
|
if (name.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
||||||
|
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { name, ownerId: userId, isPersonal: false, members: { create: { userId } } },
|
||||||
|
})
|
||||||
|
await createDefaultColumns(project.id)
|
||||||
|
const full = await prisma.project.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
include: { _count: { select: { members: true, tasks: true } } },
|
||||||
|
})
|
||||||
|
res.json({ item: projectSummary(full) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/projects/:id 详情(项目 + 成员 + 列)
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
const [members, columns] = await Promise.all([
|
||||||
|
prisma.projectMember.findMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
loadColumns(project.id),
|
||||||
|
])
|
||||||
|
res.json({
|
||||||
|
item: {
|
||||||
|
...projectSummary(project),
|
||||||
|
members: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission })),
|
||||||
|
columns,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/projects/:id { name?, archived? }
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
const { name, archived } = req.body || {}
|
||||||
|
const data = {}
|
||||||
|
if (name !== undefined) {
|
||||||
|
const t = String(name).trim()
|
||||||
|
if (!t) return res.status(400).json({ error: '项目名称不能为空' })
|
||||||
|
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
||||||
|
data.name = t
|
||||||
|
}
|
||||||
|
if (archived !== undefined) data.archived = !!archived
|
||||||
|
const row = await prisma.project.update({ where: { id: project.id }, data })
|
||||||
|
res.json({ item: projectSummary(row) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id 个人项目拒绝 (T-PJ-02, T-EX-09)
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可删除' })
|
||||||
|
await prisma.project.delete({ where: { id: project.id } })
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- 成员 ----------
|
||||||
|
|
||||||
|
// POST /api/projects/:id/members { userId } | { email }
|
||||||
|
router.post('/:id/members', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可添加成员' })
|
||||||
|
|
||||||
|
const { userId: targetId, email } = req.body || {}
|
||||||
|
let target = null
|
||||||
|
if (targetId) target = await prisma.user.findUnique({ where: { id: Number(targetId) } })
|
||||||
|
else if (email) target = await prisma.user.findFirst({ where: { email: String(email).trim() } })
|
||||||
|
if (!target) return res.status(404).json({ error: '用户不存在' })
|
||||||
|
|
||||||
|
const exist = await prisma.projectMember.findUnique({
|
||||||
|
where: { projectId_userId: { projectId: project.id, userId: target.id } },
|
||||||
|
})
|
||||||
|
if (exist) return res.status(409).json({ error: '该用户已是项目成员' })
|
||||||
|
|
||||||
|
await prisma.projectMember.create({ data: { projectId: project.id, userId: target.id } })
|
||||||
|
res.json({ ok: true, member: { userId: target.id, name: target.name } })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id/members/:uid 移除成员 / 自己退出 (T-PJ-06/07)
|
||||||
|
router.delete('/:id/members/:uid', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可移除成员' })
|
||||||
|
|
||||||
|
const target = Number(req.params.uid)
|
||||||
|
// 只能移除自己, 或是创建人移除他人 (本期权限均等, 允许成员自退)
|
||||||
|
if (target !== userId && project.ownerId !== userId) {
|
||||||
|
return res.status(403).json({ error: '只能移除自己,或由创建人移除他人' })
|
||||||
|
}
|
||||||
|
await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } })
|
||||||
|
// 成员清空后项目静默保留 (T-PJ-08)
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- 看板列 ----------
|
||||||
|
|
||||||
|
// GET /api/projects/:id/columns
|
||||||
|
router.get('/:id/columns', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
res.json({ items: await loadColumns(project.id) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/projects/:id/columns { name }
|
||||||
|
router.post('/:id/columns', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
|
||||||
|
const name = String(req.body?.name || '').trim()
|
||||||
|
if (!name) return res.status(400).json({ error: '列名不能为空' })
|
||||||
|
if (name.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
|
||||||
|
|
||||||
|
const cols = await loadColumns(project.id)
|
||||||
|
if (cols.length >= MAX_COLUMNS) return res.status(400).json({ error: `最多 ${MAX_COLUMNS} 列` })
|
||||||
|
if (cols.some((c) => c.name === name)) return res.status(409).json({ error: '列名已存在' })
|
||||||
|
|
||||||
|
const row = await prisma.boardColumn.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
name,
|
||||||
|
sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0,
|
||||||
|
boundStatus: null,
|
||||||
|
retention: 'forever',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res.json({ item: row })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/projects/:id/columns/order { ids: number[] }
|
||||||
|
router.put('/:id/columns/order', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number) : []
|
||||||
|
const cols = await loadColumns(project.id)
|
||||||
|
if (ids.length !== cols.length) return res.status(400).json({ error: '列数量不匹配' })
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
ids.map((id, idx) => prisma.boardColumn.update({ where: { id }, data: { sortOrder: idx } })),
|
||||||
|
)
|
||||||
|
res.json({ items: await loadColumns(project.id) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/columns/:id { name?, boundStatus?, retention? }
|
||||||
|
export const columnRouter = Router()
|
||||||
|
columnRouter.use(authRequired)
|
||||||
|
|
||||||
|
columnRouter.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const col = await prisma.boardColumn.findUnique({ where: { id } })
|
||||||
|
if (!col) return res.status(404).json({ error: '列不存在' })
|
||||||
|
const project = await loadProjectForUser(col.projectId, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
|
||||||
|
const { name, boundStatus, retention } = req.body || {}
|
||||||
|
const data = {}
|
||||||
|
if (name !== undefined) {
|
||||||
|
const t = String(name).trim()
|
||||||
|
if (!t) return res.status(400).json({ error: '列名不能为空' })
|
||||||
|
if (t.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
|
||||||
|
const dup = await prisma.boardColumn.findFirst({ where: { projectId: col.projectId, name: t, NOT: { id } } })
|
||||||
|
if (dup) return res.status(409).json({ error: '列名已存在' })
|
||||||
|
data.name = t
|
||||||
|
}
|
||||||
|
if (boundStatus !== undefined) {
|
||||||
|
// null | todo | doing | done
|
||||||
|
if (boundStatus !== null && !['todo', 'doing', 'done'].includes(boundStatus)) {
|
||||||
|
return res.status(400).json({ error: '绑定状态取值非法' })
|
||||||
|
}
|
||||||
|
data.boundStatus = boundStatus
|
||||||
|
}
|
||||||
|
if (retention !== undefined) {
|
||||||
|
if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(retention)) {
|
||||||
|
return res.status(400).json({ error: '保留选项取值非法' })
|
||||||
|
}
|
||||||
|
data.retention = retention
|
||||||
|
}
|
||||||
|
const row = await prisma.boardColumn.update({ where: { id }, data })
|
||||||
|
res.json({ item: row })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/columns/:id?moveToColumnId=N 删列并迁移任务 (T-COL-04, T-EX-16/17)
|
||||||
|
columnRouter.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const col = await prisma.boardColumn.findUnique({ where: { id } })
|
||||||
|
if (!col) return res.status(404).json({ error: '列不存在' })
|
||||||
|
const project = await loadProjectForUser(col.projectId, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
|
||||||
|
const cols = await loadColumns(col.projectId)
|
||||||
|
if (cols.length <= 1) return res.status(400).json({ error: '至少保留一列' })
|
||||||
|
|
||||||
|
// 迁移目标: 指定列 否则相邻列(优先后一个, 没有则前一个)
|
||||||
|
let targetId = Number(req.query.moveToColumnId) || 0
|
||||||
|
if (!targetId) {
|
||||||
|
const idx = cols.findIndex((c) => c.id === id)
|
||||||
|
targetId = (cols[idx + 1] || cols[idx - 1]).id
|
||||||
|
}
|
||||||
|
const target = cols.find((c) => c.id === targetId)
|
||||||
|
if (!target || target.id === id) return res.status(400).json({ error: '迁移目标列非法' })
|
||||||
|
|
||||||
|
// 迁移任务: 状态按目标列绑定重新裁决 (设计文档 §2.4)
|
||||||
|
const tasks = await prisma.task.findMany({ where: { columnId: id } })
|
||||||
|
const newStatus = target.boundStatus || null
|
||||||
|
await prisma.task.updateMany({
|
||||||
|
where: { columnId: id },
|
||||||
|
data: {
|
||||||
|
columnId: target.id,
|
||||||
|
...(newStatus ? { status: newStatus } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await prisma.boardColumn.delete({ where: { id } })
|
||||||
|
res.json({ ok: true, moved: tasks.length, toColumnId: target.id })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
export { createDefaultColumns, loadMemberIds }
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// 任务模块: 任务 / 子任务 / 跨项目拷贝
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
import { authRequired } from '../middleware.js'
|
||||||
|
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'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(authRequired)
|
||||||
|
|
||||||
|
const TASK_INCLUDE = {
|
||||||
|
column: true,
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
tags: true,
|
||||||
|
children: {
|
||||||
|
include: {
|
||||||
|
column: true,
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归一化: members / tags 平铺为数组, 便于前端直接用
|
||||||
|
function shape(task) {
|
||||||
|
if (!task) return task
|
||||||
|
const out = {
|
||||||
|
...task,
|
||||||
|
members: (task.members || []).map((m) => ({ userId: m.userId, name: m.user?.name })),
|
||||||
|
tags: (task.tags || []).map((t) => t.name),
|
||||||
|
}
|
||||||
|
if (Array.isArray(task.children)) out.children = task.children.map(shape)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 项目下的任务列表 ----------
|
||||||
|
|
||||||
|
// GET /api/projects/:id/tasks
|
||||||
|
router.get('/projects/:id/tasks', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
|
||||||
|
const columns = await loadColumns(project.id)
|
||||||
|
const rows = await prisma.task.findMany({
|
||||||
|
where: { projectId: project.id, parentId: null },
|
||||||
|
include: TASK_INCLUDE,
|
||||||
|
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 })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/projects/:id/tasks { title, description?, parentId?, assigneeId?, memberIds?, tags? }
|
||||||
|
router.post('/projects/:id/tasks', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
|
||||||
|
const { title, description = '', parentId, assigneeId, memberIds = [], tags = [] } = req.body || {}
|
||||||
|
const cleanTitle = String(title || '').trim()
|
||||||
|
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
||||||
|
if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
|
||||||
|
|
||||||
|
const columns = await loadColumns(project.id)
|
||||||
|
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
|
||||||
|
|
||||||
|
// 子任务校验: 父任务必须存在、同项目、且本身是顶层任务 (T-ST-01/02, T-EX-12)
|
||||||
|
let pid = null
|
||||||
|
if (parentId) {
|
||||||
|
const parent = await prisma.task.findUnique({ where: { id: Number(parentId) } })
|
||||||
|
if (!parent) return res.status(404).json({ error: '父任务不存在' })
|
||||||
|
if (parent.projectId !== project.id) return res.status(400).json({ error: '子任务必须与父任务同项目' })
|
||||||
|
if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' })
|
||||||
|
pid = parent.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds_ = await loadMemberIds(project.id)
|
||||||
|
const memberSet = new Set(memberIds_)
|
||||||
|
// 负责人: 未指定 => 默认创建者本人 (T-TK-09); 显式 null => 不指派
|
||||||
|
let finalAssignee = null
|
||||||
|
if (assigneeId === undefined) finalAssignee = memberSet.has(userId) ? userId : null
|
||||||
|
else if (assigneeId !== null) {
|
||||||
|
const aid = Number(assigneeId)
|
||||||
|
if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' })
|
||||||
|
finalAssignee = aid
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstCol = columns[0]
|
||||||
|
const row = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
columnId: firstCol.id,
|
||||||
|
parentId: pid,
|
||||||
|
title: cleanTitle,
|
||||||
|
description: String(description || ''),
|
||||||
|
status: firstCol.boundStatus || 'todo',
|
||||||
|
assigneeId: finalAssignee,
|
||||||
|
creatorId: userId,
|
||||||
|
extras: [],
|
||||||
|
members: {
|
||||||
|
create: (Array.isArray(memberIds) ? memberIds : [])
|
||||||
|
.map(Number).filter((id) => memberSet.has(id))
|
||||||
|
.map((uid) => ({ userId: uid })),
|
||||||
|
},
|
||||||
|
tags: { create: (Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) })) },
|
||||||
|
},
|
||||||
|
include: TASK_INCLUDE,
|
||||||
|
})
|
||||||
|
res.json({ item: shape(row) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- 单条任务 ----------
|
||||||
|
|
||||||
|
// GET /api/tasks/:id
|
||||||
|
router.get('/tasks/:id', 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: '任务不存在或无权限' })
|
||||||
|
res.json({ item: shape(found.task) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/tasks/:id 关键: 列/状态联动裁决
|
||||||
|
router.put('/tasks/:id', 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, project } = found
|
||||||
|
const body = req.body || {}
|
||||||
|
|
||||||
|
const columns = await loadColumns(project.id)
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
// 文本 / 扩展信息
|
||||||
|
if (body.title !== undefined) {
|
||||||
|
const t = String(body.title).trim()
|
||||||
|
if (!t) return res.status(400).json({ error: '任务标题不能为空' })
|
||||||
|
if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
|
||||||
|
data.title = t
|
||||||
|
}
|
||||||
|
if (body.description !== undefined) data.description = String(body.description)
|
||||||
|
if (body.extras !== undefined) {
|
||||||
|
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
||||||
|
data.extras = body.extras.map((e) => ({
|
||||||
|
label: String(e?.label ?? '').slice(0, 40),
|
||||||
|
value: String(e?.value ?? ''),
|
||||||
|
url: e?.url ? String(e.url) : '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (body.sortOrder !== undefined) data.sortOrder = Number(body.sortOrder) || 0
|
||||||
|
|
||||||
|
// 负责人: 必须是项目成员或 null (T-AS-03)
|
||||||
|
const memberIds = await loadMemberIds(project.id)
|
||||||
|
const memberSet = new Set(memberIds)
|
||||||
|
if (body.assigneeId !== undefined) {
|
||||||
|
if (body.assigneeId === null || body.assigneeId === '') data.assigneeId = null
|
||||||
|
else {
|
||||||
|
const aid = Number(body.assigneeId)
|
||||||
|
if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' })
|
||||||
|
data.assigneeId = aid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列 / 状态: 走统一裁决 (T-BD-*)
|
||||||
|
const patch = {}
|
||||||
|
if (body.columnId !== undefined) patch.columnId = Number(body.columnId)
|
||||||
|
if (body.status !== undefined) {
|
||||||
|
if (!['todo', 'doing', 'done'].includes(body.status)) return res.status(400).json({ error: '任务状态取值非法' })
|
||||||
|
patch.status = body.status
|
||||||
|
}
|
||||||
|
if (patch.columnId !== undefined && !columns.some((c) => c.id === patch.columnId)) {
|
||||||
|
return res.status(400).json({ error: '看板列不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalStatus = task.status
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
const r = resolveTaskWrite(task, patch, columns)
|
||||||
|
data.status = r.status
|
||||||
|
data.columnId = r.columnId
|
||||||
|
finalStatus = r.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成时间: 进入 done 写 now, 退出 done 置空 (T-FL-10)
|
||||||
|
if (finalStatus === 'done' && task.status !== 'done') data.doneAt = new Date()
|
||||||
|
else if (finalStatus !== 'done' && data.status !== undefined) data.doneAt = null
|
||||||
|
|
||||||
|
// 成员 / 标签: 整体替换
|
||||||
|
if (Array.isArray(body.memberIds)) {
|
||||||
|
const ids = body.memberIds.map(Number).filter((id) => memberSet.has(id))
|
||||||
|
await prisma.taskMember.deleteMany({ where: { taskId: task.id } })
|
||||||
|
if (ids.length) {
|
||||||
|
await prisma.taskMember.createMany({
|
||||||
|
data: ids.map((uid) => ({ taskId: task.id, userId: uid })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.tags)) {
|
||||||
|
await prisma.taskTag.deleteMany({ where: { taskId: task.id } })
|
||||||
|
const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
||||||
|
if (names.length) {
|
||||||
|
await prisma.taskTag.createMany({ data: names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) })) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽降为子任务 (T-ST-12~15)
|
||||||
|
if (body.parentId !== undefined) {
|
||||||
|
if (body.parentId === null) {
|
||||||
|
data.parentId = null
|
||||||
|
} else {
|
||||||
|
const pid = Number(body.parentId)
|
||||||
|
if (pid === task.id) return res.status(400).json({ error: '不能成为自己的子任务' })
|
||||||
|
const parent = await prisma.task.findUnique({ where: { id: pid } })
|
||||||
|
if (!parent) return res.status(404).json({ error: '目标任务不存在' })
|
||||||
|
if (parent.projectId !== task.projectId) return res.status(400).json({ error: '子任务必须与父任务同项目' })
|
||||||
|
if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' })
|
||||||
|
const childCount = await prisma.task.count({ where: { parentId: task.id } })
|
||||||
|
if (childCount > 0) return res.status(400).json({ error: '该任务已有子任务,不能成为子任务' })
|
||||||
|
data.parentId = pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await prisma.task.update({ where: { id: task.id }, data, include: TASK_INCLUDE })
|
||||||
|
res.json({ item: shape(row) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/tasks/:id 级联删除子任务 (T-ST-11, T-EX-07)
|
||||||
|
router.delete('/tasks/:id', 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 childCount = await prisma.task.count({ where: { parentId: found.task.id } })
|
||||||
|
await prisma.task.delete({ where: { id: found.task.id } })
|
||||||
|
res.json({ ok: true, deletedSubtasks: childCount })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/tasks/:id/copy { projectId } 跨项目拷贝 (T-CP-*)
|
||||||
|
router.post('/tasks/:id/copy', 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 targetId = Number(req.body?.projectId)
|
||||||
|
const targetProject = await loadProjectForUser(targetId, userId)
|
||||||
|
if (!targetProject) return res.status(403).json({ error: '目标项目不存在或你不是其成员' })
|
||||||
|
|
||||||
|
const targetColumns = await loadColumns(targetProject.id)
|
||||||
|
if (!targetColumns.length) return res.status(400).json({ error: '目标项目没有可用的看板列' })
|
||||||
|
const targetMemberIds = await loadMemberIds(targetProject.id)
|
||||||
|
|
||||||
|
// 源任务需带列名映射信息
|
||||||
|
const sourceColumns = await loadColumns(found.project.id)
|
||||||
|
const source = { ...found.task, __columns: sourceColumns }
|
||||||
|
|
||||||
|
const result = await copyTask({
|
||||||
|
source, targetProject, targetColumns, operatorId: userId, targetMemberIds,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fresh = await prisma.task.findUnique({ where: { id: result.task.id }, include: TASK_INCLUDE })
|
||||||
|
res.json({ item: shape(fresh), dropped: result.dropped })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 任务模块: 项目成员校验 (T-PM-01, B-13)
|
||||||
|
// 所有 /api/projects/:id/* 与任务写操作都必须确认请求者是项目成员.
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
/** 取出当前用户可访问的项目 (不存在或非成员 -> null) */
|
||||||
|
export async function loadProjectForUser(projectId, userId) {
|
||||||
|
const id = Number(projectId)
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id, members: { some: { userId } } },
|
||||||
|
})
|
||||||
|
return project || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务 -> 校验其项目可访问, 返回 { task, project } */
|
||||||
|
export async function loadTaskForUser(taskId, userId) {
|
||||||
|
const id = Number(taskId)
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null
|
||||||
|
const task = await prisma.task.findFirst({
|
||||||
|
where: { id, project: { members: { some: { userId } } } },
|
||||||
|
include: {
|
||||||
|
column: true,
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
tags: true,
|
||||||
|
children: {
|
||||||
|
include: {
|
||||||
|
column: true,
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!task) return null
|
||||||
|
const project = await prisma.project.findUnique({ where: { id: task.projectId } })
|
||||||
|
return { task, project }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目全部列 (按显示顺序) */
|
||||||
|
export async function loadColumns(projectId) {
|
||||||
|
return prisma.boardColumn.findMany({
|
||||||
|
where: { projectId: Number(projectId) },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目成员 userId 集合 */
|
||||||
|
export async function loadMemberIds(projectId) {
|
||||||
|
const rows = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId: Number(projectId) },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
return rows.map((r) => r.userId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// 任务模块: 跨项目拷贝 (T-CP-*)
|
||||||
|
// 拷贝而非移动: 新建记录 + 深拷贝子任务, 源任务零改动.
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拷贝一条任务(含子任务)到目标项目。
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* - 全字段复制: 标题/描述/状态/列/扩展卡片/标签 (T-CP-02)
|
||||||
|
* - 子任务一并复制, 且归属目标项目 (T-CP-03, T-CP-08)
|
||||||
|
* - creatorId = 操作者 (T-CP-04)
|
||||||
|
* - 负责人 / 任务成员只保留"属于目标项目成员"的部分, 其余丢弃并回报 (T-CP-07, T-EX-08)
|
||||||
|
* - 状态与列沿用源值, 不强行纠正 (T-EX-14)
|
||||||
|
*
|
||||||
|
* @returns {{ task, dropped: { assignee: null|{id,name}, members: {id,name}[] } }}
|
||||||
|
*/
|
||||||
|
export async function copyTask({ source, targetProject, targetColumns, operatorId, targetMemberIds }) {
|
||||||
|
const memberSet = new Set(targetMemberIds)
|
||||||
|
|
||||||
|
// 列映射: 目标项目里找同名列, 找不到就用第一列 (保证 columnId 合法)
|
||||||
|
const pickColumn = (sourceColumnId) => {
|
||||||
|
const srcCol = source.__columns?.find((c) => c.id === sourceColumnId)
|
||||||
|
if (srcCol) {
|
||||||
|
const same = targetColumns.find((c) => c.name === srcCol.name)
|
||||||
|
if (same) return same.id
|
||||||
|
}
|
||||||
|
return targetColumns[0]?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人员过滤
|
||||||
|
const keepAssignee = source.assigneeId && memberSet.has(source.assigneeId) ? source.assigneeId : null
|
||||||
|
const droppedMembers = []
|
||||||
|
const keptMemberIds = []
|
||||||
|
for (const m of source.members || []) {
|
||||||
|
if (memberSet.has(m.userId)) keptMemberIds.push(m.userId)
|
||||||
|
else droppedMembers.push(m.user)
|
||||||
|
}
|
||||||
|
const droppedAssignee = source.assigneeId && !memberSet.has(source.assigneeId) ? source.assignee : null
|
||||||
|
|
||||||
|
const baseData = (extra) => ({
|
||||||
|
projectId: targetProject.id,
|
||||||
|
title: source.title,
|
||||||
|
description: source.description || '',
|
||||||
|
status: source.status,
|
||||||
|
columnId: extra.columnId,
|
||||||
|
parentId: extra.parentId ?? null,
|
||||||
|
assigneeId: extra.parentId ? (source.assigneeId && memberSet.has(source.assigneeId) ? source.assigneeId : null) : keepAssignee,
|
||||||
|
creatorId: operatorId,
|
||||||
|
extras: source.extras ?? [],
|
||||||
|
doneAt: source.status === 'done' ? (source.doneAt || new Date()) : null,
|
||||||
|
sortOrder: source.sortOrder ?? 0,
|
||||||
|
tags: {
|
||||||
|
create: (source.tags || []).map((t) => ({ name: t.name })),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newTask = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
...baseData({ columnId: pickColumn(source.columnId) }),
|
||||||
|
members: {
|
||||||
|
create: keptMemberIds.map((uid) => ({ userId: uid })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 子任务: 同法复制, parentId 指向新父任务
|
||||||
|
for (const child of source.children || []) {
|
||||||
|
const childMembers = (child.members || [])
|
||||||
|
.filter((m) => memberSet.has(m.userId))
|
||||||
|
.map((m) => m.userId)
|
||||||
|
await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
...baseData({
|
||||||
|
columnId: pickColumn(child.columnId),
|
||||||
|
parentId: newTask.id,
|
||||||
|
}),
|
||||||
|
members: { create: childMembers.map((uid) => ({ userId: uid })) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: newTask,
|
||||||
|
dropped: {
|
||||||
|
assignee: droppedAssignee ? { id: droppedAssignee.id, name: droppedAssignee.name } : null,
|
||||||
|
members: droppedMembers.map((u) => ({ id: u.id, name: u.name })),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// 任务模块: 自然日边界工具 (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
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// 任务模块: 看板列 与 任务状态 的联动裁决 (唯一口径)
|
||||||
|
// 对应需求: T-BD-04/05/06/07/08/13/14/15/16
|
||||||
|
//
|
||||||
|
// 两个正交维度:
|
||||||
|
// Task.columnId 空间 (哪一列)
|
||||||
|
// Task.status 属性 (todo/doing/done)
|
||||||
|
// 列上 boundStatus 决定二者是否联动:
|
||||||
|
// null 该列与状态完全独立
|
||||||
|
// todo等 该列绑定到该状态
|
||||||
|
//
|
||||||
|
// 三种场景:
|
||||||
|
// A 拖拽换列 -> 只看目标列: 目标列绑定则状态跟随, 不绑定则保留原状态
|
||||||
|
// B 改状态 -> 当前列已匹配则不改列; 否则按列序找第一个绑定该状态的列 (找不到就不动)
|
||||||
|
// C 子任务 -> 跳过全部联动
|
||||||
|
|
||||||
|
/** 列按 sortOrder 升序 (调用方保证已排序, 这里再兜底一次) */
|
||||||
|
export function sortColumns(columns) {
|
||||||
|
return [...columns].sort((a, b) => (a.sortOrder - b.sortOrder) || (a.id - b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景 A: 任务从任意列拖到 targetColumn
|
||||||
|
* @returns {{ status: string }} 新状态
|
||||||
|
*/
|
||||||
|
export function statusOnColumnChange(currentStatus, targetColumn) {
|
||||||
|
if (!targetColumn) return { status: currentStatus }
|
||||||
|
if (targetColumn.boundStatus) return { status: targetColumn.boundStatus }
|
||||||
|
// 目标列不绑定 => 保留原状态 (T-BD-07)
|
||||||
|
return { status: currentStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景 B: 任务状态改为 newStatus 后, 应该落在哪一列
|
||||||
|
* @returns {{ columnId: number, moved: boolean }}
|
||||||
|
*/
|
||||||
|
export function columnOnStatusChange(task, newStatus, columns) {
|
||||||
|
// 已匹配 (当前列就绑定该状态) => 原地不动
|
||||||
|
if (task.column && task.column.boundStatus === newStatus) {
|
||||||
|
return { columnId: task.columnId, moved: false }
|
||||||
|
}
|
||||||
|
// 按当前列顺序取第一个绑定该状态的列 (T-BD-06, T-BD-15)
|
||||||
|
const ordered = sortColumns(columns || [])
|
||||||
|
const target = ordered.find((c) => c.boundStatus === newStatus)
|
||||||
|
if (target) return { columnId: target.id, moved: target.id !== task.columnId }
|
||||||
|
// 无匹配列 => 不移动, 保留原列 (T-BD-16)
|
||||||
|
return { columnId: task.columnId, moved: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子任务豁免: 不参与任何列/状态联动 (T-BD-13) */
|
||||||
|
export function isSubtask(task) {
|
||||||
|
return task.parentId != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一裁决入口: 写接口调用它算出最终的 status 与 columnId
|
||||||
|
*
|
||||||
|
* @param {object} task 现有任务 (含 columnId, status, parentId, column)
|
||||||
|
* @param {object} patch 本次提交 { columnId?, status? }
|
||||||
|
* @param {object[]} columns 项目全部列
|
||||||
|
* @returns {{ status: string, columnId: number }}
|
||||||
|
*/
|
||||||
|
export function resolveTaskWrite(task, patch, columns) {
|
||||||
|
let status = patch.status !== undefined ? patch.status : task.status
|
||||||
|
let columnId = patch.columnId !== undefined ? patch.columnId : task.columnId
|
||||||
|
|
||||||
|
// 子任务: 原样落库
|
||||||
|
if (isSubtask(task)) return { status, columnId }
|
||||||
|
|
||||||
|
const ordered = sortColumns(columns || [])
|
||||||
|
const targetColumn = ordered.find((c) => c.id === columnId)
|
||||||
|
|
||||||
|
// 场景 A: 显式换列
|
||||||
|
if (patch.columnId !== undefined && targetColumn) {
|
||||||
|
status = statusOnColumnChange(status, targetColumn).status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景 B: 状态变了(或换列后状态变了) => 检查是否需要归位
|
||||||
|
// 注意: 只在"状态发生变化"或"显式改状态"时才归位, 单纯拖拽已由 A 处理
|
||||||
|
if (patch.status !== undefined) {
|
||||||
|
const next = columnOnStatusChange({ ...task, columnId, column: targetColumn }, status, ordered)
|
||||||
|
columnId = next.columnId
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, columnId }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 任务模块: 保留选项的可见性过滤 (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
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import MonitorPage from './pages/MonitorPage.jsx'
|
|||||||
import ServerDetail from './pages/monitor/ServerDetail.jsx'
|
import ServerDetail from './pages/monitor/ServerDetail.jsx'
|
||||||
import MePage from './pages/MePage.jsx'
|
import MePage from './pages/MePage.jsx'
|
||||||
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
||||||
|
import TaskPage from './pages/task/TaskPage.jsx'
|
||||||
import { useAuth } from './auth/AuthContext.jsx'
|
import { useAuth } from './auth/AuthContext.jsx'
|
||||||
|
|
||||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||||
@@ -36,6 +37,9 @@ export default function App() {
|
|||||||
{/* 打卡为默认首页 */}
|
{/* 打卡为默认首页 */}
|
||||||
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
||||||
<Route path="/checkin" element={<CheckinPage />} />
|
<Route path="/checkin" element={<CheckinPage />} />
|
||||||
|
{/* 任务模块 (V0.2): 项目列表 / 项目内列表+看板 (T-PM-06) */}
|
||||||
|
<Route path="/task" element={<TaskPage />} />
|
||||||
|
<Route path="/task/:pid" element={<TaskPage />} />
|
||||||
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
||||||
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
||||||
<Route path="/macro/*" element={<MacroPage />} />
|
<Route path="/macro/*" element={<MacroPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { api } from './client.js'
|
||||||
|
|
||||||
|
const q = (params) => {
|
||||||
|
const s = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && v !== '') s.set(k, v)
|
||||||
|
})
|
||||||
|
const str = s.toString()
|
||||||
|
return str ? '?' + str : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目
|
||||||
|
export const projectApi = {
|
||||||
|
list: () => api('/projects'),
|
||||||
|
detail: (id) => api(`/projects/${id}`),
|
||||||
|
create: (body) => api('/projects', { method: 'POST', body }),
|
||||||
|
update: (id, body) => api(`/projects/${id}`, { method: 'PUT', body }),
|
||||||
|
remove: (id) => api(`/projects/${id}`, { method: 'DELETE' }),
|
||||||
|
addMember: (id, body) => api(`/projects/${id}/members`, { method: 'POST', body }),
|
||||||
|
removeMember: (id, uid) => api(`/projects/${id}/members/${uid}`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 看板列
|
||||||
|
export const columnApi = {
|
||||||
|
list: (projectId) => api(`/projects/${projectId}/columns`),
|
||||||
|
add: (projectId, body) => api(`/projects/${projectId}/columns`, { method: 'POST', body }),
|
||||||
|
update: (id, body) => api(`/columns/${id}`, { method: 'PUT', body }),
|
||||||
|
remove: (id, moveToColumnId) => api(`/columns/${id}${q({ moveToColumnId })}`, { method: 'DELETE' }),
|
||||||
|
order: (projectId, ids) => api(`/projects/${projectId}/columns/order`, { method: 'PUT', body: { ids } }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务
|
||||||
|
export const taskApi = {
|
||||||
|
list: (projectId) => api(`/projects/${projectId}/tasks`),
|
||||||
|
create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }),
|
||||||
|
detail: (id) => api(`/tasks/${id}`),
|
||||||
|
update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }),
|
||||||
|
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
|
||||||
|
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import TabIcon from './TabIcon.jsx'
|
|||||||
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
||||||
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
||||||
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
|
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
|
||||||
|
// V0.2 任务模块: 「任务」插入到「打卡」之后 (T-PM-06), 默认首页仍为打卡
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||||
|
{ key: '/task', name: 'task', label: '任务' },
|
||||||
{ key: '/macro', name: 'macro', label: '宏观' },
|
{ key: '/macro', name: 'macro', label: '宏观' },
|
||||||
{ key: '/monitor', name: 'monitor', label: '监控' },
|
{ key: '/monitor', name: 'monitor', label: '监控' },
|
||||||
{ key: '/me', name: 'me', label: '我' },
|
{ key: '/me', name: 'me', label: '我' },
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ const ICONS = {
|
|||||||
<path d="M9 15.5l2.2 2.2L15.5 13" />
|
<path d="M9 15.5l2.2 2.2L15.5 13" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
// 任务: 清单本 + 勾选行 (V0.2 任务模块)
|
||||||
|
task: (active) => (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="4" y="4" width="16" height="17" rx="2" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
|
||||||
|
<path d="M8 9.5l1.4 1.4L12 8.3" />
|
||||||
|
<path d="M14.5 9.5h3" />
|
||||||
|
<path d="M8 15.5l1.4 1.4L12 14.3" />
|
||||||
|
<path d="M14.5 15.5h3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
me: (active) => (
|
me: (active) => (
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="8" r="4" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
<circle cx="12" cy="8" r="4" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// 扩展信息卡片 (T-TK-04~07): 任意条目, 默认收起, 全部非必填
|
||||||
|
// 条目结构 { label, value, url? }
|
||||||
|
export default function ExtrasCard({ extras = [], onChange, readOnly = false }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const list = Array.isArray(extras) ? extras : []
|
||||||
|
|
||||||
|
const update = (idx, key, val) => {
|
||||||
|
const next = list.map((e, i) => (i === idx ? { ...e, [key]: val } : e))
|
||||||
|
onChange?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = () => onChange?.([...list, { label: '', value: '', url: '' }])
|
||||||
|
const remove = (idx) => onChange?.(list.filter((_, i) => i !== idx))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="extras">
|
||||||
|
<div className="extras-head" onClick={() => setOpen((v) => !v)}>
|
||||||
|
<span className="caret">{open ? '▾' : '▸'}</span>
|
||||||
|
<span>扩展信息</span>
|
||||||
|
{list.length > 0 && <span className="tag">{list.length}</span>}
|
||||||
|
<span className="cv">{open ? '收起' : '默认收起 · 全部非必填'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="extras-body">
|
||||||
|
{list.length === 0 && (
|
||||||
|
<div className="extras-empty">暂无条目,可记录仓库 / 分支 / 链接等任意信息</div>
|
||||||
|
)}
|
||||||
|
{list.map((e, i) => (
|
||||||
|
<div className="extra-row" key={i}>
|
||||||
|
<input
|
||||||
|
className="inp extra-k"
|
||||||
|
placeholder="名称"
|
||||||
|
value={e.label || ''}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(ev) => update(i, 'label', ev.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="inp extra-v"
|
||||||
|
placeholder="值"
|
||||||
|
value={e.value || ''}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(ev) => update(i, 'value', ev.target.value)}
|
||||||
|
/>
|
||||||
|
{!readOnly && (
|
||||||
|
<button type="button" className="extra-del" onClick={() => remove(i)}>×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<button type="button" className="extras-add" onClick={add}>+ 添加一条</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 任务状态小圆点 / 药丸标记 (T-ST-09)
|
||||||
|
const LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
|
||||||
|
|
||||||
|
export default function StatusDot({ status = 'todo', pill = false, className = '' }) {
|
||||||
|
const cls = `sdot ${status}${pill ? ' pill' : ''} ${className}`.trim()
|
||||||
|
if (pill) return <span className={cls}>{LABEL[status] || ''}</span>
|
||||||
|
return <span className={cls} title={LABEL[status] || ''} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import StatusDot from './StatusDot.jsx'
|
||||||
|
|
||||||
|
// 看板卡片 (T-UI-02): 轻量化
|
||||||
|
// - 标题 + 状态点 (T-ST-09)
|
||||||
|
// - 负责人 12px/600 突出 (T-AS-01); 任务成员 10.5px 弱化置于下方 (T-AS-02)
|
||||||
|
// - 子任务内嵌简洁展示 + 可直接勾选 (T-ST-06/07)
|
||||||
|
const MAX_SUB = 3
|
||||||
|
|
||||||
|
export default function TaskCard({
|
||||||
|
task, bindDrag, onClick, onToggleSub, onToggle, onAddSub, dragging = false, highlight = false,
|
||||||
|
}) {
|
||||||
|
const children = task.children || []
|
||||||
|
const shown = children.slice(0, MAX_SUB)
|
||||||
|
const rest = children.length - shown.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bcard${dragging ? ' dragging' : ''}${highlight ? ' hl' : ''}`}
|
||||||
|
data-task-id={task.id}
|
||||||
|
onClick={onClick}
|
||||||
|
{...(bindDrag ? bindDrag(task) : {})}
|
||||||
|
>
|
||||||
|
<div className="brow1">
|
||||||
|
<span className="btitle">{task.title}</span>
|
||||||
|
<span
|
||||||
|
className="bstat"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||||
|
title="点按切换完成"
|
||||||
|
>
|
||||||
|
<StatusDot status={task.status} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bmeta">
|
||||||
|
<span className={`bassignee${task.assignee ? '' : ' none'}`}>
|
||||||
|
{task.assignee?.name || '未指派'}
|
||||||
|
</span>
|
||||||
|
{task.members?.length > 0 && (
|
||||||
|
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
||||||
|
>
|
||||||
|
<span className="sbox" />
|
||||||
|
<span className="stitle">{c.title}</span>
|
||||||
|
<StatusDot status={c.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
||||||
|
|
||||||
|
{onAddSub && (
|
||||||
|
<div
|
||||||
|
className="bsub-add"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onAddSub(task) }}
|
||||||
|
>
|
||||||
|
+ 子任务
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import StatusDot from './StatusDot.jsx'
|
||||||
|
|
||||||
|
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
||||||
|
export default function TaskRow({
|
||||||
|
task, onToggle, onClick, onMore, indent = false, children,
|
||||||
|
}) {
|
||||||
|
const done = task.status === 'done'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
|
||||||
|
data-task-id={indent ? undefined : task.id}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="box"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
|
||||||
|
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{indent ? (
|
||||||
|
<>
|
||||||
|
<span className="sub-title">{task.title}</span>
|
||||||
|
<StatusDot status={task.status} pill />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="task-main">
|
||||||
|
<div className="task-title">{task.title}</div>
|
||||||
|
<div className="task-meta">
|
||||||
|
<StatusDot status={task.status} />
|
||||||
|
<span>{task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'}</span>
|
||||||
|
{task.column?.name && <span className="col">{task.column.name}</span>}
|
||||||
|
<span className={`assignee${task.assignee ? '' : ' none'}`}>
|
||||||
|
{task.assignee?.name || '未指派'}
|
||||||
|
</span>
|
||||||
|
{task.members?.length > 0 && (
|
||||||
|
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-right">
|
||||||
|
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Popup, Toast } from 'antd-mobile'
|
||||||
|
|
||||||
|
// 人员选择 (T-AS-03/04): 只能从"当前项目成员"中选
|
||||||
|
export default function UserPicker({
|
||||||
|
visible, members = [], value, multiple = false, title = '选择负责人',
|
||||||
|
allowEmpty = true, onClose, onConfirm,
|
||||||
|
}) {
|
||||||
|
const [sel, setSel] = useState(multiple ? [] : null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
setSel(multiple ? (value || []) : (value ?? null))
|
||||||
|
}, [visible, value, multiple])
|
||||||
|
|
||||||
|
const toggle = (uid) => {
|
||||||
|
if (multiple) {
|
||||||
|
setSel((prev) => {
|
||||||
|
const arr = Array.isArray(prev) ? prev : []
|
||||||
|
return arr.includes(uid) ? arr.filter((x) => x !== uid) : [...arr, uid]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSel((prev) => (prev === uid ? null : uid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (!multiple && sel === null && !allowEmpty) {
|
||||||
|
Toast.show({ content: '请选择负责人' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onConfirm?.(sel)
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}>
|
||||||
|
<div className="sheet-title">{title}</div>
|
||||||
|
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||||
|
|
||||||
|
{members.length === 0 && <div className="extras-empty">项目暂无成员</div>}
|
||||||
|
|
||||||
|
{members.map((m) => {
|
||||||
|
const on = multiple ? (Array.isArray(sel) && sel.includes(m.userId)) : sel === m.userId
|
||||||
|
return (
|
||||||
|
<div className="pick-row" key={m.userId} onClick={() => toggle(m.userId)}>
|
||||||
|
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
|
||||||
|
<span className="pn">{m.name}</span>
|
||||||
|
<span className={`ck${on ? ' on' : ''}`} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{allowEmpty && !multiple && (
|
||||||
|
<div className="pick-row" onClick={() => setSel(null)}>
|
||||||
|
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
|
||||||
|
<span className="pn">清除负责人</span>
|
||||||
|
<span className={`ck${sel === null ? ' on' : ''}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="btn-primary" onClick={confirm}>确定</div>
|
||||||
|
<div className="btn-ghost" onClick={onClose}>取消</div>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// 长按拖拽 (T-UI-07, T-ST-12)
|
||||||
|
// 用法: const drag = useLongPressDrag({ onDrop })
|
||||||
|
// - 长按 300ms 后进入拖拽态, 产生浮层数据
|
||||||
|
// - 松手时用 elementFromPoint 找落点: [data-column-id] / [data-task-id]
|
||||||
|
// - 返回 { dragging, ghost, bind(task) }
|
||||||
|
const LONG_PRESS = 300
|
||||||
|
const MOVE_TOLERANCE = 8
|
||||||
|
|
||||||
|
export default function useLongPressDrag({ onDropColumn, onDropTask, onReject } = {}) {
|
||||||
|
const [dragging, setDragging] = useState(null) // { task, x, y }
|
||||||
|
const timer = useRef(null)
|
||||||
|
const start = useRef(null)
|
||||||
|
const active = useRef(false)
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||||
|
start.current = null
|
||||||
|
active.current = false
|
||||||
|
document.removeEventListener('touchmove', onMove, { passive: false })
|
||||||
|
document.removeEventListener('touchend', onEnd)
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onEnd)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pointOf = (ev) => {
|
||||||
|
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
|
||||||
|
return { x: t.clientX, y: t.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
function onMove(ev) {
|
||||||
|
if (!active.current) {
|
||||||
|
// 未进入拖拽前, 若移动超过容差则取消长按(用户想滚动)
|
||||||
|
if (start.current) {
|
||||||
|
const p = pointOf(ev)
|
||||||
|
if (Math.abs(p.x - start.current.x) > MOVE_TOLERANCE || Math.abs(p.y - start.current.y) > MOVE_TOLERANCE) {
|
||||||
|
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.cancelable) ev.preventDefault()
|
||||||
|
const p = pointOf(ev)
|
||||||
|
setDragging((d) => (d ? { ...d, x: p.x, y: p.y } : d))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnd(ev) {
|
||||||
|
if (!active.current) { cleanup(); return }
|
||||||
|
const p = pointOf(ev)
|
||||||
|
const el = document.elementFromPoint(p.x, p.y)
|
||||||
|
const taskEl = el?.closest?.('[data-task-id]')
|
||||||
|
const colEl = el?.closest?.('[data-column-id]')
|
||||||
|
const task = dragging?.task
|
||||||
|
cleanup()
|
||||||
|
setDragging(null)
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
if (taskEl) {
|
||||||
|
const targetId = Number(taskEl.dataset.taskId)
|
||||||
|
if (targetId && targetId !== task.id) onDropTask?.(task, targetId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (colEl) {
|
||||||
|
const columnId = Number(colEl.dataset.columnId)
|
||||||
|
if (columnId && columnId !== task.columnId) onDropColumn?.(task, columnId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onReject?.('未放到有效位置')
|
||||||
|
}
|
||||||
|
|
||||||
|
const bind = useCallback((task) => {
|
||||||
|
const onStart = (ev) => {
|
||||||
|
if (ev.button && ev.button !== 0) return
|
||||||
|
const p = pointOf(ev)
|
||||||
|
start.current = p
|
||||||
|
setDragging(null)
|
||||||
|
active.current = false
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
active.current = true
|
||||||
|
setDragging({ task, x: p.x, y: p.y })
|
||||||
|
if (navigator.vibrate) navigator.vibrate(10)
|
||||||
|
}, LONG_PRESS)
|
||||||
|
document.addEventListener('touchmove', onMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onEnd)
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onEnd)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
onTouchStart: onStart,
|
||||||
|
onMouseDown: onStart,
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { dragging, bind, cancel: () => { cleanup(); setDragging(null) } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Toast } from 'antd-mobile'
|
||||||
|
import TaskCard from '../../components/task/TaskCard.jsx'
|
||||||
|
import useLongPressDrag from '../../components/task/useLongPressDrag.js'
|
||||||
|
|
||||||
|
// 看板视图 (T-BD-01, T-UI-07, T-ST-06/12)
|
||||||
|
// 列横向滚动; 长按卡片拖拽换列 / 降为子任务
|
||||||
|
export default function BoardView({
|
||||||
|
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onQuickAdd,
|
||||||
|
}) {
|
||||||
|
const [overCol, setOverCol] = useState(null)
|
||||||
|
|
||||||
|
const drag = useLongPressDrag({
|
||||||
|
onDropColumn: (task, columnId) => { setOverCol(null); onMoveColumn?.(task, columnId) },
|
||||||
|
onDropTask: (task, parentId) => { setOverCol(null); onMakeSubtask?.(task, parentId) },
|
||||||
|
onReject: (msg) => { setOverCol(null); Toast.show({ content: msg }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="board-wrap">
|
||||||
|
{ordered.map((col) => {
|
||||||
|
const list = (tasks || []).filter((t) => t.columnId === col.id)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bcol${overCol === col.id ? ' dragover' : ''}`}
|
||||||
|
key={col.id}
|
||||||
|
data-column-id={col.id}
|
||||||
|
onDragEnter={() => setOverCol(col.id)}
|
||||||
|
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
|
||||||
|
>
|
||||||
|
<div className="bcol-head">
|
||||||
|
<span className="bcol-name">{col.name}</span>
|
||||||
|
<span className="bcol-cnt">{list.length}</span>
|
||||||
|
{col.boundStatus ? (
|
||||||
|
<span className="bcol-bind">
|
||||||
|
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="bcol-bind off">未绑定</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bcol-body">
|
||||||
|
{list.map((t) => (
|
||||||
|
<TaskCard
|
||||||
|
key={t.id}
|
||||||
|
task={t}
|
||||||
|
dragging={drag.dragging?.task?.id === t.id}
|
||||||
|
highlight={drag.dragging && drag.dragging.task.id !== t.id && overCol === col.id}
|
||||||
|
bindDrag={drag.bind}
|
||||||
|
onClick={() => { if (!drag.dragging) onOpen?.(t) }}
|
||||||
|
onToggleSub={onToggleSub}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
|
||||||
|
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drag.dragging && (
|
||||||
|
<div
|
||||||
|
className="drag-ghost"
|
||||||
|
style={{ left: drag.dragging.x, top: drag.dragging.y }}
|
||||||
|
>
|
||||||
|
{drag.dragging.task.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const MAX_COLUMNS = 10
|
||||||
|
|
||||||
|
// 列设置 (T-COL-*, T-BD-02/09/10, T-FL-01/02/15)
|
||||||
|
// 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)
|
||||||
|
const [editingName, setEditingName] = useState(null) // { id, name }
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
|
||||||
|
const patchLocal = (id, patch) =>
|
||||||
|
setList((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)))
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setPending(true)
|
||||||
|
try {
|
||||||
|
// 逐列保存变更(只提交与初始不同的字段)
|
||||||
|
for (const c of list) {
|
||||||
|
const orig = ordered.find((o) => o.id === c.id)
|
||||||
|
if (!orig) continue
|
||||||
|
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: '已保存' })
|
||||||
|
onSaved?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumn() {
|
||||||
|
if (list.length >= MAX_COLUMNS) { Toast.show({ content: `最多 ${MAX_COLUMNS} 列` }); return }
|
||||||
|
const name = `新列 ${list.length + 1}`
|
||||||
|
try {
|
||||||
|
const r = await columnApi.add(projectId, { name })
|
||||||
|
setList((prev) => [...prev, r.item])
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeColumn(col) {
|
||||||
|
if (list.length <= 1) { Toast.show({ content: '至少保留一列' }); return }
|
||||||
|
const idx = list.findIndex((c) => c.id === col.id)
|
||||||
|
const fallback = list[idx + 1] || list[idx - 1]
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
content: `删除「${col.name}」?其中的任务将迁移到「${fallback.name}」,其状态会按目标列的绑定重新确定。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await columnApi.remove(col.id, fallback.id)
|
||||||
|
setList((prev) => prev.filter((c) => c.id !== col.id))
|
||||||
|
Toast.show({ content: '已删除' })
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(idx, dir) {
|
||||||
|
const next = [...list]
|
||||||
|
const j = idx + dir
|
||||||
|
if (j < 0 || j >= next.length) return
|
||||||
|
;[next[idx], next[j]] = [next[j], next[idx]]
|
||||||
|
setList(next)
|
||||||
|
// 立即持久化顺序
|
||||||
|
columnApi.order(projectId, next.map((c) => c.id)).catch((e) => Toast.show({ content: e.message }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cs-page">
|
||||||
|
<div className="navbar-sub">
|
||||||
|
<span className="back" onClick={onClose}>‹</span>
|
||||||
|
<span className="title">列设置</span>
|
||||||
|
<div className="right">
|
||||||
|
<span className="mini-btn solid" onClick={save}>{pending ? '保存中…' : '保存'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cs-body">
|
||||||
|
<div className="set-hint">
|
||||||
|
调整列顺序会影响「改状态时自动归位」的目标列(取第一个绑定该状态的列)。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-h" style={{ margin: '12px 14px 8px' }}>列与状态绑定</div>
|
||||||
|
|
||||||
|
{list.map((col, idx) => (
|
||||||
|
<div className="set-row" key={col.id}>
|
||||||
|
<span className="cs-ord">
|
||||||
|
<span onClick={() => move(idx, -1)}>↑</span>
|
||||||
|
<span onClick={() => move(idx, 1)}>↓</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="sname"
|
||||||
|
onClick={() => setEditingName({ id: col.id, name: col.name })}
|
||||||
|
>
|
||||||
|
{col.name}
|
||||||
|
</span>
|
||||||
|
<div className="sctrl">
|
||||||
|
<select
|
||||||
|
className={`drop${col.boundStatus ? '' : ' off'}`}
|
||||||
|
value={col.boundStatus || ''}
|
||||||
|
onChange={(e) => patchLocal(col.id, { boundStatus: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">不绑定状态</option>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="cs-del" onClick={() => removeColumn(col)}>删</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={{ padding: '12px 14px' }}>
|
||||||
|
<span className="mini-btn" onClick={addColumn}>+ 新增列</span>
|
||||||
|
<span className="cs-count">当前 {list.length} / {MAX_COLUMNS} 列</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-h" style={{ margin: '6px 14px 8px' }}>已完成任务保留</div>
|
||||||
|
|
||||||
|
{list.map((col) => (
|
||||||
|
<div className="set-row" key={col.id}>
|
||||||
|
<span className="sname" style={{ width: 82 }}>{col.name}</span>
|
||||||
|
<div className="sctrl">
|
||||||
|
<select
|
||||||
|
className="drop"
|
||||||
|
value={col.retention || 'forever'}
|
||||||
|
onChange={(e) => patchLocal(col.id, { retention: e.target.value })}
|
||||||
|
>
|
||||||
|
{RETENTION_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>{RETENTION_LABEL[o]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="set-hint">
|
||||||
|
保留选项:永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示。<br />
|
||||||
|
仅影响已完成任务;未完成任务始终展示。列表与看板按同一规则过滤。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 14px 20px' }}>
|
||||||
|
<div className="btn-primary" onClick={save}>{pending ? '保存中…' : '保存'}</div>
|
||||||
|
<div className="btn-ghost" onClick={onClose}>取消</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={!!editingName}
|
||||||
|
title="列名"
|
||||||
|
content={
|
||||||
|
<input
|
||||||
|
className="inp"
|
||||||
|
value={editingName?.name || ''}
|
||||||
|
maxLength={20}
|
||||||
|
onChange={(e) => setEditingName((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
{ key: 'c', text: '取消', onClick: () => setEditingName(null) },
|
||||||
|
{
|
||||||
|
key: 'ok',
|
||||||
|
text: '确定',
|
||||||
|
bold: true,
|
||||||
|
onClick: () => {
|
||||||
|
const name = (editingName?.name || '').trim()
|
||||||
|
if (!name) { Toast.show({ content: '列名不能为空' }); return }
|
||||||
|
if (list.some((c) => c.id !== editingName.id && c.name === name)) {
|
||||||
|
Toast.show({ content: '列名已存在' }); return
|
||||||
|
}
|
||||||
|
patchLocal(editingName.id, { name })
|
||||||
|
setEditingName(null)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClose={() => setEditingName(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import TaskRow from '../../components/task/TaskRow.jsx'
|
||||||
|
|
||||||
|
// 列表视图 (T-VW-01, T-ST-04/05)
|
||||||
|
// 按看板列分组展示(列表不分组也允许, 这里默认分组更接近 Todoist)
|
||||||
|
// 子任务缩进 + 折叠展开
|
||||||
|
export default function ListView({
|
||||||
|
tasks, columns, onToggle, onOpen, onAddSub,
|
||||||
|
}) {
|
||||||
|
const [collapsed, setCollapsed] = useState({})
|
||||||
|
|
||||||
|
if (!tasks || tasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty" style={{ paddingTop: 90 }}>
|
||||||
|
<div className="ico">✓</div>
|
||||||
|
<p>这个项目还没有任务</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组: 按列顺序, 未匹配列的任务归到一起
|
||||||
|
const groups = []
|
||||||
|
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
for (const c of orderedCols) {
|
||||||
|
const list = tasks.filter((t) => t.columnId === c.id)
|
||||||
|
if (list.length) groups.push({ key: c.id, name: c.name, list })
|
||||||
|
}
|
||||||
|
const known = new Set(orderedCols.map((c) => c.id))
|
||||||
|
const orphans = tasks.filter((t) => !known.has(t.columnId))
|
||||||
|
if (orphans.length) groups.push({ key: 'other', name: '其他', list: orphans })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="list-body">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.key}>
|
||||||
|
<div className="group-title">
|
||||||
|
{g.name}
|
||||||
|
<span className="cnt">{g.list.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{g.list.map((t) => {
|
||||||
|
const kids = t.children || []
|
||||||
|
const isCollapsed = !!collapsed[t.id]
|
||||||
|
return (
|
||||||
|
<div key={t.id}>
|
||||||
|
<TaskRow
|
||||||
|
task={t}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClick={() => onOpen(t)}
|
||||||
|
onMore={() => onOpen(t)}
|
||||||
|
/>
|
||||||
|
{kids.length > 0 && (
|
||||||
|
<>
|
||||||
|
{!isCollapsed && kids.map((c) => (
|
||||||
|
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} />
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="collapse"
|
||||||
|
onClick={() => setCollapsed((p) => ({ ...p, [t.id]: !p[t.id] }))}
|
||||||
|
>
|
||||||
|
{isCollapsed ? `展开 ${kids.length} 个子任务 ⌄` : `收起 ${kids.length} 个子任务 ⌃`}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kids.length === 0 && onAddSub && (
|
||||||
|
<div className="sub-add-hint" onClick={() => onAddSub(t)}>+ 子任务</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Dialog, Toast } from 'antd-mobile'
|
||||||
|
import { projectApi } from '../../api/task.js'
|
||||||
|
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||||
|
|
||||||
|
// 项目成员 (T-PJ-06/07, T-AS-06, T-PM-02)
|
||||||
|
export default function ProjectMembers({ project, onClose, onChanged }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const meId = user?.id
|
||||||
|
const [members, setMembers] = useState(project.members || [])
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
const e = email.trim()
|
||||||
|
if (!e) return
|
||||||
|
try {
|
||||||
|
await projectApi.addMember(project.id, { email: e })
|
||||||
|
setEmail('')
|
||||||
|
setAdding(false)
|
||||||
|
Toast.show({ content: '已添加' })
|
||||||
|
onChanged?.()
|
||||||
|
onClose?.()
|
||||||
|
} catch (err) {
|
||||||
|
Toast.show({ content: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(m, isSelf) {
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
content: isSelf ? '退出该项目?退出后将不再可见。' : `移除成员「${m.name}」?`,
|
||||||
|
confirmText: isSelf ? '退出' : '移除',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await projectApi.removeMember(project.id, m.userId)
|
||||||
|
setMembers((prev) => prev.filter((x) => x.userId !== m.userId))
|
||||||
|
Toast.show({ content: isSelf ? '已退出' : '已移除' })
|
||||||
|
onChanged?.()
|
||||||
|
if (isSelf) onClose?.()
|
||||||
|
} catch (err) {
|
||||||
|
Toast.show({ content: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cs-page">
|
||||||
|
<div className="navbar-sub">
|
||||||
|
<span className="back" onClick={onClose}>‹</span>
|
||||||
|
<span className="title">{project.name}</span>
|
||||||
|
<div className="right">
|
||||||
|
{!project.isPersonal && (
|
||||||
|
<span className="mini-btn solid" onClick={() => setAdding(true)}>+ 添加</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cs-body">
|
||||||
|
<div className="set-hint">
|
||||||
|
{project.isPersonal
|
||||||
|
? '个人默认项目不可添加成员。'
|
||||||
|
: '项目内所有成员权限均等、全读写;负责人与成员只是信息标记,不影响权限。'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{members.map((m) => {
|
||||||
|
const isOwner = m.userId === project.ownerId
|
||||||
|
const isMe = m.userId === meId
|
||||||
|
return (
|
||||||
|
<div className="set-row" key={m.userId} style={{ padding: '12px 14px' }}>
|
||||||
|
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
|
||||||
|
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
|
||||||
|
{m.name}
|
||||||
|
{isMe && <span className="tag grey" style={{ marginLeft: 6 }}>我</span>}
|
||||||
|
{isOwner && <span className="tag grey" style={{ marginLeft: 6 }}>创建人</span>}
|
||||||
|
</span>
|
||||||
|
{isMe && !project.isPersonal && (
|
||||||
|
<span className="link danger" onClick={() => remove(m, true)}>退出</span>
|
||||||
|
)}
|
||||||
|
{!isMe && isOwner && <span className="link" style={{ color: '#999' }}>—</span>}
|
||||||
|
{!isMe && !isOwner && !project.isPersonal && (
|
||||||
|
<span className="link danger" onClick={() => remove(m, false)}>移除</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="set-hint">
|
||||||
|
成员被移出项目后,其担任的负责人 / 成员标记会保留历史并显示为「已退出」,不清空、不报错。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={adding}
|
||||||
|
title="添加成员"
|
||||||
|
content={
|
||||||
|
<input
|
||||||
|
className="inp"
|
||||||
|
placeholder="输入成员邮箱"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
{ key: 'c', text: '取消', onClick: () => setAdding(false) },
|
||||||
|
{ key: 'ok', text: '添加', bold: true, onClick: add },
|
||||||
|
]}
|
||||||
|
onClose={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { DotLoading, Toast } from 'antd-mobile'
|
||||||
|
import { projectApi, taskApi } from '../../api/task.js'
|
||||||
|
import { filterVisible, msToNextBoundary } from './date.js'
|
||||||
|
import { getViewMode, setViewMode } from './prefs.js'
|
||||||
|
import ListView from './ListView.jsx'
|
||||||
|
import BoardView from './BoardView.jsx'
|
||||||
|
import ColumnSettings from './ColumnSettings.jsx'
|
||||||
|
import TaskDetail from './TaskDetail.jsx'
|
||||||
|
import ProjectMembers from './ProjectMembers.jsx'
|
||||||
|
|
||||||
|
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
|
||||||
|
export default function ProjectView({ projectId, onBack }) {
|
||||||
|
const [project, setProject] = useState(null)
|
||||||
|
const [tasks, setTasks] = useState(null)
|
||||||
|
const [columns, setColumns] = useState([])
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [mode, setMode] = useState(() => getViewMode(projectId) || 'list')
|
||||||
|
const [openTask, setOpenTask] = useState(null)
|
||||||
|
const [showColumns, setShowColumns] = useState(false)
|
||||||
|
const [showMembers, setShowMembers] = useState(false)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [tick, setTick] = useState(0) // 保留窗口重算触发
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setErr('')
|
||||||
|
Promise.all([projectApi.detail(projectId), taskApi.list(projectId)])
|
||||||
|
.then(([p, t]) => {
|
||||||
|
setProject(p.item)
|
||||||
|
setColumns(p.item.columns || [])
|
||||||
|
setTasks(t.items || [])
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(e.message))
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => { setTasks(null); load() }, [load])
|
||||||
|
|
||||||
|
// 切换视图并记忆 (T-VW-02/03/06)
|
||||||
|
const switchMode = (next) => {
|
||||||
|
setMode(next)
|
||||||
|
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 }
|
||||||
|
const kids = (t.children || []).map((c) => (c.id === id ? { ...c, ...patch } : c))
|
||||||
|
return { ...t, children: kids }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const replaceTask = (fresh) => setTasks((prev) => (prev || []).map((t) => (t.id === fresh.id ? fresh : t)))
|
||||||
|
|
||||||
|
/** 更新任务(列/状态/字段), 服务端裁决后以返回值为准 */
|
||||||
|
async function updateTask(id, patch) {
|
||||||
|
const snapshot = tasks
|
||||||
|
patchTask(id, patch)
|
||||||
|
try {
|
||||||
|
const r = await taskApi.update(id, patch)
|
||||||
|
replaceTask(r.item)
|
||||||
|
return r.item
|
||||||
|
} catch (e) {
|
||||||
|
setTasks(snapshot)
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 勾选完成 / 取消完成 */
|
||||||
|
const toggleDone = (task) => updateTask(task.id, {
|
||||||
|
status: task.status === 'done' ? 'todo' : 'done',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 拖拽换列 (T-BD-04/07) */
|
||||||
|
const moveToColumn = async (task, columnId) => {
|
||||||
|
const col = columns.find((c) => c.id === columnId)
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { columnId })
|
||||||
|
const target = columns.find((c) => c.id === columnId)
|
||||||
|
if (target?.boundStatus && target.boundStatus !== task.status) {
|
||||||
|
Toast.show({ content: `已移入 ${col?.name},状态改为「${target.boundStatus === 'done' ? '完成' : target.boundStatus === 'doing' ? '执行中' : '未开始'}」` })
|
||||||
|
} else {
|
||||||
|
Toast.show({ content: `已移入 ${col?.name}` })
|
||||||
|
}
|
||||||
|
} catch { /* 已回滚并提示 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改状态 (T-BD-14~16) */
|
||||||
|
const changeStatus = async (task, status) => {
|
||||||
|
try {
|
||||||
|
const fresh = await updateTask(task.id, { status })
|
||||||
|
const moved = fresh && fresh.columnId !== task.columnId
|
||||||
|
if (moved) {
|
||||||
|
const col = columns.find((c) => c.id === fresh.columnId)
|
||||||
|
Toast.show({ content: `已移至 ${col?.name || '目标列'}` })
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽降为子任务 (T-ST-12) */
|
||||||
|
const makeSubtask = async (task, parentId) => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { parentId })
|
||||||
|
// 成为子任务后不再作为顶层卡片出现
|
||||||
|
setTasks((prev) => (prev || []).filter((t) => t.id !== task.id))
|
||||||
|
Toast.show({ content: '已转为子任务' })
|
||||||
|
load()
|
||||||
|
} catch { /* 错误已提示 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建任务 */
|
||||||
|
async function createTask(title, parentId = null) {
|
||||||
|
const t = String(title || '').trim()
|
||||||
|
if (!t) return
|
||||||
|
try {
|
||||||
|
await taskApi.create(projectId, { title: t, parentId })
|
||||||
|
setAdding(false)
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="page-with-navbar">
|
||||||
|
<div className="navbar-sub">
|
||||||
|
<span className="back" onClick={onBack}>‹</span>
|
||||||
|
<span className="title">任务</span>
|
||||||
|
</div>
|
||||||
|
<div className="empty">
|
||||||
|
<div className="ico err">!</div>
|
||||||
|
<p>{err}</p>
|
||||||
|
<div className="mini-btn solid" onClick={load}>重试</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project || !tasks) {
|
||||||
|
return (
|
||||||
|
<div className="page-with-navbar">
|
||||||
|
<div className="navbar-sub">
|
||||||
|
<span className="back" onClick={onBack}>‹</span>
|
||||||
|
<span className="title">任务</span>
|
||||||
|
</div>
|
||||||
|
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-with-navbar">
|
||||||
|
<div className="navbar-sub">
|
||||||
|
<span className="back" onClick={onBack}>‹</span>
|
||||||
|
<span className="title">{project.name}</span>
|
||||||
|
<div className="right">
|
||||||
|
<span className="mini-btn" onClick={() => setShowMembers(true)}>成员</span>
|
||||||
|
<span className="mini-btn" onClick={() => setAdding((v) => !v)}>+ 新增</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`task-content${mode === 'board' ? ' is-board' : ''}`}>
|
||||||
|
<div className="pv-head">
|
||||||
|
<div className="seg">
|
||||||
|
<div className={mode === 'list' ? 'on' : ''} onClick={() => switchMode('list')}>列表</div>
|
||||||
|
<div className={mode === 'board' ? 'on' : ''} onClick={() => switchMode('board')}>看板</div>
|
||||||
|
</div>
|
||||||
|
<div className="right">
|
||||||
|
<span className="mini-btn grey" onClick={() => setShowColumns(true)}>列设置</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<div className="quick-add">
|
||||||
|
<input
|
||||||
|
className="inp"
|
||||||
|
autoFocus
|
||||||
|
placeholder="输入任务标题,回车创建"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') createTask(e.currentTarget.value)
|
||||||
|
if (e.key === 'Escape') setAdding(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'list' ? (
|
||||||
|
<ListView
|
||||||
|
tasks={visibleTasks}
|
||||||
|
columns={columns}
|
||||||
|
onToggle={toggleDone}
|
||||||
|
onOpen={(t) => setOpenTask(t)}
|
||||||
|
onAddSub={(parent) => setOpenTask(parent)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BoardView
|
||||||
|
tasks={visibleTasks}
|
||||||
|
columns={columns}
|
||||||
|
onMoveColumn={moveToColumn}
|
||||||
|
onMakeSubtask={makeSubtask}
|
||||||
|
onOpen={(t) => setOpenTask(t)}
|
||||||
|
onToggleSub={(c) => toggleDone(c)}
|
||||||
|
onToggle={(t) => toggleDone(t)}
|
||||||
|
onQuickAdd={() => { setAdding(true) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openTask && (
|
||||||
|
<TaskDetail
|
||||||
|
task={openTask}
|
||||||
|
columns={columns}
|
||||||
|
members={project.members || []}
|
||||||
|
onClose={() => setOpenTask(null)}
|
||||||
|
onSaved={() => { setOpenTask(null); load() }}
|
||||||
|
onChanged={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showColumns && (
|
||||||
|
<ColumnSettings
|
||||||
|
projectId={projectId}
|
||||||
|
columns={columns}
|
||||||
|
onClose={() => setShowColumns(false)}
|
||||||
|
onSaved={() => { setShowColumns(false); load() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMembers && (
|
||||||
|
<ProjectMembers
|
||||||
|
project={project}
|
||||||
|
onClose={() => setShowMembers(false)}
|
||||||
|
onChanged={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Dialog, Popup, Toast } from 'antd-mobile'
|
||||||
|
import { projectApi, taskApi } from '../../api/task.js'
|
||||||
|
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||||
|
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
|
||||||
|
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||||
|
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||||
|
|
||||||
|
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
|
||||||
|
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
|
||||||
|
const [form, setForm] = useState(() => ({
|
||||||
|
title: task.title || '',
|
||||||
|
description: task.description || '',
|
||||||
|
extras: task.extras || [],
|
||||||
|
memberIds: (task.members || []).map((m) => m.userId),
|
||||||
|
assigneeId: task.assignee?.id ?? null,
|
||||||
|
status: task.status,
|
||||||
|
tags: task.tags || [],
|
||||||
|
}))
|
||||||
|
const [subs, setSubs] = useState(task.children || [])
|
||||||
|
const [newSub, setNewSub] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||||
|
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
|
||||||
|
|
||||||
|
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
|
||||||
|
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm({
|
||||||
|
title: task.title || '',
|
||||||
|
description: task.description || '',
|
||||||
|
extras: task.extras || [],
|
||||||
|
memberIds: (task.members || []).map((m) => m.userId),
|
||||||
|
assigneeId: task.assignee?.id ?? null,
|
||||||
|
status: task.status,
|
||||||
|
tags: task.tags || [],
|
||||||
|
})
|
||||||
|
setSubs(task.children || [])
|
||||||
|
}, [task])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.title.trim()) { Toast.show({ content: '标题不能为空' }); return }
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const r = await taskApi.update(task.id, {
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description,
|
||||||
|
extras: form.extras,
|
||||||
|
assigneeId: form.assigneeId,
|
||||||
|
memberIds: form.memberIds,
|
||||||
|
status: form.status,
|
||||||
|
tags: form.tags,
|
||||||
|
})
|
||||||
|
Toast.show({ content: '已保存' })
|
||||||
|
onSaved?.(r.item)
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSub() {
|
||||||
|
const t = newSub.trim()
|
||||||
|
if (!t) return
|
||||||
|
try {
|
||||||
|
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
|
||||||
|
setSubs((prev) => [...prev, r.item])
|
||||||
|
setNewSub('')
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSub(sub) {
|
||||||
|
const next = sub.status === 'done' ? 'todo' : 'done'
|
||||||
|
try {
|
||||||
|
const r = await taskApi.update(sub.id, { status: next })
|
||||||
|
setSubs((prev) => prev.map((s) => (s.id === sub.id ? r.item : s)))
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTask() {
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
content: subs.length
|
||||||
|
? `删除该任务?将一并删除 ${subs.length} 条子任务。`
|
||||||
|
: '删除该任务?',
|
||||||
|
confirmText: '删除',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await taskApi.remove(task.id)
|
||||||
|
Toast.show({ content: '已删除' })
|
||||||
|
onSaved?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCopy() {
|
||||||
|
try {
|
||||||
|
const d = await projectApi.list()
|
||||||
|
setCopies((d.items || []).filter((p) => p.id !== task.projectId && !p.archived))
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCopy(projectId) {
|
||||||
|
try {
|
||||||
|
const r = await taskApi.copy(task.id, projectId)
|
||||||
|
const dropped = r.dropped || {}
|
||||||
|
const lost = [dropped.assignee?.name, ...(dropped.members || []).map((m) => m.name)].filter(Boolean)
|
||||||
|
Toast.show({
|
||||||
|
content: lost.length
|
||||||
|
? `已拷贝到目标项目;${lost.join('、')} 不是该项目成员,已不保留`
|
||||||
|
: '已拷贝到目标项目',
|
||||||
|
})
|
||||||
|
setCopies(null)
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible
|
||||||
|
onMaskClick={onClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px 14px' }}
|
||||||
|
>
|
||||||
|
<div className="sheet-title">{task.title}</div>
|
||||||
|
<div className="sheet-sub">
|
||||||
|
{task.column?.name} · {STATUS_LABEL[task.status]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>标题</label>
|
||||||
|
<input
|
||||||
|
className="inp"
|
||||||
|
value={form.title}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>描述</label>
|
||||||
|
<textarea
|
||||||
|
className="inp"
|
||||||
|
rows={2}
|
||||||
|
placeholder="补充说明…"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>状态</label>
|
||||||
|
<div className="seg">
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
className={form.status === s ? 'on' : ''}
|
||||||
|
onClick={() => setForm((p) => ({ ...p, status: s }))}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[s]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>负责人</label>
|
||||||
|
<div className="pick-row" onClick={() => setPicker('assignee')}>
|
||||||
|
{assigneeName ? (
|
||||||
|
<div className="avatar">{assigneeName.slice(0, 1)}</div>
|
||||||
|
) : (
|
||||||
|
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
|
||||||
|
)}
|
||||||
|
<span className="pn">{assigneeName || '未指派'}</span>
|
||||||
|
<span className="link">更换</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>任务成员</label>
|
||||||
|
<div className="pick-row" onClick={() => setPicker('members')}>
|
||||||
|
<div className="avatar" style={{ background: '#eef2f7', color: '#7b8794' }}>
|
||||||
|
{form.memberIds.length || 0}
|
||||||
|
</div>
|
||||||
|
<span className="pn" style={{ fontSize: 12.5, color: form.memberIds.length ? 'inherit' : '#999' }}>
|
||||||
|
{memberNames.length ? memberNames.join('、') : '未选择成员'}
|
||||||
|
</span>
|
||||||
|
<span className="link">编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExtrasCard
|
||||||
|
extras={form.extras}
|
||||||
|
onChange={(next) => setForm((p) => ({ ...p, extras: next }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="section-h">子任务 <span className="tag">{subs.length}</span></div>
|
||||||
|
{subs.map((s) => (
|
||||||
|
<div className="pick-row" key={s.id} onClick={() => toggleSub(s)}>
|
||||||
|
<span className={`ck${s.status === 'done' ? ' on' : ''}`} />
|
||||||
|
<span className="pn">{s.title}</span>
|
||||||
|
<StatusDot status={s.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="extra-row" style={{ marginTop: 8 }}>
|
||||||
|
<input
|
||||||
|
className="inp"
|
||||||
|
placeholder="添加子任务,回车确认"
|
||||||
|
value={newSub}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(e) => setNewSub(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
|
||||||
|
<div className="btn-ghost" onClick={openCopy}>拷贝到其他项目</div>
|
||||||
|
<div className="btn-danger" onClick={removeTask}>
|
||||||
|
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserPicker
|
||||||
|
visible={picker === 'assignee'}
|
||||||
|
title="选择负责人"
|
||||||
|
members={members}
|
||||||
|
value={form.assigneeId}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
onConfirm={(uid) => setForm((p) => ({ ...p, assigneeId: uid }))}
|
||||||
|
/>
|
||||||
|
<UserPicker
|
||||||
|
visible={picker === 'members'}
|
||||||
|
title="选择任务成员"
|
||||||
|
multiple
|
||||||
|
members={members}
|
||||||
|
value={form.memberIds}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
visible={!!copies}
|
||||||
|
onMaskClick={() => setCopies(null)}
|
||||||
|
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}
|
||||||
|
>
|
||||||
|
<div className="sheet-title">跨项目拷贝</div>
|
||||||
|
<div className="sheet-sub">源任务保持不变;新任务的创建人是你</div>
|
||||||
|
{(copies || []).length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
|
||||||
|
{(copies || []).map((p) => (
|
||||||
|
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id)}>
|
||||||
|
<div className="avatar">{(p.name || '?').slice(0, 1)}</div>
|
||||||
|
<span className="pn">{p.name}</span>
|
||||||
|
<span className="link">拷贝</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="btn-ghost" onClick={() => setCopies(null)}>取消</div>
|
||||||
|
</Popup>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Dialog, DotLoading, Empty, Input, Toast } from 'antd-mobile'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { projectApi } from '../../api/task.js'
|
||||||
|
import { getLastProjectId, setLastProjectId, prunePrefs } from './prefs.js'
|
||||||
|
import ProjectView from './ProjectView.jsx'
|
||||||
|
|
||||||
|
// 任务模块容器 (T-PM-06)
|
||||||
|
// 无 :pid -> 项目列表
|
||||||
|
// 有 :pid -> 该项目视图(列表/看板)
|
||||||
|
export default function TaskPage() {
|
||||||
|
const { pid } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [projects, setProjects] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setErr('')
|
||||||
|
projectApi.list()
|
||||||
|
.then((d) => {
|
||||||
|
const items = d.items || []
|
||||||
|
setProjects(items)
|
||||||
|
prunePrefs(items.map((p) => p.id))
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(e.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// 记住上次访问的项目 (T-VW-07)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pid) setLastProjectId(Number(pid))
|
||||||
|
}, [pid])
|
||||||
|
|
||||||
|
if (pid) {
|
||||||
|
return <ProjectView projectId={Number(pid)} onBack={() => navigate('/task')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-with-navbar">
|
||||||
|
<PageHeader title="任务" />
|
||||||
|
<div className="task-content">
|
||||||
|
<ProjectList
|
||||||
|
projects={projects}
|
||||||
|
err={err}
|
||||||
|
onRetry={load}
|
||||||
|
onOpen={(id) => {
|
||||||
|
setLastProjectId(id)
|
||||||
|
navigate(`/task/${id}`)
|
||||||
|
}}
|
||||||
|
onChanged={load}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="empty">
|
||||||
|
<div className="ico err">!</div>
|
||||||
|
<p>{err}</p>
|
||||||
|
<div className="mini-btn solid" onClick={onRetry}>重试</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!projects) {
|
||||||
|
return (
|
||||||
|
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = projects.filter((p) => !p.archived)
|
||||||
|
const archived = projects.filter((p) => p.archived)
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const t = name.trim()
|
||||||
|
if (!t) { Toast.show({ content: '请输入项目名称' }); return }
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await projectApi.create({ name: t })
|
||||||
|
setName('')
|
||||||
|
setCreating(false)
|
||||||
|
Toast.show({ content: '已创建' })
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
||||||
|
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="proj-head">
|
||||||
|
<b>我的项目</b>
|
||||||
|
<div className="right">
|
||||||
|
<div className="mini-btn" onClick={() => setCreating(true)}>+ 新建</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active.map((p) => (
|
||||||
|
<div className="proj-item" key={p.id} onClick={() => onOpen(p.id)}>
|
||||||
|
<div className="pico" style={{ background: colorOf(p) }}>
|
||||||
|
{(p.name || '?').slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
<div className="pmain">
|
||||||
|
<div className="ptitle">
|
||||||
|
{p.name}
|
||||||
|
{p.isPersonal && <span className="badge">个人</span>}
|
||||||
|
</div>
|
||||||
|
<div className="psub">
|
||||||
|
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pcount">›</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{active.length === 0 && (
|
||||||
|
<div className="empty" style={{ paddingTop: 90 }}>
|
||||||
|
<div className="ico">▤</div>
|
||||||
|
<p>还没有协作项目</p>
|
||||||
|
<div className="mini-btn solid" onClick={() => setCreating(true)}>新建项目</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{archived.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="proj-group">已归档({archived.length})</div>
|
||||||
|
{archived.map((p) => (
|
||||||
|
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
|
||||||
|
<div className="pico" style={{ background: '#a8b0ba' }}>{(p.name || '?').slice(0, 1)}</div>
|
||||||
|
<div className="pmain">
|
||||||
|
<div className="ptitle">{p.name}</div>
|
||||||
|
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
|
||||||
|
</div>
|
||||||
|
<div className="pcount">›</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={creating}
|
||||||
|
title="新建项目"
|
||||||
|
content={
|
||||||
|
<div className="field" style={{ marginTop: 8 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="项目名称"
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
{ key: 'cancel', text: '取消', onClick: () => setCreating(false) },
|
||||||
|
{ key: 'ok', text: saving ? '创建中…' : '创建', bold: true, onClick: create },
|
||||||
|
]}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免未使用告警
|
||||||
|
void Empty
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 任务模块前端: 自然日边界 + 保留选项可见性
|
||||||
|
// 与后端 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']
|
||||||
|
|
||||||
|
const FMT = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
export function toDateStr(d = new Date()) {
|
||||||
|
return FMT.format(d instanceof Date ? d : new Date(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留选项 -> 窗口起点 ("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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 任务模块: 设备端偏好 (不落库, 不跨设备)
|
||||||
|
// lastProjectId 上次访问的项目 (T-VW-07)
|
||||||
|
// viewMode 按项目记忆的视图 list | board (T-VW-03/06)
|
||||||
|
const KEY = 'iboard_task_prefs'
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return { lastProjectId: null, viewMode: {} }
|
||||||
|
const obj = JSON.parse(raw)
|
||||||
|
return {
|
||||||
|
lastProjectId: obj.lastProjectId ?? null,
|
||||||
|
viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {},
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { lastProjectId: null, viewMode: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(obj) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(obj))
|
||||||
|
} catch { /* 忽略配额错误 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastProjectId() {
|
||||||
|
return read().lastProjectId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLastProjectId(id) {
|
||||||
|
const cur = read()
|
||||||
|
cur.lastProjectId = id ?? null
|
||||||
|
write(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视图记忆: 无记录返回 null, 由调用方决定默认值 */
|
||||||
|
export function getViewMode(projectId) {
|
||||||
|
if (!projectId) return null
|
||||||
|
return read().viewMode[String(projectId)] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setViewMode(projectId, mode) {
|
||||||
|
if (!projectId) return
|
||||||
|
const cur = read()
|
||||||
|
cur.viewMode[String(projectId)] = mode
|
||||||
|
write(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理已不可见的项目记录 */
|
||||||
|
export function prunePrefs(visibleIds) {
|
||||||
|
const set = new Set((visibleIds || []).map(String))
|
||||||
|
const cur = read()
|
||||||
|
if (cur.lastProjectId && !set.has(String(cur.lastProjectId))) cur.lastProjectId = null
|
||||||
|
for (const k of Object.keys(cur.viewMode)) {
|
||||||
|
if (!set.has(k)) delete cur.viewMode[k]
|
||||||
|
}
|
||||||
|
write(cur)
|
||||||
|
}
|
||||||
@@ -883,3 +883,216 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
.mv-sheet-row.danger { color: var(--color-danger); }
|
.mv-sheet-row.danger { color: var(--color-danger); }
|
||||||
.mv-sheet-row.disabled { opacity: .45; cursor: default; }
|
.mv-sheet-row.disabled { opacity: .45; cursor: default; }
|
||||||
.mv-sheet-row .k { font-size: 11px; color: #b8bcc4; }
|
.mv-sheet-row .k { font-size: 11px; color: #b8bcc4; }
|
||||||
|
|
||||||
|
/* ================== 任务模块 (V0.2, 需求 T-*) ================== */
|
||||||
|
/* 项目列表 / 列表视图 / 看板视图 / 列设置 / 任务详情 / 成员 */
|
||||||
|
.task-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; }
|
||||||
|
.task-content.is-board { overflow: hidden; }
|
||||||
|
|
||||||
|
/* ---- 项目列表 ---- */
|
||||||
|
.proj-head { display: flex; align-items: center; padding: 12px 14px 8px; }
|
||||||
|
.proj-head b { font-size: 16px; }
|
||||||
|
.proj-head .right { margin-left: auto; display: flex; gap: 6px; }
|
||||||
|
.proj-group { font-size: 11px; color: #b8bcc4; letter-spacing: .5px; padding: 10px 16px 6px; }
|
||||||
|
.proj-item {
|
||||||
|
display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 10px;
|
||||||
|
padding: 13px 14px; margin: 0 12px 8px; box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer;
|
||||||
|
}
|
||||||
|
.proj-item .pico {
|
||||||
|
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
|
||||||
|
justify-content: center; color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.proj-item .pmain { flex: 1; min-width: 0; }
|
||||||
|
.proj-item .ptitle { font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.proj-item .psub { font-size: 11.5px; color: var(--color-muted); margin-top: 2px; }
|
||||||
|
.proj-item .pcount { font-size: 15px; color: #c0c4cc; flex-shrink: 0; }
|
||||||
|
.badge { font-size: 10px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 0 5px; font-weight: 400; }
|
||||||
|
|
||||||
|
/* ---- 视图头部 / 快速新增 ---- */
|
||||||
|
.pv-head { padding: 10px 14px 8px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||||
|
.pv-head .seg { display: flex; background: #f2f3f5; border-radius: 8px; padding: 3px; width: 112px; }
|
||||||
|
.pv-head .seg div { flex: 1; text-align: center; font-size: 13px; padding: 5px 0; border-radius: 6px; color: var(--color-muted); cursor: pointer; }
|
||||||
|
.pv-head .seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||||||
|
.pv-head .right { margin-left: auto; display: flex; gap: 6px; }
|
||||||
|
.mini-btn {
|
||||||
|
font-size: 11px; border: 1px solid var(--color-primary); color: var(--color-primary);
|
||||||
|
border-radius: 999px; padding: 3px 10px; background: #fff; white-space: nowrap; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mini-btn.solid { background: var(--color-primary); color: #fff; }
|
||||||
|
.mini-btn.grey { border-color: #dcdcdc; color: var(--color-muted); }
|
||||||
|
.quick-add { padding: 0 12px 8px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ---- 列表视图 ---- */
|
||||||
|
.list-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 16px; }
|
||||||
|
.group-title { display: flex; align-items: center; gap: 7px; padding: 12px 16px 6px; font-size: 12px; color: var(--color-muted); font-weight: 600; }
|
||||||
|
.group-title::before { content: ""; width: 3px; height: 12px; border-radius: 2px; background: var(--color-primary); }
|
||||||
|
.group-title .cnt { color: #b8bcc4; font-weight: 400; }
|
||||||
|
.task-row {
|
||||||
|
display: flex; align-items: flex-start; gap: 9px; background: #fff; border-radius: 9px;
|
||||||
|
padding: 10px 12px; margin: 0 12px 7px; box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer;
|
||||||
|
}
|
||||||
|
.task-row.done { background: #fafbfa; }
|
||||||
|
.task-row .box, .sub-row .box {
|
||||||
|
width: 19px; height: 19px; border-radius: 50%; border: 1.5px solid #d0d0d0;
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.task-row.done .box, .sub-row.done .box { background: #00b578; border-color: #00b578; }
|
||||||
|
.box svg { opacity: 0; }
|
||||||
|
.task-row.done .box svg, .sub-row.done .box svg { opacity: 1; }
|
||||||
|
.task-main { flex: 1; min-width: 0; }
|
||||||
|
.task-title { font-size: 14px; display: flex; align-items: center; gap: 6px; word-break: break-word; }
|
||||||
|
.task-row.done .task-title { color: #a0a0a0; text-decoration: line-through; }
|
||||||
|
.task-meta { display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px; color: #999; flex-wrap: wrap; }
|
||||||
|
.task-meta .col { color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
|
||||||
|
.task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
|
||||||
|
.task-meta .assignee.none { color: #b8bcc4; font-weight: 400; }
|
||||||
|
.task-meta .members { font-size: 10.5px; color: #a7adb6; }
|
||||||
|
.task-right { display: flex; align-items: center; flex-shrink: 0; }
|
||||||
|
.more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; }
|
||||||
|
.sub-row {
|
||||||
|
display: flex; align-items: center; gap: 8px; background: #fff; border-radius: 8px;
|
||||||
|
padding: 8px 12px 8px 30px; margin: -2px 12px 7px 26px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||||
|
position: relative; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; }
|
||||||
|
.sub-row .box { width: 16px; height: 16px; }
|
||||||
|
.sub-title { flex: 1; font-size: 13px; color: #444; }
|
||||||
|
.sub-row.done .sub-title { color: #a8a8a8; text-decoration: line-through; }
|
||||||
|
.collapse { font-size: 11px; color: var(--color-primary); padding: 2px 0 8px 30px; margin: 0 12px; cursor: pointer; }
|
||||||
|
.sub-add-hint { font-size: 11px; color: #b8bcc4; padding: 0 0 8px 44px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ---- 状态点 ---- */
|
||||||
|
.sdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
|
.sdot.todo { background: #c9ced6; }
|
||||||
|
.sdot.doing { background: #1677ff; }
|
||||||
|
.sdot.done { background: #00b578; }
|
||||||
|
.sdot.pill { width: auto; height: auto; border-radius: 999px; font-size: 10px; padding: 0 6px; line-height: 15px; color: #fff; }
|
||||||
|
.sdot.pill.todo { background: #a8b0ba; }
|
||||||
|
.sdot.pill.doing { background: #1677ff; }
|
||||||
|
.sdot.pill.done { background: #00b578; }
|
||||||
|
|
||||||
|
/* ---- 看板视图 ---- */
|
||||||
|
.board-wrap {
|
||||||
|
display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden;
|
||||||
|
flex: 1 1 auto; min-height: 0; align-items: stretch;
|
||||||
|
}
|
||||||
|
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.bcol-head { display: flex; align-items: center; gap: 5px; padding: 0 2px 7px; flex-shrink: 0; }
|
||||||
|
.bcol-name { font-size: 12px; font-weight: 700; color: #3d4450; }
|
||||||
|
.bcol-cnt { font-size: 11px; color: #999; background: #e8ebef; border-radius: 999px; padding: 0 6px; line-height: 15px; }
|
||||||
|
.bcol-bind { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 4px; margin-left: auto; }
|
||||||
|
.bcol-bind.off { color: #999; background: #f2f3f5; }
|
||||||
|
.bcol-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px; }
|
||||||
|
.bcol.dragover .bcol-body { background: #eaf2ff; border-radius: 8px; outline: 1.5px dashed var(--color-primary); }
|
||||||
|
.bcol-empty { font-size: 11px; color: #c0c4cc; text-align: center; padding: 10px 0; }
|
||||||
|
.bcard {
|
||||||
|
background: #fff; border-radius: 8px; padding: 9px 10px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bcard.dragging { opacity: .55; transform: scale(.97) rotate(-1deg); box-shadow: 0 8px 18px rgba(15,23,42,.16); }
|
||||||
|
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
|
||||||
|
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
|
||||||
|
.bstat { display: flex; align-items: center; padding: 2px; cursor: pointer; }
|
||||||
|
.bmeta { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 10.5px; }
|
||||||
|
.bassignee { font-size: 11px; font-weight: 600; color: #4a5160; }
|
||||||
|
.bassignee.none { color: #b8bcc4; font-weight: 400; }
|
||||||
|
.bmembers { font-size: 10px; color: #a7adb6; }
|
||||||
|
.bsub {
|
||||||
|
display: flex; align-items: center; gap: 5px; margin-top: 5px; padding-top: 5px;
|
||||||
|
border-top: 1px dashed #eef0f3; font-size: 11px; color: #5a616b; cursor: pointer;
|
||||||
|
}
|
||||||
|
.bsub .sbox { width: 13px; height: 13px; border-radius: 50%; border: 1.3px solid #d0d0d0; flex-shrink: 0; }
|
||||||
|
.bsub.done .sbox { background: #00b578; border-color: #00b578; }
|
||||||
|
.bsub .stitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bsub.done .stitle { color: #a8a8a8; text-decoration: line-through; }
|
||||||
|
.bmore { font-size: 10px; color: #999; margin-top: 4px; }
|
||||||
|
.bsub-add { font-size: 10.5px; color: #b8bcc4; margin-top: 6px; cursor: pointer; }
|
||||||
|
.badd {
|
||||||
|
background: rgba(255,255,255,.6); border: 1px dashed #d5dae1; border-radius: 8px;
|
||||||
|
padding: 8px; text-align: center; font-size: 11.5px; color: #b8bcc4; cursor: pointer; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.drag-ghost {
|
||||||
|
position: fixed; z-index: 999; transform: translate(-50%, -50%); pointer-events: none;
|
||||||
|
background: rgba(22,119,255,.92); color: #fff; font-size: 12px; padding: 6px 10px;
|
||||||
|
border-radius: 8px; max-width: 180px; box-shadow: 0 8px 18px rgba(15,23,42,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 列设置 / 成员页 ---- */
|
||||||
|
.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; }
|
||||||
|
.cs-page .navbar-sub { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
|
||||||
|
.cs-page .navbar-sub .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
|
||||||
|
.cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; }
|
||||||
|
.cs-page .navbar-sub .right { margin-left: auto; display: flex; gap: 6px; }
|
||||||
|
.cs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
|
||||||
|
.set-row {
|
||||||
|
display: flex; align-items: center; gap: 10px; background: #fff;
|
||||||
|
padding: 12px 14px; margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
.set-row .sname { font-size: 13px; font-weight: 600; width: 64px; flex-shrink: 0; cursor: pointer; }
|
||||||
|
.set-row .sctrl { flex: 1; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||||
|
.set-hint { font-size: 11px; color: #999; padding: 8px 14px 14px; line-height: 1.7; }
|
||||||
|
.section-h { font-size: 11px; color: #b8bcc4; letter-spacing: .5px; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.drop {
|
||||||
|
border: 1px solid var(--color-border); border-radius: 7px; padding: 4px 6px; font-size: 12px;
|
||||||
|
color: var(--color-text); background: #fff; min-width: 96px;
|
||||||
|
}
|
||||||
|
.drop.off { color: #999; background: #f7f8fa; }
|
||||||
|
.cs-ord { display: flex; flex-direction: column; font-size: 9px; color: #c0c4cc; line-height: 1.1; }
|
||||||
|
.cs-ord span { cursor: pointer; }
|
||||||
|
.cs-del { font-size: 11px; color: #ff3141; cursor: pointer; padding: 2px 4px; }
|
||||||
|
.cs-count { font-size: 11px; color: #999; margin-left: 8px; }
|
||||||
|
|
||||||
|
/* ---- 任务详情 ---- */
|
||||||
|
.sheet-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word; }
|
||||||
|
.sheet-sub { font-size: 11.5px; color: #999; margin-bottom: 14px; }
|
||||||
|
.field { margin-bottom: 13px; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
|
||||||
|
.inp {
|
||||||
|
border: 1px solid var(--color-border); border-radius: 8px; padding: 8px 10px;
|
||||||
|
font-size: 14px; background: #fff; color: var(--color-text); width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
textarea.inp { resize: vertical; }
|
||||||
|
.pick-row { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid var(--color-border); cursor: pointer; }
|
||||||
|
.pick-row:last-child { border-bottom: none; }
|
||||||
|
.pick-row .pn { flex: 1; font-size: 13.5px; }
|
||||||
|
.avatar {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%; background: #dfe6f0; color: #5a6a80;
|
||||||
|
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ck { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid #d0d0d0; flex-shrink: 0; position: relative; }
|
||||||
|
.ck.on { background: var(--color-primary); border-color: var(--color-primary); }
|
||||||
|
.ck.on::after {
|
||||||
|
content: ""; position: absolute; left: 5px; top: 2px; width: 4px; height: 8px;
|
||||||
|
border: solid #fff; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.btn-primary { text-align: center; background: var(--color-primary); color: #fff; border-radius: 8px; padding: 10px 0; font-size: 15px; font-weight: 600; margin-top: 18px; cursor: pointer; }
|
||||||
|
.btn-ghost { text-align: center; border: 1px solid var(--color-border); border-radius: 8px; padding: 9px 0; font-size: 15px; color: var(--color-muted); margin-top: 8px; background: #fff; cursor: pointer; }
|
||||||
|
.btn-danger { text-align: center; border: 1px solid #ff3141; border-radius: 8px; padding: 9px 0; font-size: 15px; color: #ff3141; margin-top: 8px; background: #fff; cursor: pointer; }
|
||||||
|
.del-hint { font-size: 11px; font-weight: 400; }
|
||||||
|
.extra-row { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
|
||||||
|
.extra-k { flex: 0 0 82px; }
|
||||||
|
.extra-v { flex: 1; }
|
||||||
|
.extra-del { border: none; background: none; color: #c0c4cc; font-size: 18px; line-height: 1; cursor: pointer; }
|
||||||
|
.extras { margin-top: 4px; }
|
||||||
|
.extras-head {
|
||||||
|
display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600;
|
||||||
|
padding: 9px 10px; background: #f7f8fa; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.extras-head .caret { font-size: 10px; color: #999; }
|
||||||
|
.extras-head .cv { margin-left: auto; color: #b8bcc4; font-size: 10px; font-weight: 400; }
|
||||||
|
.extras-body { padding: 4px 0 2px; }
|
||||||
|
.extras-empty { font-size: 11.5px; color: #b8bcc4; padding: 8px 2px; }
|
||||||
|
.extras-add { font-size: 12px; color: var(--color-primary); background: none; border: none; padding: 6px 2px; cursor: pointer; }
|
||||||
|
.tag { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
|
||||||
|
.tag.grey { color: #999; background: #f2f3f5; }
|
||||||
|
|
||||||
|
/* ---- 空态 / 错误态 ---- */
|
||||||
|
.empty { text-align: center; padding: 70px 20px; color: #999; }
|
||||||
|
.empty .ico {
|
||||||
|
width: 64px; height: 64px; margin: 0 auto 12px; border-radius: 50%; background: #f2f3f5;
|
||||||
|
display: flex; align-items: center; justify-content: center; color: #c4c8ce; font-size: 30px;
|
||||||
|
}
|
||||||
|
.empty .ico.err { background: #fff1f0; color: #ff7875; }
|
||||||
|
.empty p { font-size: 13px; margin-bottom: 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user