diff --git a/2.Design/API.md b/2.Design/API.md new file mode 100644 index 0000000..6bfe90c --- /dev/null +++ b/2.Design/API.md @@ -0,0 +1,206 @@ +# 接口约定(M0–M1 已实现) + +> 只写**已经在跑**的接口。写完没验证的东西不进这份文档。 +> 前缀 `/api/v1`。所有响应都是同一个信封,**HTTP 200 不代表业务成功**。 + +## 0. 通用 + +### 0.1 信封 + +```json +{ "code": 0, "message": "ok", "data": { }, "request_id": "c4a8…" } +``` + +- `code == 0` 才是成功;非 0 时 `data` 里放结构化细节(例如冲突时的 `current_version`)。 +- `request_id` 出现在响应头 `X-Request-Id` 与日志里,报障时带上它。 +- 分页统一 `{items, next_cursor, has_more}`;`next_cursor` 为空即到底。 + +### 0.2 错误码 + +| 码 | HTTP | 含义 | 客户端该怎么处理 | +|---|---|---|---| +| 1001 | 400 | 参数不合法 | 提示并标红字段 | +| 1002 | 401 | 未认证 | 清 token → 登录页 | +| 1004 | 404 | 资源不存在 | — | +| 1009 | 409 | 唯一冲突(如 slug 已存在) | 换个名字 | +| 2001 | 401 | 缺少访问令牌 | 登录页 | +| 2002 | 401 | 令牌无效/过期 | 先试 refresh,失败再登录 | +| 2003 | 403 | 令牌类型不符(refresh 当 access 用) | 程序 bug | +| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 | +| 2011 | 400 | 验证码错误/已过期 | 重发 | +| 2012 | 403 | 账号被禁用 | 联系管理员 | +| 3001 | 404 | 工作区不存在 | — | +| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" | +| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 | +| 3005 | 409 | 已经是成员 | 直接进工作区 | +| 3010 | 400 | 邀请码无效/过期 | 提示重新索取 | +| 4009 | 409 | 版本冲突(乐观锁) | 用 `data.current_version` 重试或刷新 | +| 5001 | 404 | 任务不存在 | 刷新列表 | +| 5002 | 409 | 非法状态流转 | 用 `data.allowed` 决定下一步给什么按钮 | +| 5003 | 404 | 标签不存在 | — | + +> 错误码发布后**不许改号**,客户端靠它做分支。 + +### 0.3 认证 + +`Authorization: Bearer `。`access` 30 分钟,`refresh` 14 天。 +`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。 + +### 0.4 幂等 + +所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: `。 +24 小时内同用户 + 同 key 返回首次结果,并带响应头 `X-Idempotent-Replay: 1`。 +**只缓存 2xx/3xx**,失败不缓存,所以重试是安全的。 + +### 0.5 并发 + +改动类接口(`PATCH /items/{id}`、`POST /items/{id}/status`、`DELETE`)必须带 `version`。 +拿不到 `version` 就别改——先 `GET` 一次。 + +--- + +## 1. 认证 `/auth` + +### `POST /auth/sms/send` + +```json +{ "phone": "13800000000" } +``` + +响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。 +60 秒内重复请求 → `2010`。 + +### `POST /auth/login` + +```json +{ "phone": "13800000000", "code": "123456" } +``` + +```json +{ "user": { "id": "…", "phone": "138****0000", "nickname": "用户0000" }, + "tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } } +``` + +手机号**首次登录即注册**(昵称取"用户" + 后四位)。手机号回显永远是打码的。 + +### `POST /auth/refresh` + +```json +{ "refresh_token": "…" } +``` + +返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。 + +### `GET /auth/me` + +返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。 + +--- + +## 2. 工作区 `/workspaces` + +| 方法 | 路径 | 权限 | 说明 | +|---|---|---|---| +| POST | `/workspaces` | 登录 | 建工作区,创建者自动成为 `owner` | +| GET | `/workspaces` | 登录 | 我加入的工作区 | +| GET | `/workspaces/{ws_id}` | 成员 | 详情 | +| GET | `/workspaces/{ws_id}/members` | 成员 | 游标分页 | +| POST | `/workspaces/{ws_id}/invitations` | admin+ | 生成邀请码,`{role}`;**只有 owner 能邀请 admin** | +| POST | `/workspaces/{ws_id}/join` | 登录 | `{code}` 凭码加入,邀请码一次性 | + +角色等级:`owner(4) > admin(3) > member(2) > guest(1)`。 + +--- + +## 3. 任务 `/workspaces/{ws_id}/items` + +所有接口都要求**是该工作区成员**;跨租户一律 `3003`,任务不存在一律 `5001`(不区分"没有"和"不是你的")。 + +### 3.1 `GET /items` + +| 参数 | 取值 | 说明 | +|---|---|---| +| `view` | `inbox` `active` `today` `week` `done` `canceled` `all` `deleted` | 默认 `active`(todo+doing+blocked) | +| `status` | 可重复 | 与 `view` 叠加时取更严的 | +| `assignee` | `me` 或 uuid | | +| `tag` | 标签名 | | +| `q` | 关键词 | 标题 + 正文,`ILIKE %q%` | +| `sort` | `updated`(默认) `due` `created` | `due` 不分页 | +| `include_deleted` | 布尔 | 与 `view=all` 一起用 | +| `limit` | 1–100,默认 20 | | +| `cursor` | 上一页的 `next_cursor` | | + +`today` / `week` 的边界按 `settings.default_timezone`(默认 `Asia/Shanghai`)算, +用**半开区间** `[今天 00:00, 明天 00:00)`,不用 UTC 切日。 + +### 3.2 `POST /items` + +```json +{ "title": "今天要交的周报", "body_md": "", "due_at": "2026-10-28T18:00:00+08:00", + "priority": 1, "assignee_id": null, "status": "inbox", "tag_names": ["周报"] } +``` + +`status` 默认 `inbox`(进收件箱,先归类再排期)。返回完整 `item`(含 `tags[]`、`version`)。 + +### 3.3 `PATCH /items/{item_id}` + +**必须带 `version`**。`null` 与"没传"含义不同: + +- 传 `due_at: null` → 表示"清空"要用 `"clear_due_at": true` +- 同理 `clear_assignee`、`clear_tags` +- `tag_names` 一旦传就是**整体替换** + +冲突回 `4009` 且 `data.current_version` 给最新版本号。 + +### 3.4 `POST /items/{item_id}/status` + +```json +{ "status": "doing", "version": 3 } +``` + +状态机(`backend/app/domain/task.py`): + +``` +inbox → todo, canceled +todo → inbox, doing, done, canceled +doing → todo, done, blocked, canceled +blocked → doing, canceled +done → todo (重开) +canceled→ todo (恢复) +``` + +非法流转回 `5002`,`data = { from, to, allowed }`。 +**前端可以据此直接渲染"下一步能点什么",不用自己维护一份规则。** +目标状态与当前相同:直接返回,版本号不变。 + +### 3.5 `DELETE /items/{item_id}?version=3` + +软删(写 `deleted_at`),只在 `view=deleted` / `include_deleted=true` 时可见。 + +### 3.6 评论 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/items/{item_id}/comments` | 时间正序,游标分页 | +| POST | `/items/{item_id}/comments` | `{body_md}` | + +### 3.7 标签 `/workspaces/{ws_id}/tags` + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/tags` | 工作区内标签,`{id,name,color}` | +| POST | `/tags` | `{name, color?}`;同名直接返回已有的(幂等,不报错) | + +--- + +## 4. 健康与版本 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/health` | `{status, db}`,DB 探活用 `SELECT 1` | +| GET | `/version` | `{commit, build_time, db_role}` | + +`db_role` 里的 `is_super / bypass_rls / read_all` 全为 `false` 才说明 RLS 真的生效—— +这是部署后的第一道检查,比"能登录"重要得多。 + +部署校验:`curl /version` 的 `commit` 必须等于 `git rev-parse --short HEAD`。 diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index be80260..d06d3b5 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -145,44 +145,58 @@ **里程碑目标**:自己开始每天用它记待办,连续 3 天不别扭。 +> 进度口径:`[x]` = 已实现**且验证过**(测试/构建/冒烟),`[ ]` = 未做。每项后面写证据或偏差原因。 + ### 1.1 数据(1d) -- [ ] `item` `item_assignee` `tag` `item_tag` `comment` `activity` 迁移 -- [ ] 索引:`(ws_id, status, due_at)`、`(ws_id, owner_id, status)`、`deleted_at` 部分索引 -- [ ] 全文索引:`zhparser` 或 `pg_bigm` 建在 `title + body_md` +- [x] `item` `tag` `item_tag` `comment` `activity` 迁移(`backend/alembic/versions/0002_tasks.py`,revision 0002 ← 0001) +- [x] 索引:`ix_item_ws_status_due (ws,status,due_at)`、`ix_item_ws_assignee`、`ix_item_ws_updated`、`ix_item_ws_sort` +- [~] 中文全文:**没用 zhparser/pg_bigm,改用 `pg_trgm` + `ILIKE`**。理由:服务器是 Alpine 上的 PG 17,`pg_available_extensions` 只有 `pg_trgm`/`pgcrypto`,装 zhparser 要编译 + 改镜像;trigram 对中文按 3 字符切,子串匹配够用,且 `gin_trgm_ops` 索引能救性能。真正需要"相关度排序"时再上 zhparser(M3 知识库阶段重评)。 +- [x] 偏差:**没有 `item_assignee` 关联表**,`item.assignee_id` 单列。理由:M1 只有"一个任务一个负责人",多人协作由工单阶段(M2)的 `item_link` 承担;提前上关联表会让每条查询都多一次 join。 +- [x] RLS:五张新表全部 `ENABLE ROW LEVEL SECURITY` + `workspace_id = app_ws_id()`,并 GRANT 给 `dadao_app` ### 1.2 领域(1.5d) -- [ ] 状态机 `inbox→todo→doing→done`(+ blocked/canceled),非法流转拒绝并给提示 -- [ ] 所有变更写 `activity`(含 `changed_fields` 增量) -- [ ] `version` 乐观锁,冲突返回 409 带当前版本 +- [x] 状态机 `backend/app/domain/task.py`:`inbox→todo→doing→done`(+ blocked/canceled,`done→todo` 可重开);非法流转抛 `5002` 并回 `allowed`("你可以去 canceled/todo"),不用前端猜 +- [x] 所有变更写 `activity`(`created` / `updated` / `status_changed{from,to}` / `commented`,payload 是 JSONB) +- [x] `version` 乐观锁:`UPDATE ... WHERE version = ?` 看 `rowcount`,冲突回 `4009` + `current_version`(实测 stale 请求拿到 `current=4`) +- [x] `done` 写 `completed_at`,重开清空;`deleted_at` 软删,`view=deleted` 才看得到 ### 1.3 API(1.5d) -- [ ] 增删改查、批量状态变更、排序(手动 sort_order) -- [ ] 视图查询:inbox / today / week / all,支持标签与关键词过滤 -- [ ] 搜索接口(标题 + 正文,返回高亮片段) +- [x] 增删改查(`backend/app/api/items.py`)、`POST /{id}/status`、软删(带 version) +- [x] 视图:`inbox|active|today|week|done|canceled|all|deleted`;`today/week` 按 `Asia/Shanghai` 切日界(`settings.default_timezone`),不按 UTC +- [x] 过滤:`status`(可重复)、`assignee=me|`、`tag`、`q`(标题+正文 ILIKE,实测中文 `周报` 命中) +- [ ] **批量状态变更**未做(M1 先做单条,批量等看板拖拽一起上) +- [~] 搜索返回高亮片段:未做,只返回命中行。理由:高亮要在客户端按 query 二次切词,纯展示逻辑,等 Web 有真实使用反馈再补 +- [x] 分页:`(sort_key DESC, id DESC)` keyset 游标;`sort=due` 不分页(NULLS LAST 会把 NULL 行切碎,只有今日/逾期短列表用) ### 1.4 Web(3d) -- [ ] 四视图 + 看板拖拽(列 = 状态) -- [ ] 详情侧栏:优先级/截止/标签/备注/子任务(一层)/评论 -- [ ] 全局快速录入(`Ctrl+K` 或顶部输入框,支持 `#标签` `!优先级` `@人` 语法) -- [ ] 键盘流:`j/k` 上下、`Enter` 完成、`e` 编辑 +- [x] 视图 tab(今天/进行中/收件箱/已完成/全部)+ 搜索框 + 回车快速新建(`web/src/pages/Tasks.tsx`) +- [x] 勾选完成/重开、`收下`(inbox→todo)、`开工`(todo→doing)、`阻塞` +- [x] 点标题展开:正文、标签、最近 8 条事件流;`409` 时提示"刚被别人改过"并重载 +- [x] 加载更多(游标),空态文案("今天没有到期的事,挺好。") +- [x] `npm run build` 通过:38 modules,`183.26 kB │ gzip 60.08 kB` +- [ ] 看板拖拽(列 = 状态)未做 +- [ ] 详情侧栏(优先级/截止日期选择器/评论输入/子任务)未做——当前详情只读 +- [ ] 全局快速录入 `#标签 !优先级 @人` 语法未做 +- [ ] 键盘流 `j/k`、`Enter`、`e` 未做 ### 1.5 安卓(1.5d) -- [ ] 列表 + 详情 + 勾选完成 -- [ ] 下拉刷新、只读缓存(Room,仅离线展示,改动必须联网) +- [ ] 任务列表 + 详情 + 勾选完成(`android/` 目前只有 M0 的登录/工作区壳) ### 1.6 通知与提醒(0.8d) -- [ ] 站内通知表与未读角标 -- [ ] 每日摘要邮件(可关) +- [ ] 站内通知表与未读角标(表在 M2 随工单一起建) +- [ ] 每日摘要邮件 ### 1.7 测试与验收(0.7d) -- [ ] 每个接口一条 happy path + 一条越权用例 -- [ ] 前端关键交互 e2e(Playwright 起 3 条:登录、建任务、拖动状态) +- [x] `backend/tests/test_items.py` 10 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、跨租户 3003 且他人列表为空、搜索+标签、`today` 时区边界、软删、评论与事件流、游标翻页不重复 +- [x] 全量 `pytest -q` → **43 passed**;`ruff check` + `ruff format --check` 全 0 +- [ ] Playwright e2e 未做 +- [ ] 性能口径(1000 条下首屏 < 300ms)未实测 ### M1 完成定义(DOD) -- [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录 +- [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录 —— 只有真实使用能证伪,代码侧无法替代 - [ ] 冷启动到"建完第一个任务"< 90 秒(新账号实测) - [ ] 1000 条任务下四视图首屏 < 300ms -- [ ] 跨租户测试、越权测试全绿 +- [x] 跨租户测试、越权测试全绿(`test_tenant_isolation.py` + `test_items.py::test_cross_tenant_access_denied`) --- diff --git a/README.md b/README.md index 4a1e035..2f22563 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,30 @@ 任务管理 · 工单协作 · 知识库,三件事一条流,由 LLM 贯通。**SaaS 形态**(个人免费 → 团队按席位)。 - 产品与技术方案(v1.0):[`2.Design/PRODUCT-PLAN.md`](2.Design/PRODUCT-PLAN.md) -- M0–M1 实施清单:[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) +- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) +- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md) +- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md) - 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md) ## 技术栈 -- 后端:FastAPI + PostgreSQL 16(RLS + pgvector)+ Redis(arq) +- 后端:FastAPI + SQLAlchemy 2.0 async + PostgreSQL(RLS,pgvector 待 M3)+ Redis - PC:React 18 + Vite + Tailwind - 安卓:Kotlin + Jetpack Compose - LLM:OpenAI 兼容网关,平台自带 DeepSeek,用户可配 Ollama 等 -## 状态 +## 现在能跑什么 -方案已定稿 v1.0(六项关键决策已确认)。下一步:M0 骨架。 +| 能力 | 状态 | +|---|---| +| 手机号验证码登录、刷新令牌、幂等请求 | ✅ 已实现并有测试 | +| 工作区 / 成员 / 邀请码加入,跨租户隔离(RLS) | ✅ 同上 | +| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,43 个测试全绿 | +| Web 构建 | ✅ `npm run build` 通过 | +| 工单、知识库、LLM 能力 | ⬜ M2–M4 | +| 安卓端任务列表 | ⬜ | + +## 快速开始 + +见 [`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md)。开发库跑在团队共享的 `fnsvr-01:8432`(PG 17.6), +测试库 `dadaotask_test` 与开发库分离——**测试会 TRUNCATE,别指向开发库**。 diff --git a/backend/alembic/versions/0002_tasks.py b/backend/alembic/versions/0002_tasks.py new file mode 100644 index 0000000..6a4bf27 --- /dev/null +++ b/backend/alembic/versions/0002_tasks.py @@ -0,0 +1,202 @@ +"""M1 任务域:item / tag / item_tag / comment / activity + 行级安全。 + +RLS 策略与 0001 同一套模式:`workspace_id = app_ws_id()`,读写都校验。 +`item` 额外允许"我创建或分配给我"——不,**不做这个特例**:跨租户看别人的任务没有业务含义, +越权面越小越好,列表查询本来就带 workspace_id 过滤。 + +注意:`pg_trgm` 是 trusted extension(PG13+),库 owner 就能装,所以迁移不需要 superuser。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0002" +down_revision: str | None = "0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +APP_ROLE = "dadao_app" + +TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity") + +_TIMESTAMP_COLUMNS = ( + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("version", sa.Integer, nullable=False, server_default="1"), +) + + +def _ws_fk() -> sa.Column: + return sa.Column( + "workspace_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("workspace.id", ondelete="CASCADE"), + nullable=False, + ) + + +def upgrade() -> None: + # 中文没有分词器(zhparser 未装),M1 的搜索走 ILIKE 子串匹配, + # trigram 索引能让"标题里找关键字"这条最常用的查询走索引而不是全表扫。 + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + op.create_table( + "item", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + _ws_fk(), + sa.Column("kind", sa.String(16), nullable=False, server_default="task"), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("body_md", sa.Text, nullable=False, server_default=""), + sa.Column("status", sa.String(16), nullable=False, server_default="inbox"), + sa.Column("priority", sa.Integer, nullable=False, server_default="0"), + sa.Column( + "creator_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("app_user.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "assignee_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("app_user.id", ondelete="SET NULL"), + ), + sa.Column("due_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + *_TIMESTAMP_COLUMNS, + sa.CheckConstraint( + "status IN ('inbox','todo','doing','done','blocked','canceled')", + name="ck_item_status", + ), + sa.CheckConstraint("kind IN ('task','ticket','knowledge')", name="ck_item_kind"), + sa.CheckConstraint("priority BETWEEN 0 AND 3", name="ck_item_priority"), + ) + op.create_index("ix_item_ws_status_due", "item", ["workspace_id", "status", "due_at"]) + op.create_index("ix_item_ws_assignee", "item", ["workspace_id", "assignee_id", "status"]) + op.create_index("ix_item_ws_updated", "item", ["workspace_id", "updated_at"]) + op.create_index("ix_item_ws_sort", "item", ["workspace_id", "status", "sort_order"]) + + op.create_table( + "tag", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + _ws_fk(), + sa.Column("name", sa.String(32), nullable=False), + sa.Column("color", sa.String(16), nullable=False, server_default=""), + *_TIMESTAMP_COLUMNS, + sa.UniqueConstraint("workspace_id", "name", name="uq_tag_ws_name"), + ) + op.create_index("ix_tag_ws", "tag", ["workspace_id", "name"]) + + op.create_table( + "item_tag", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + _ws_fk(), + sa.Column( + "item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("item.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "tag_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tag.id", ondelete="CASCADE"), + nullable=False, + ), + *_TIMESTAMP_COLUMNS, + sa.UniqueConstraint("item_id", "tag_id", name="uq_item_tag"), + ) + op.create_index("ix_item_tag_ws_tag", "item_tag", ["workspace_id", "tag_id"]) + + op.create_table( + "comment", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + _ws_fk(), + sa.Column( + "item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("item.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "author_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("app_user.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("body_md", sa.Text, nullable=False, server_default=""), + sa.Column("deleted_at", sa.DateTime(timezone=True)), + *_TIMESTAMP_COLUMNS, + ) + op.create_index("ix_comment_ws_item_created", "comment", ["workspace_id", "item_id", "created_at"]) + + op.create_table( + "activity", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + _ws_fk(), + sa.Column( + "item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("item.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "actor_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("app_user.id", ondelete="SET NULL"), + ), + sa.Column("action", sa.String(32), nullable=False), + sa.Column("payload", postgresql.JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_activity_ws_item_created", "activity", ["workspace_id", "item_id", "created_at"]) + + # 标题三元组索引:ILIKE '%关键字%' 能用到它 + op.execute( + "CREATE INDEX ix_item_title_trgm ON item USING gin (title gin_trgm_ops)" + ) + + for statement in _POLICY_STATEMENTS: + op.execute(statement) + for statement in _GRANT_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + # 先摘策略:策略里引用了这些表本身,表没了策略语句就悬空 + for table in TENANT_TABLES_0002: + op.execute(f"DROP POLICY IF EXISTS {table}_isolation ON {table}") + op.execute("DROP INDEX IF EXISTS ix_item_title_trgm") + for table in ( + "activity", + "comment", + "item_tag", + "tag", + "item", + ): + op.drop_table(table) + # pg_trgm 是库级共享资源,别在这里删(其它模块可能也在用) + + +_POLICY_STATEMENTS: tuple[str, ...] = tuple( + statement + for table in TENANT_TABLES_0002 + for statement in ( + f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;", + f""" + CREATE POLICY {table}_isolation ON {table} + USING (workspace_id = app_ws_id()) + WITH CHECK (workspace_id = app_ws_id()); + """, + ) +) + +_GRANT_STATEMENTS: tuple[str, ...] = ( + f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0002)} TO {APP_ROLE};", +) diff --git a/backend/app/api/items.py b/backend/app/api/items.py new file mode 100644 index 0000000..fed8406 --- /dev/null +++ b/backend/app/api/items.py @@ -0,0 +1,612 @@ +"""任务(item)接口。 + +写操作的并发控制统一走**乐观锁**:请求必须带 `version`,落库时用 +`UPDATE ... WHERE id = ? AND version = ?` 并检查 rowcount。 +不用"先查再改"——两个请求同时读到 version=3 时,两边都会认为自己是对的, +后写的静默覆盖先写的,而且日志里看不出异常。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from pydantic import BaseModel, Field +from sqlalchemy import Select, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config import get_settings +from ..deps import MemberDep, SessionDep +from ..domain.task import ( + ALL_STATUSES, + apply_status, + due_window, + ensure_transition, + view_statuses, +) +from ..errors import AppError, ErrorCode +from ..models_task import Activity, Comment, Item, ItemTag, Tag +from ..schemas import Pagination, encode_cursor, ok + +router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"]) +tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"]) + +MAX_TITLE = 200 +MAX_BODY = 100_000 + + +# ── 入参 ──────────────────────────────────────────────────────────────── +class CreateItemIn(BaseModel): + title: str = Field(min_length=1, max_length=MAX_TITLE) + body_md: str = Field(default="", max_length=MAX_BODY) + status: str = Field(default="inbox") + priority: int = Field(default=0, ge=0, le=3) + assignee_id: uuid.UUID | None = None + due_at: datetime | None = None + tag_names: list[str] = Field(default_factory=list, max_length=10) + + +class UpdateItemIn(BaseModel): + """只传要改的字段。注意:`None` 表示"不动",想把 due_at 清空请传 `clear_due_at`。""" + + version: int = Field(ge=1) + title: str | None = Field(default=None, min_length=1, max_length=MAX_TITLE) + body_md: str | None = Field(default=None, max_length=MAX_BODY) + priority: int | None = Field(default=None, ge=0, le=3) + assignee_id: uuid.UUID | None = None + clear_assignee: bool = False + due_at: datetime | None = None + clear_due_at: bool = False + tag_names: list[str] | None = Field(default=None, max_length=10) + + +class StatusIn(BaseModel): + version: int = Field(ge=1) + status: str + + +class CommentIn(BaseModel): + body_md: str = Field(min_length=1, max_length=20_000) + + +class TagIn(BaseModel): + name: str = Field(min_length=1, max_length=32) + color: str = Field(default="", max_length=16) + + +# ── 序列化 ────────────────────────────────────────────────────────────── +def _tag_out(tag: Tag) -> dict[str, Any]: + return {"id": str(tag.id), "name": tag.name, "color": tag.color} + + +def _item_out(item: Item, tags: list[Tag] | None = None) -> dict[str, Any]: + return { + "id": str(item.id), + "kind": item.kind, + "title": item.title, + "body_md": item.body_md, + "status": item.status, + "priority": item.priority, + "creator_id": str(item.creator_id), + "assignee_id": str(item.assignee_id) if item.assignee_id else None, + "due_at": item.due_at, + "completed_at": item.completed_at, + "sort_order": item.sort_order, + "deleted_at": item.deleted_at, + "tags": [_tag_out(t) for t in (tags or [])], + "created_at": item.created_at, + "updated_at": item.updated_at, + "version": item.version, + } + + +# ── 内部工具 ──────────────────────────────────────────────────────────── +async def _load_item(session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID) -> Item: + item = await session.get(Item, item_id) + if item is None or item.workspace_id != ws_id or item.kind != "task": + # 跨租户 / 不存在 / 不是任务,对外一律"不存在",不泄漏别人有没有这条数据 + raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在") + return item + + +async def _tags_of( + session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID] +) -> dict[uuid.UUID, list[Tag]]: + if not item_ids: + return {} + rows = ( + await session.execute( + select(ItemTag.item_id, Tag) + .join(Tag, Tag.id == ItemTag.tag_id) + .where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(item_ids)) + .order_by(Tag.name) + ) + ).all() + grouped: dict[uuid.UUID, list[Tag]] = {} + for item_id, tag in rows: + grouped.setdefault(item_id, []).append(tag) + return grouped + + +def _normalize_tag_names(names: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for raw in names: + name = raw.strip() + if not name or name.lower() in seen: + continue + seen.add(name.lower()) + result.append(name) + return result + + +async def _set_tags(session: AsyncSession, ws_id: uuid.UUID, item: Item, names: list[str]) -> None: + """整组替换:客户端传什么就是什么,不做增量(增量语义在弱网下容易和用户预期错位)。""" + names = _normalize_tag_names(names) + existing = { + tag.name.lower(): tag + for tag in (await session.execute(select(Tag).where(Tag.workspace_id == ws_id))).scalars() + } + keep: list[Tag] = [] + for name in names: + tag = existing.get(name.lower()) + if tag is None: + tag = Tag(workspace_id=ws_id, name=name) + session.add(tag) + await session.flush() + existing[name.lower()] = tag + keep.append(tag) + + await session.execute( + ItemTag.__table__.delete().where(ItemTag.workspace_id == ws_id, ItemTag.item_id == item.id) + ) + for tag in keep: + session.add(ItemTag(workspace_id=ws_id, item_id=item.id, tag_id=tag.id)) + await session.flush() + + +async def _log( + session: AsyncSession, item: Item, actor_id: uuid.UUID, action: str, payload: dict | None = None +) -> None: + session.add( + Activity( + workspace_id=item.workspace_id, + item_id=item.id, + actor_id=actor_id, + action=action, + payload=payload or {}, + ) + ) + + +async def _apply_update( + session: AsyncSession, + item: Item, + *, + version: int, + values: dict[str, Any], + actor_id: uuid.UUID, + action: str, + payload: dict | None = None, +) -> Item: + """乐观锁写库:rowcount=0 说明别人先改了,别默默覆盖。""" + values = {**values, "version": version + 1, "updated_at": datetime.now(timezone.utc)} + result = await session.execute( + update(Item) + .where(Item.id == item.id, Item.workspace_id == item.workspace_id, Item.version == version) + .values(**values) + ) + if result.rowcount == 0: + await session.refresh(item) + raise AppError( + ErrorCode.VERSION_CONFLICT, + "这条任务已被别人修改,请刷新后重试", + details={"current_version": item.version}, + ) + await session.refresh(item) + await _log(session, item, actor_id, action, payload) + return item + + +# ── 列表 ──────────────────────────────────────────────────────────────── +def _apply_filters( + stmt: Select, + *, + ws_id: uuid.UUID, + me: uuid.UUID, + view: str | None, + status: list[str] | None, + assignee: str | None, + q: str | None, + tag: str | None, + include_deleted: bool, + now: datetime, + tz: str, +) -> Select: + stmt = stmt.where(Item.workspace_id == ws_id, Item.kind == "task") + + if view == "deleted": + stmt = stmt.where(Item.deleted_at.is_not(None)) + elif not include_deleted: + stmt = stmt.where(Item.deleted_at.is_(None)) + + if status: + invalid = [s for s in status if s not in ALL_STATUSES] + if invalid: + raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{','.join(invalid)}") + stmt = stmt.where(Item.status.in_(status)) + elif view and view != "deleted": + stmt = stmt.where(Item.status.in_(view_statuses(view))) + + due_from, due_to = due_window(view, now=now, tz=tz) + if due_from is not None: + stmt = stmt.where(Item.due_at.is_not(None), Item.due_at >= due_from, Item.due_at < due_to) + + if assignee: + if assignee == "me": + stmt = stmt.where(Item.assignee_id == me) + else: + try: + stmt = stmt.where(Item.assignee_id == uuid.UUID(assignee)) + except ValueError as exc: + raise AppError(ErrorCode.BAD_REQUEST, "assignee 需要是 me 或用户 id") from exc + + if q and q.strip(): + needle = f"%{q.strip()}%" + stmt = stmt.where(or_(Item.title.ilike(needle), Item.body_md.ilike(needle))) + + if tag and tag.strip(): + stmt = stmt.where( + Item.id.in_( + select(ItemTag.item_id) + .join(Tag, Tag.id == ItemTag.tag_id) + .where(ItemTag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower()) + ) + ) + return stmt + + +@router.get("", summary="任务列表(视图 / 状态 / 负责人 / 关键字 / 标签)") +async def list_items( + ctx: MemberDep, + session: SessionDep, + view: Annotated[ + str | None, Query(description="inbox|active|today|week|done|canceled|all|deleted") + ] = None, + status: Annotated[list[str] | None, Query(description="精确状态,可重复传")] = None, + assignee: Annotated[str | None, Query(description="me 或用户 uuid")] = None, + q: Annotated[str | None, Query(max_length=100, description="标题/正文关键字")] = None, + tag: Annotated[str | None, Query(max_length=32)] = None, + sort: Annotated[str, Query(description="updated|due|created")] = "updated", + include_deleted: bool = False, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[str | None, Query()] = None, +) -> dict: + if sort not in ("updated", "due", "created"): + raise AppError(ErrorCode.BAD_REQUEST, "sort 只能是 updated / due / created") + + settings = get_settings() + now = datetime.now(timezone.utc) + page = Pagination(limit=limit, cursor=cursor) + limit, parsed_cursor = page.parsed() + + base = _apply_filters( + select(Item), + ws_id=ctx.ws_id, + me=ctx.user.id, + view=view, + status=status, + assignee=assignee, + q=q, + tag=tag, + include_deleted=include_deleted, + now=now, + tz=settings.default_timezone, + ) + + if sort == "due": + # 截止时间排序涉及 NULLS LAST,keyset 分页会把 NULL 行切碎。 + # 逾期/今日清单本来就是短列表,这里不分页(上限 100 条),语义更清楚。 + rows = ( + ( + await session.execute( + base.order_by(Item.due_at.asc().nulls_last(), Item.sort_order, Item.id).limit( + limit + ) + ) + ) + .scalars() + .all() + ) + tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows]) + return ok( + { + "items": [_item_out(r, tags.get(r.id)) for r in rows], + "next_cursor": None, + "has_more": False, + } + ) + + if sort == "created": + order = (Item.created_at.desc(), Item.id.desc()) + sort_col, sort_attr = Item.created_at, "created_at" + else: + order = (Item.updated_at.desc(), Item.id.desc()) + sort_col, sort_attr = Item.updated_at, "updated_at" + + stmt = base.order_by(*order).limit(limit + 1) + if parsed_cursor is not None: + stmt = stmt.where( + or_( + sort_col < parsed_cursor.created_at, + (sort_col == parsed_cursor.created_at) & (Item.id < parsed_cursor.id), + ) + ) + + rows = (await session.execute(stmt)).scalars().all() + has_more = len(rows) > limit + rows = list(rows[:limit]) + next_cursor = ( + encode_cursor(getattr(rows[-1], sort_attr), rows[-1].id) if has_more and rows else None + ) + tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows]) + return ok( + { + "items": [_item_out(r, tags.get(r.id)) for r in rows], + "next_cursor": next_cursor, + "has_more": has_more, + } + ) + + +@router.get("/{item_id}", summary="任务详情(含最近 50 条事件)") +async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict: + item = await _load_item(session, ctx.ws_id, item_id) + tags = await _tags_of(session, ctx.ws_id, [item.id]) + activities = ( + ( + await session.execute( + select(Activity) + .where(Activity.workspace_id == ctx.ws_id, Activity.item_id == item.id) + .order_by(Activity.created_at.desc(), Activity.id.desc()) + .limit(50) + ) + ) + .scalars() + .all() + ) + data = _item_out(item, tags.get(item.id)) + data["activities"] = [ + { + "id": str(a.id), + "action": a.action, + "actor_id": str(a.actor_id) if a.actor_id else None, + "payload": a.payload, + "created_at": a.created_at, + } + for a in activities + ] + return ok(data) + + +# ── 写操作 ────────────────────────────────────────────────────────────── +@router.post("", summary="新建任务") +async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -> dict: + if body.status not in ALL_STATUSES: + raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}") + + fields: dict[str, Any] = {"status": body.status, "completed_at": None} + apply_status(fields, body.status, now=datetime.now(timezone.utc)) + + item = Item( + id=uuid.uuid4(), + workspace_id=ctx.ws_id, + kind="task", + title=body.title.strip(), + body_md=body.body_md, + priority=body.priority, + creator_id=ctx.user.id, + assignee_id=body.assignee_id, + due_at=body.due_at, + status=fields["status"], + completed_at=fields["completed_at"], + ) + session.add(item) + await session.flush() + if body.tag_names: + await _set_tags(session, ctx.ws_id, item, body.tag_names) + await _log(session, item, ctx.user.id, "created", {"status": item.status}) + + tags = await _tags_of(session, ctx.ws_id, [item.id]) + return ok(_item_out(item, tags.get(item.id)), message="已创建") + + +@router.patch("/{item_id}", summary="修改任务字段(乐观锁)") +async def update_item( + item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep +) -> dict: + item = await _load_item(session, ctx.ws_id, item_id) + + values: dict[str, Any] = {} + changes: dict[str, Any] = {} + if body.title is not None and body.title.strip() != item.title: + values["title"] = body.title.strip() + changes["title"] = values["title"] + if body.body_md is not None and body.body_md != item.body_md: + values["body_md"] = body.body_md + changes["body_md"] = "changed" + if body.priority is not None and body.priority != item.priority: + values["priority"] = body.priority + changes["priority"] = body.priority + if body.clear_assignee: + values["assignee_id"] = None + changes["assignee_id"] = None + elif body.assignee_id is not None and body.assignee_id != item.assignee_id: + values["assignee_id"] = body.assignee_id + changes["assignee_id"] = str(body.assignee_id) + if body.clear_due_at: + values["due_at"] = None + changes["due_at"] = None + elif body.due_at is not None and body.due_at != item.due_at: + values["due_at"] = body.due_at + changes["due_at"] = body.due_at.isoformat() + + item = await _apply_update( + session, + item, + version=body.version, + values=values, + actor_id=ctx.user.id, + action="updated", + payload={"changes": changes} if changes else None, + ) + if body.tag_names is not None: + await _set_tags(session, ctx.ws_id, item, body.tag_names) + await _log( + session, + item, + ctx.user.id, + "tags_changed", + {"tags": _normalize_tag_names(body.tag_names)}, + ) + item.version += 1 + + tags = await _tags_of(session, ctx.ws_id, [item.id]) + return ok(_item_out(item, tags.get(item.id)), message="已保存") + + +@router.post("/{item_id}/status", summary="改状态(状态机 + 乐观锁)") +async def change_status( + item_id: uuid.UUID, body: StatusIn, ctx: MemberDep, session: SessionDep +) -> dict: + item = await _load_item(session, ctx.ws_id, item_id) + if body.status not in ALL_STATUSES: + raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}") + if body.status == item.status: + tags = await _tags_of(session, ctx.ws_id, [item.id]) + return ok(_item_out(item, tags.get(item.id)), message="状态未变化") + + ensure_transition(item.status, body.status) + previous = item.status + fields: dict[str, Any] = {"status": body.status, "completed_at": None} + apply_status(fields, body.status, now=datetime.now(timezone.utc)) + item = await _apply_update( + session, + item, + version=body.version, + values={"status": fields["status"], "completed_at": fields["completed_at"]}, + actor_id=ctx.user.id, + action="status_changed", + payload={"from": previous, "to": body.status}, + ) + tags = await _tags_of(session, ctx.ws_id, [item.id]) + return ok(_item_out(item, tags.get(item.id)), message="状态已更新") + + +@router.delete("/{item_id}", summary="删除任务(软删;带 version 才防并发)") +async def delete_item( + item_id: uuid.UUID, + ctx: MemberDep, + session: SessionDep, + version: Annotated[int, Query(ge=1)], +) -> dict: + item = await _load_item(session, ctx.ws_id, item_id) + item = await _apply_update( + session, + item, + version=version, + values={"deleted_at": datetime.now(timezone.utc)}, + actor_id=ctx.user.id, + action="deleted", + ) + return ok({"id": str(item.id), "deleted_at": item.deleted_at}, message="已删除") + + +# ── 评论 ──────────────────────────────────────────────────────────────── +@router.post("/{item_id}/comments", summary="加评论") +async def add_comment( + item_id: uuid.UUID, body: CommentIn, ctx: MemberDep, session: SessionDep +) -> dict: + item = await _load_item(session, ctx.ws_id, item_id) + comment = Comment( + workspace_id=ctx.ws_id, + item_id=item.id, + author_id=ctx.user.id, + body_md=body.body_md, + ) + session.add(comment) + await session.flush() + await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)}) + return ok( + { + "id": str(comment.id), + "item_id": str(item.id), + "author_id": str(comment.author_id), + "body_md": comment.body_md, + "created_at": comment.created_at, + }, + message="已评论", + ) + + +@router.get("/{item_id}/comments", summary="评论列表") +async def list_comments(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict: + item = await _load_item(session, ctx.ws_id, item_id) + rows = ( + ( + await session.execute( + select(Comment) + .where( + Comment.workspace_id == ctx.ws_id, + Comment.item_id == item.id, + Comment.deleted_at.is_(None), + ) + .order_by(Comment.created_at.asc(), Comment.id.asc()) + .limit(200) + ) + ) + .scalars() + .all() + ) + return ok( + { + "items": [ + { + "id": str(c.id), + "author_id": str(c.author_id), + "body_md": c.body_md, + "created_at": c.created_at, + } + for c in rows + ] + } + ) + + +# ── 标签 ──────────────────────────────────────────────────────────────── +@tag_router.get("", summary="标签列表") +async def list_tags(ctx: MemberDep, session: SessionDep) -> dict: + rows = ( + (await session.execute(select(Tag).where(Tag.workspace_id == ctx.ws_id).order_by(Tag.name))) + .scalars() + .all() + ) + return ok({"items": [_tag_out(t) for t in rows]}) + + +@tag_router.post("", summary="建标签(同名返回已有)") +async def create_tag(body: TagIn, ctx: MemberDep, session: SessionDep) -> dict: + name = body.name.strip() + exists = ( + await session.execute( + select(Tag).where(Tag.workspace_id == ctx.ws_id, func.lower(Tag.name) == name.lower()) + ) + ).scalar_one_or_none() + if exists is not None: + return ok(_tag_out(exists), message="已存在") + tag = Tag(workspace_id=ctx.ws_id, name=name, color=body.color) + session.add(tag) + await session.flush() + return ok(_tag_out(tag), message="已创建") diff --git a/backend/app/config.py b/backend/app/config.py index aed4fe3..f2a86be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -42,6 +42,11 @@ class Settings(BaseSettings): # ── Redis ───────────────────────────────────────────── redis_url: str = "redis://127.0.0.1:6380/0" + # ── 业务 ────────────────────────────────────────────── + # "今天/本周"这类视图按用户时区切日界,不能按 UTC —— 中国用户 23:30 建的任务 + # 在 UTC 下已经是"明天"了。M7 多地域时改成按工作区配置。 + default_timezone: str = "Asia/Shanghai" + # ── 认证 ────────────────────────────────────────────── jwt_secret: str = DEV_JWT_SECRET jwt_algorithm: str = "HS256" diff --git a/backend/app/domain/__init__.py b/backend/app/domain/__init__.py new file mode 100644 index 0000000..acd6ddf --- /dev/null +++ b/backend/app/domain/__init__.py @@ -0,0 +1 @@ +"""任务域业务规则(与框架无关,方便单测)。""" diff --git a/backend/app/domain/task.py b/backend/app/domain/task.py new file mode 100644 index 0000000..0f8731e --- /dev/null +++ b/backend/app/domain/task.py @@ -0,0 +1,100 @@ +"""任务状态机与"视图"口径。 + +两条原则: +1. **状态机是契约**:客户端据此置灰按钮,服务端必须再校验一次。 + 任何"直接改 status 列"的代码路径都是 bug。 +2. **视图是查询口径,不是状态**:`today` / `week` 是筛选条件,不往 item 上落字段, + 否则每天都要批量刷库,还会因为时区边界出错。 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from ..errors import AppError, ErrorCode + +# 允许的流转。没列出的就是禁止的(显式白名单,默认拒绝)。 +TRANSITIONS: dict[str, frozenset[str]] = { + # 待整理:进来先放着,决定做或不做 + "inbox": frozenset({"todo", "canceled"}), + # 待办:可以开工,也可以直接标记完成(写文档类的事不需要"进行中") + "todo": frozenset({"inbox", "doing", "done", "canceled"}), + # 进行中 + "doing": frozenset({"todo", "done", "blocked", "canceled"}), + # 被阻塞(M2 起:任务升级成工单会自动进这里) + "blocked": frozenset({"doing", "canceled"}), + # 已完成:只能重开 + "done": frozenset({"todo"}), + # 已取消:只能重开 + "canceled": frozenset({"todo"}), +} + +# 表示"这件事还没完"的状态 +OPEN_STATUSES: tuple[str, ...] = ("inbox", "todo", "doing", "blocked") +ACTIVE_STATUSES: tuple[str, ...] = ("todo", "doing", "blocked") +ALL_STATUSES: tuple[str, ...] = tuple(TRANSITIONS) + +# 视图 → 状态集合。`today` / `week` 还要叠加截止时间窗口(见 due_window) +VIEW_STATUSES: dict[str, tuple[str, ...]] = { + "inbox": ("inbox",), + "active": ACTIVE_STATUSES, + "today": ACTIVE_STATUSES, + "week": ACTIVE_STATUSES, + "done": ("done",), + "canceled": ("canceled",), + "all": ALL_STATUSES, +} + +DEFAULT_VIEW = "active" + + +def can_transition(current: str, target: str) -> bool: + return target in TRANSITIONS.get(current, frozenset()) + + +def ensure_transition(current: str, target: str) -> None: + """非法流转直接拒绝,并把"当前能去哪"一起返回,客户端不用自己猜。""" + if current == target: + return + if not can_transition(current, target): + raise AppError( + ErrorCode.ITEM_STATUS_INVALID, + f"状态不能从 {current} 直接变成 {target}", + details={ + "from": current, + "to": target, + "allowed": sorted(TRANSITIONS.get(current, ())), + }, + ) + + +def apply_status(item: dict, target: str, *, now: datetime) -> None: + """就地更新 status/completed_at。`completed_at` 只在 done 时有值。""" + item["status"] = target + item["completed_at"] = now if target == "done" else None + + +def view_statuses(view: str | None) -> tuple[str, ...]: + return VIEW_STATUSES.get(view or DEFAULT_VIEW, ACTIVE_STATUSES) + + +def due_window( + view: str | None, *, now: datetime, tz: str +) -> tuple[datetime | None, datetime | None]: + """返回 (due_from, due_to) 半开区间 `[from, to)`。 + + `today` = 今天 00:00(含)到明天 00:00(不含),按**用户时区**算, + 不是 UTC —— 中国用户 23:30 创建"今天到期"的任务,UTC 下已经是明天了。 + """ + if view not in ("today", "week"): + return None, None + zone = ZoneInfo(tz) + local_now = now.astimezone(zone) + start = local_now.replace(hour=0, minute=0, second=0, microsecond=0) + if view == "today": + end = start + timedelta(days=1) + else: + # 本周剩余:到今天所在自然周的周日结束 + end = start + timedelta(days=7 - start.weekday()) + return start, end diff --git a/backend/app/errors.py b/backend/app/errors.py index 697b9f5..762385b 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -36,6 +36,11 @@ class ErrorCode(IntEnum): # 4xxx 并发 VERSION_CONFLICT = 4009 + # 5xxx 任务域 + ITEM_NOT_FOUND = 5001 + ITEM_STATUS_INVALID = 5002 + TAG_NOT_FOUND = 5003 + # 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层 _HTTP_STATUS: dict[ErrorCode, int] = { @@ -59,6 +64,9 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.ALREADY_A_MEMBER: 409, ErrorCode.INVITATION_INVALID: 400, ErrorCode.VERSION_CONFLICT: 409, + ErrorCode.ITEM_NOT_FOUND: 404, + ErrorCode.ITEM_STATUS_INVALID: 409, + ErrorCode.TAG_NOT_FOUND: 404, } diff --git a/backend/app/main.py b/backend/app/main.py index 51d7819..ea61972 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse from sqlalchemy import text from starlette.exceptions import HTTPException as StarletteHTTPException -from .api import auth, workspaces +from .api import auth, items, workspaces from .config import get_settings from .db import SessionFactory, current_app_role, engine from .errors import AppError, ErrorCode, http_status_for @@ -100,6 +100,8 @@ async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse: app.include_router(auth.router, prefix=settings.api_prefix) app.include_router(workspaces.router, prefix=settings.api_prefix) +app.include_router(items.router, prefix=settings.api_prefix) +app.include_router(items.tag_router, prefix=settings.api_prefix) @app.get("/health", tags=["运维"], summary="存活探针") diff --git a/backend/app/models.py b/backend/app/models.py index 0024584..719e97f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -132,4 +132,15 @@ class AuditLog(Base): # 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加) -TENANT_TABLES: tuple[str, ...] = ("workspace", "member", "invitation", "audit_log") +# +# 任务域的表在 models_task.py —— 它 import 本模块的 Base,所以只能在文件末尾反向 import, +# 否则循环导入。 +from .models_task import TASK_TENANT_TABLES # noqa: E402 + +TENANT_TABLES: tuple[str, ...] = ( + "workspace", + "member", + "invitation", + "audit_log", + *TASK_TENANT_TABLES, +) diff --git a/backend/app/models_task.py b/backend/app/models_task.py new file mode 100644 index 0000000..1688adc --- /dev/null +++ b/backend/app/models_task.py @@ -0,0 +1,148 @@ +"""任务域模型(M1):item / tag / item_tag / comment / activity。 + +约定与 M0 一致:uuid 主键、通用列、`workspace_id NOT NULL`。 + +两个刻意的取舍: +- **M1 只有一个负责人 `assignee_id`**,不做 `item_assignee` 多对多。M1 是"自己的待办", + 多人协作在 M2 工单里才真正需要;提前建多对多表和对应 UI 是过度设计,等真要用再迁移。 +- **`kind` 列现在就加上**(默认 `task`)。M2 的工单、M3 的知识都是同一条 item 流, + 现在加一列只是默认值,将来加 `ticket_ext` / `knowledge_doc` 时不用改主表。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + DateTime, + ForeignKey, + Index, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from .models import Base, Timestamps, new_uuid + +ITEM_STATUSES: tuple[str, ...] = ("inbox", "todo", "doing", "done", "blocked", "canceled") + + +class Item(Base, Timestamps): + """任务/工单/知识的统一载体。M1 只用 `kind='task'`。""" + + __tablename__ = "item" + __table_args__ = ( + Index("ix_item_ws_status_due", "workspace_id", "status", "due_at"), + Index("ix_item_ws_assignee", "workspace_id", "assignee_id", "status"), + Index("ix_item_ws_updated", "workspace_id", "updated_at"), + Index("ix_item_ws_sort", "workspace_id", "status", "sort_order"), + ) + + id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + kind: Mapped[str] = mapped_column(String(16), nullable=False, default="task") + title: Mapped[str] = mapped_column(String(200), nullable=False) + body_md: Mapped[str] = mapped_column(Text, nullable=False, default="") + status: Mapped[str] = mapped_column(String(16), nullable=False, default="inbox") + priority: Mapped[int] = mapped_column(nullable=False, default=0) + creator_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False + ) + assignee_id: Mapped[uuid.UUID | None] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL") + ) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + sort_order: Mapped[int] = mapped_column(nullable=False, default=0) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class Tag(Base, Timestamps): + __tablename__ = "tag" + __table_args__ = ( + UniqueConstraint("workspace_id", "name", name="uq_tag_ws_name"), + Index("ix_tag_ws", "workspace_id", "name"), + ) + + id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(32), nullable=False) + color: Mapped[str] = mapped_column(String(16), nullable=False, default="") + + +class ItemTag(Base, Timestamps): + __tablename__ = "item_tag" + __table_args__ = ( + UniqueConstraint("item_id", "tag_id", name="uq_item_tag"), + Index("ix_item_tag_ws_tag", "workspace_id", "tag_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + item_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False + ) + tag_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("tag.id", ondelete="CASCADE"), nullable=False + ) + + +class Comment(Base, Timestamps): + __tablename__ = "comment" + __table_args__ = (Index("ix_comment_ws_item_created", "workspace_id", "item_id", "created_at"),) + + id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + item_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False + ) + author_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False + ) + body_md: Mapped[str] = mapped_column(Text, nullable=False, default="") + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class Activity(Base): + """事件流:item 的每一次实质变更都落一条。 + + 这是"这条任务怎么走到今天的"唯一真相源,M4 的 LLM 沉淀、M3 的知识都从这里取料。 + 只增不改,所以没有 version / updated_at。 + """ + + __tablename__ = "activity" + __table_args__ = ( + Index("ix_activity_ws_item_created", "workspace_id", "item_id", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + item_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False + ) + actor_id: Mapped[uuid.UUID | None] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL") + ) + action: Mapped[str] = mapped_column(String(32), nullable=False) + payload: Mapped[dict | None] = mapped_column(JSONB) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + +TASK_TENANT_TABLES: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity") diff --git a/backend/requirements.txt b/backend/requirements.txt index 07194e7..03960f9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,9 @@ pydantic==2.10.4 pydantic-settings==2.7.0 pyjwt==2.10.1 redis==5.2.1 +# Windows 没有系统 tzdata,zoneinfo 找不到 "Asia/Shanghai"; +# Linux 上装它也无害(会用包里的,而不是系统的)。 +tzdata==2026.5 # dev / test only (split into requirements-dev.txt when it grows) pytest==8.3.4 diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py new file mode 100644 index 0000000..6e80cec --- /dev/null +++ b/backend/tests/test_items.py @@ -0,0 +1,272 @@ +"""任务域:状态机、乐观锁、视图、搜索、租户隔离。 + +这些用例覆盖的是"用户能感觉到的错",不是代码覆盖率: +状态乱跳、两个人同时改、别人的任务突然出现、今天的清单里混进明天的任务。 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from httpx import AsyncClient + +from .conftest import create_workspace, login, unwrap + +TZ = ZoneInfo("Asia/Shanghai") + + +def items_url(ws_id: str) -> str: + return f"/api/v1/workspaces/{ws_id}/items" + + +async def _new_item(client: AsyncClient, user: dict, ws_id: str, **overrides) -> dict: + payload = {"title": "写周报", **overrides} + return unwrap(await client.post(items_url(ws_id), json=payload, headers=user["headers"])) + + +async def test_create_task_defaults_to_inbox(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + item = await _new_item(client, user, ws["id"]) + + assert item["status"] == "inbox" + assert item["kind"] == "task" + assert item["version"] == 1 + assert item["creator_id"] == user["user_id"] + + inbox = unwrap( + await client.get(items_url(ws["id"]), params={"view": "inbox"}, headers=user["headers"]) + ) + assert [i["id"] for i in inbox["items"]] == [item["id"]] + + +async def test_status_machine_happy_path_sets_completed_at(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + item = await _new_item(client, user, ws["id"]) + + for target in ("todo", "doing", "done"): + item = unwrap( + await client.post( + f"{items_url(ws['id'])}/{item['id']}/status", + json={"status": target, "version": item["version"]}, + headers=user["headers"], + ) + ) + assert item["status"] == target + assert item["completed_at"] is not None + + # 重开:completed_at 必须被清掉,否则"完成时间"会一直挂着 + item = unwrap( + await client.post( + f"{items_url(ws['id'])}/{item['id']}/status", + json={"status": "todo", "version": item["version"]}, + headers=user["headers"], + ) + ) + assert item["status"] == "todo" + assert item["completed_at"] is None + + +async def test_illegal_transition_is_rejected(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + item = await _new_item(client, user, ws["id"]) + + resp = await client.post( + f"{items_url(ws['id'])}/{item['id']}/status", + json={"status": "done", "version": item["version"]}, # inbox 不能直接 done + headers=user["headers"], + ) + body = resp.json() + assert body["code"] == 5002 + assert body["data"]["from"] == "inbox" + assert "todo" in body["data"]["allowed"] + + +async def test_stale_version_is_rejected_not_silently_overwritten(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + item = await _new_item(client, user, ws["id"]) + + first = unwrap( + await client.patch( + f"{items_url(ws['id'])}/{item['id']}", + json={"version": item["version"], "title": "写周报(周四)"}, + headers=user["headers"], + ) + ) + assert first["version"] == item["version"] + 1 + + resp = await client.patch( + f"{items_url(ws['id'])}/{item['id']}", + json={"version": item["version"], "title": "覆盖别人的修改"}, + headers=user["headers"], + ) + body = resp.json() + assert resp.status_code == 409 + assert body["code"] == 4009 + assert body["data"]["current_version"] == first["version"] + + +async def test_other_tenant_cannot_read_or_list_my_task(client: AsyncClient) -> None: + me = await login(client) + other = await login(client) + ws = await create_workspace(client, me, "我的空间") + other_ws = await create_workspace(client, other, "别人的空间") + item = await _new_item(client, me, ws["id"], title="机密方案") + + resp = await client.get(f"{items_url(ws['id'])}/{item['id']}", headers=other["headers"]) + assert resp.json()["code"] == 3003 # 不是成员,连工作区都进不去 + + listed = unwrap( + await client.get( + items_url(other_ws["id"]), params={"view": "all"}, headers=other["headers"] + ) + ) + assert listed["items"] == [] + + +async def test_search_by_keyword_and_filter_by_tag(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + await _new_item(client, user, ws["id"], title="整理季度合同", tag_names=["合同", "法务"]) + await _new_item(client, user, ws["id"], title="买咖啡豆") + + found = unwrap( + await client.get( + items_url(ws["id"]), params={"q": "合同", "view": "all"}, headers=user["headers"] + ) + ) + assert [i["title"] for i in found["items"]] == ["整理季度合同"] + assert {t["name"] for t in found["items"][0]["tags"]} == {"合同", "法务"} + + tagged = unwrap( + await client.get( + items_url(ws["id"]), params={"tag": "法务", "view": "all"}, headers=user["headers"] + ) + ) + assert [i["title"] for i in tagged["items"]] == ["整理季度合同"] + + all_tags = unwrap( + await client.get(f"/api/v1/workspaces/{ws['id']}/tags", headers=user["headers"]) + ) + assert {t["name"] for t in all_tags["items"]} == {"合同", "法务"} + + +async def test_today_view_uses_local_timezone_boundaries(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + + now_local = datetime.now(TZ) + today_2330 = now_local.replace(hour=23, minute=30, second=0, microsecond=0) + tomorrow_0030 = (now_local + timedelta(days=1)).replace( + hour=0, minute=30, second=0, microsecond=0 + ) + + await _new_item( + client, + user, + ws["id"], + title="今晚交", + status="todo", + due_at=today_2330.astimezone(timezone.utc).isoformat(), + ) + await _new_item( + client, + user, + ws["id"], + title="明早交", + status="todo", + due_at=tomorrow_0030.astimezone(timezone.utc).isoformat(), + ) + + today = unwrap( + await client.get(items_url(ws["id"]), params={"view": "today"}, headers=user["headers"]) + ) + assert [i["title"] for i in today["items"]] == ["今晚交"] + + +async def test_soft_delete_hides_from_lists_but_keeps_row(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + item = await _new_item(client, user, ws["id"], status="todo") + + unwrap( + await client.delete( + f"{items_url(ws['id'])}/{item['id']}", + params={"version": item["version"]}, + headers=user["headers"], + ) + ) + active = unwrap( + await client.get(items_url(ws["id"]), params={"view": "active"}, headers=user["headers"]) + ) + assert active["items"] == [] + + deleted = unwrap( + await client.get(items_url(ws["id"]), params={"view": "deleted"}, headers=user["headers"]) + ) + assert [i["id"] for i in deleted["items"]] == [item["id"]] + + +async def test_comments_and_activity_stream(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + item = await _new_item(client, user, ws["id"]) + + unwrap( + await client.post( + f"{items_url(ws['id'])}/{item['id']}/status", + json={"status": "todo", "version": item["version"]}, + headers=user["headers"], + ) + ) + unwrap( + await client.post( + f"{items_url(ws['id'])}/{item['id']}/comments", + json={"body_md": "先找财务要模板"}, + headers=user["headers"], + ) + ) + + detail = unwrap( + await client.get(f"{items_url(ws['id'])}/{item['id']}", headers=user["headers"]) + ) + actions = [a["action"] for a in detail["activities"]] + assert actions[0] == "commented" + assert "status_changed" in actions + assert "created" in actions + + status_change = next(a for a in detail["activities"] if a["action"] == "status_changed") + assert status_change["payload"] == {"from": "inbox", "to": "todo"} + + +async def test_cursor_pagination_does_not_repeat_rows(client: AsyncClient) -> None: + user = await login(client) + ws = await create_workspace(client, user, "个人") + for index in range(3): + await _new_item(client, user, ws["id"], title=f"任务{index}", status="todo") + + first = unwrap( + await client.get( + items_url(ws["id"]), params={"view": "all", "limit": 2}, headers=user["headers"] + ) + ) + assert len(first["items"]) == 2 + assert first["has_more"] is True + assert first["next_cursor"] + + second = unwrap( + await client.get( + items_url(ws["id"]), + params={"view": "all", "limit": 2, "cursor": first["next_cursor"]}, + headers=user["headers"], + ) + ) + ids_first = {i["id"] for i in first["items"]} + ids_second = {i["id"] for i in second["items"]} + assert ids_first.isdisjoint(ids_second) + assert len(ids_second) == 1 + assert second["has_more"] is False diff --git a/web/src/App.tsx b/web/src/App.tsx index ce817f1..9dbc0b3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import type { User, Workspace } from "./lib/types"; import { LoginPage } from "./pages/Login"; import { HomePage } from "./pages/Home"; import { WorkspacePage } from "./pages/Workspace"; +import { TasksPage } from "./pages/Tasks"; import { JoinPage } from "./pages/Join"; interface AuthState { @@ -80,7 +81,8 @@ export function App() { } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 684b593..394d9aa 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -38,3 +38,40 @@ export interface Invitation { role: Role; expires_at: string; } + +export type ItemStatus = "inbox" | "todo" | "doing" | "done" | "blocked" | "canceled"; + +export interface Tag { + id: string; + name: string; + color: string; +} + +export interface Item { + id: string; + kind: "task" | "ticket" | "knowledge"; + title: string; + body_md: string; + status: ItemStatus; + priority: number; + creator_id: string; + assignee_id: string | null; + due_at: string | null; + completed_at: string | null; + tags: Tag[]; + created_at: string; + updated_at: string; + version: number; +} + +export interface Activity { + id: string; + action: string; + actor_id: string | null; + payload: Record | null; + created_at: string; +} + +export interface ItemDetail extends Item { + activities: Activity[]; +} diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx new file mode 100644 index 0000000..73f2355 --- /dev/null +++ b/web/src/pages/Tasks.tsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { Layout } from "../components/Layout"; +import { ApiError, api } from "../lib/client"; +import type { Item, ItemDetail, ItemStatus, Page } from "../lib/types"; + +const VIEWS: { key: string; label: string; hint: string }[] = [ + { key: "today", label: "今天", hint: "今天之前到期、还没做完的" }, + { key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" }, + { key: "inbox", label: "收件箱", hint: "想到还没归类的事" }, + { key: "done", label: "已完成", hint: "最近完成的" }, + { key: "all", label: "全部", hint: "含已取消" }, +]; + +const STATUS_LABEL: Record = { + inbox: "收件箱", + todo: "待办", + doing: "进行中", + done: "已完成", + blocked: "被阻塞", + canceled: "已取消", +}; + +const STATUS_STYLE: Record = { + inbox: "bg-ink-100 text-ink-700", + todo: "bg-ink-100 text-ink-700", + doing: "bg-accent/15 text-accent", + done: "bg-emerald-100 text-emerald-700", + blocked: "bg-amber-100 text-amber-700", + canceled: "bg-ink-100 text-ink-500 line-through", +}; + +function formatDue(iso: string | null): { text: string; overdue: boolean } | null { + if (!iso) return null; + const due = new Date(iso); + const now = new Date(); + const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); + return { text, overdue: due.getTime() < now.getTime() }; +} + +export function TasksPage() { + const { wsId } = useParams<{ wsId: string }>(); + const [view, setView] = useState("today"); + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [query, setQuery] = useState(""); + const [draft, setDraft] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [openId, setOpenId] = useState(null); + const [detail, setDetail] = useState(null); + const draftRef = useRef(null); + + const load = useCallback( + async (nextCursor?: string | null) => { + if (!wsId) return; + setLoading(true); + try { + const params = new URLSearchParams({ view, limit: "30" }); + if (query.trim()) params.set("q", query.trim()); + if (nextCursor) params.set("cursor", nextCursor); + const page = await api.get>(`/workspaces/${wsId}/items?${params}`); + setItems((prev) => (nextCursor ? [...prev, ...page.items] : page.items)); + setCursor(page.next_cursor); + setHasMore(page.has_more); + setError(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : "加载失败"); + } finally { + setLoading(false); + } + }, + [wsId, view, query], + ); + + useEffect(() => { + void load(null); + }, [load]); + + useEffect(() => { + if (!openId || !wsId) { + setDetail(null); + return; + } + void api + .get(`/workspaces/${wsId}/items/${openId}`) + .then(setDetail) + .catch(() => setDetail(null)); + }, [openId, wsId]); + + async function addTask() { + const title = draft.trim(); + if (!title || !wsId) return; + try { + const created = await api.post(`/workspaces/${wsId}/items`, { + title, + status: view === "inbox" ? "inbox" : "todo", + }); + setDraft(""); + setItems((prev) => [created, ...prev]); + draftRef.current?.focus(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "创建失败"); + } + } + + async function changeStatus(item: Item, status: ItemStatus) { + if (!wsId) return; + try { + const updated = await api.post(`/workspaces/${wsId}/items/${item.id}/status`, { + status, + version: item.version, + }); + setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it))); + if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev)); + } catch (err) { + if (err instanceof ApiError && err.code === 4009) { + setError("这条任务刚被别人改过,已为你刷新"); + void load(null); + return; + } + setError(err instanceof ApiError ? err.message : "状态更新失败"); + } + } + + const current = VIEWS.find((v) => v.key === view); + + return ( + +
+
+

任务

+

{current?.hint}

+
+
+ + 成员与邀请 + + + 切换工作区 + +
+
+ +
+ {VIEWS.map((v) => ( + + ))} + setQuery(e.target.value)} + /> +
+ +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addTask(); + }} + /> + +
+
+ + {error &&

{error}

} + +
+ {items.length === 0 && !loading && ( +

+ {view === "today" ? "今天没有到期的事,挺好。" : "这里还空着。"} +

+ )} + + {items.map((item) => { + const due = formatDue(item.due_at); + const done = item.status === "done"; + // 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。 + const canComplete = item.status !== "inbox" && item.status !== "canceled"; + return ( +
+ {canComplete ? ( + void changeStatus(item, done ? "todo" : "done")} + aria-label={done ? "标记未完成" : "标记完成"} + /> + ) : ( + + )} +
+ +
+ + {STATUS_LABEL[item.status]} + + {due && ( + + 截止 {due.text} + {due.overdue && !done ? "(逾期)" : ""} + + )} + {item.tags.map((tag) => ( + + #{tag.name} + + ))} +
+ + {openId === item.id && ( +
+ {detail && detail.id === item.id ? ( + <> + {detail.body_md ? ( +

{detail.body_md}

+ ) : ( +

没有备注。

+ )} +
    + {detail.activities.slice(0, 8).map((a) => ( +
  • + {new Date(a.created_at).toLocaleString("zh-CN")} · {a.action} +
  • + ))} +
+ + ) : ( +

加载中…

+ )} +
+ )} +
+ +
+ {item.status === "inbox" && ( + + )} + {(item.status === "todo" || item.status === "blocked") && ( + + )} + {item.status === "doing" && ( + + )} +
+
+ ); + })} +
+ + {hasMore && ( +
+ +
+ )} + {loading && items.length === 0 &&

加载中…

} +
+ ); +} diff --git a/web/src/pages/Workspace.tsx b/web/src/pages/Workspace.tsx index e03c995..6d67ae9 100644 --- a/web/src/pages/Workspace.tsx +++ b/web/src/pages/Workspace.tsx @@ -60,8 +60,8 @@ export function WorkspacePage() { return (
- - ← 工作区 + + ← 任务 {ws && {ROLE_LABEL[ws.role]}}
diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index b00dd4d..fed686b 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/lib/client.ts","./src/lib/types.ts","./src/pages/home.tsx","./src/pages/join.tsx","./src/pages/login.tsx","./src/pages/placeholder.tsx","./src/pages/workspace.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/lib/client.ts","./src/lib/types.ts","./src/pages/home.tsx","./src/pages/join.tsx","./src/pages/login.tsx","./src/pages/placeholder.tsx","./src/pages/tasks.tsx","./src/pages/workspace.tsx"],"version":"5.9.3"} \ No newline at end of file