Docker Build and Push / build-image (push) Successful in 4m5s
1. BUILD_ID 改为 yyyyMMddHHmmss (去掉中间点), 仍 UTC+8
2. 任务 ⋯ 菜单去掉顶部任务名标题
3. 任务详情去掉底部「拷贝到其他项目」「删除任务」按钮 (⋯ 菜单已有)
4+6. 键盘遮挡统一方案 (重点设计 §7.2):
- index.html 加 interactive-widget=resizes-content
- App 级 useKeyboardInsetWatcher 写 --kb-inset (visualViewport+window 双源)
- 底部弹层统一 .kb-safe 类 (安全区+20px+键盘插入量, dvh 收缩+内部滚动)
- TaskDetail/UserPicker/CopyTaskPopup/信息块弹层全部迁移, 删除 per-component hook
- 信息块 textarea 补 focus scrollIntoView 兜底
5. 拖动方案重做 (重点设计 §7.1):
- 新 useColumnDrag: Pointer Events + setPointerCapture, 长按400ms原位抬起,
elementFromPoint 高亮目标列, 边缘 rAF 自动横滚; 移除手指跟随浮层(任务名)
- 列表行长按 500ms → 「移动到列」ActionSheet (不做自由拖动)
- 移除「拖到卡片转子任务」误触路径, 统一走详情页层级区
- 删除旧 useLongPressDrag
已验证: 99 文件语法 0 错误、导入 0 缺失
452 lines
27 KiB
Markdown
452 lines
27 KiB
Markdown
# 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 (首屏: 上=RecentProjects 条, 下=当前项目 ProjectView) │
|
||
│ /task/list ──► ProjectList (完整项目列表, 从「更多项目」进入) │
|
||
│ /task/:pid ──► TaskPage (打开指定项目, 供深链) │
|
||
│ ProjectView ──┬── ListView (列表 + 子任务缩进折叠) │
|
||
│ ├── BoardView (看板 + 长按拖拽) │
|
||
│ └── ProjectManage (项目管理: 成员 / 列设置 / 项目信息) │
|
||
│ 组件: TaskCard / SubtaskRow / StatusDot / ExtrasCard / UserPicker / RecentProjects │
|
||
│ 状态: 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 # 设备端偏好(视图记忆/上次项目/最近项目手动排序)
|
||
TaskPage.jsx # 首屏容器: RecentProjects 条 + 当前项目 ProjectView
|
||
ProjectList.jsx # 完整项目列表(/task/list, 从「更多项目」进入)
|
||
ProjectView.jsx # 列表/看板容器
|
||
ListView.jsx # 列表视图
|
||
BoardView.jsx # 看板视图
|
||
ColumnSettings.jsx # 列管理 + 绑定 + 保留选项(由项目管理进入)
|
||
TaskDetail.jsx # 任务详情(含扩展卡片 / 子任务;底部安全距离)
|
||
ProjectMembers.jsx # 成员管理(由项目管理进入)
|
||
ProjectManage.jsx # 项目管理: 成员 / 列设置 / 项目信息 三区
|
||
components/task/
|
||
TaskCard.jsx SubtaskRow.jsx StatusDot.jsx
|
||
ExtrasCard.jsx UserPicker.jsx useLongPressDrag.js
|
||
RecentProjects.jsx # 首屏最近项目条(卡片可长按排序)
|
||
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) |
|
||
|
||
---
|
||
|
||
## 7. 重点设计(2026-10-24 二轮评审)
|
||
|
||
### 7.1 任务拖动方案(重做)
|
||
|
||
旧版(手指跟随浮层 + 落点命中即转子任务)在真机上不可靠:浮层遮挡视线、touch 事件与滚动冲突、拖到卡片上误触转子任务。重做后的方案收敛为「**一个可靠手势 + 一个可靠菜单**」:
|
||
|
||
| 场景 | 交互 | 实现 |
|
||
| --- | --- | --- |
|
||
| 看板换列 | 长按 400ms 激活(激活前移动 >10px 视为滚动,自动取消)→ 卡片**原位抬起**(不跟随手指、无浮层、无任务名)→ 指针划过列高亮 → 松手落列 | `useColumnDrag`:Pointer Events + `setPointerCapture`(触摸/鼠标统一、捕获后不丢事件、天然吞掉 pointercancel);`elementFromPoint` 命中 `[data-column-id]`;指针靠近容器边缘时 rAF 自动横滚,可拖到屏幕外的列 |
|
||
| 列表移动 | 长按 500ms → 弹「移动到列」ActionSheet(当前列打勾) | `TaskRow` 长按计时 → `ProjectView.openMoveMenu`;列表是纵向滚动流,不做自由拖动 |
|
||
| 转为子任务 | 不走拖拽(已移除「拖到卡片上」路径,误触率高) | 详情页「层级」区:选父任务转为子任务 / 一键转为顶层 |
|
||
|
||
关键细节:卡片 `touch-action: pan-y`(纵向滚动留给浏览器,水平位移给拖动);激活与提交都以同一元素的 pointer 事件为准;`pointercancel` 与 `pointerup` 同样清理状态。换列后的状态仍由服务端按目标列绑定统一裁决(§2.2 不变)。
|
||
|
||
### 7.2 键盘遮挡统一方案
|
||
|
||
多个底部弹层(任务详情、信息块编辑、人员选择、拷贝)都出现过"输入框被输入法遮挡"。逐个组件接键盘逻辑不可维护,统一为**一个全局信号 + 一个 CSS 类**:
|
||
|
||
1. **信号源**:`App` 级唯一监听器 `useKeyboardInsetWatcher`,同时监听 `visualViewport` 的 resize/scroll 与 `window.resize`(配合 `index.html` 的 `interactive-widget=resizes-content`,Chromium 系 WebView 键盘弹出会整体收缩布局视口);以"见过的最大视口高"为基线计算键盘插入量,写入 CSS 变量 **`--kb-inset`**(>90px 才视为键盘,过滤地址栏抖动)。
|
||
2. **消费端**:底部弹层统一挂 **`.kb-safe`** 类 —— `padding-bottom: calc(安全区 + 20px + var(--kb-inset))`、`max-height` 用 `dvh` 随视口收缩并内部滚动。组件不再各自 import 键盘 hook、不再写内联 padding。
|
||
3. **兜底**:长输入区(子任务输入、信息块 textarea)聚焦 300ms 后 `scrollIntoView({block:'center'})`,双保险。
|
||
|
||
新增弹层只要挂 `.kb-safe` 即自动获得键盘避让,无需再处理。
|