feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
CI / backend (push) Canceled after 0s

- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
This commit is contained in:
Steven
2026-10-28 03:48:48 +08:00
parent d390b7b58a
commit e25a1a4e31
19 changed files with 1959 additions and 31 deletions
+206
View File
@@ -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_token>`。`access` 30 分钟,`refresh` 14 天。
`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。
### 0.4 幂等
所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: <uuid>`。
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`。
+35 -21
View File
@@ -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|<uuid>`、`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`)
---
+18 -4
View File
@@ -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,别指向开发库**。
+202
View File
@@ -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};",
)
+612
View File
@@ -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="已创建")
+5
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""任务域业务规则(与框架无关,方便单测)。"""
+100
View File
@@ -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
+8
View File
@@ -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,
}
+3 -1
View File
@@ -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="存活探针")
+12 -1
View File
@@ -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,
)
+148
View File
@@ -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")
+3
View File
@@ -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
+272
View File
@@ -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
+3 -1
View File
@@ -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() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/w/:wsId" element={<WorkspacePage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+37
View File
@@ -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<string, unknown> | null;
created_at: string;
}
export interface ItemDetail extends Item {
activities: Activity[];
}
+291
View File
@@ -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<ItemStatus, string> = {
inbox: "收件箱",
todo: "待办",
doing: "进行中",
done: "已完成",
blocked: "被阻塞",
canceled: "已取消",
};
const STATUS_STYLE: Record<ItemStatus, string> = {
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<Item[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [query, setQuery] = useState("");
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [detail, setDetail] = useState<ItemDetail | null>(null);
const draftRef = useRef<HTMLInputElement>(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<Page<Item>>(`/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<ItemDetail>(`/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<Item>(`/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<Item>(`/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 (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">任务</h1>
<p className="mt-0.5 text-xs text-ink-500">{current?.hint}</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
成员与邀请
</Link>
<Link to="/" className="text-ink-500 hover:text-ink-900">
切换工作区
</Link>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
{VIEWS.map((v) => (
<button
key={v.key}
onClick={() => setView(v.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
view === v.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{v.label}
</button>
))}
<input
className="input ml-auto w-48"
placeholder="搜索标题或正文…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="card mb-4">
<div className="flex gap-2">
<input
ref={draftRef}
className="input flex-1"
placeholder="加一件要做的事,回车创建"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addTask();
}}
/>
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addTask()}>
添加
</button>
</div>
</div>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<div className="card divide-y divide-ink-100 p-0">
{items.length === 0 && !loading && (
<p className="p-6 text-center text-sm text-ink-500">
{view === "today" ? "今天没有到期的事,挺好。" : "这里还空着。"}
</p>
)}
{items.map((item) => {
const due = formatDue(item.due_at);
const done = item.status === "done";
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
const canComplete = item.status !== "inbox" && item.status !== "canceled";
return (
<div key={item.id} className="flex items-start gap-3 px-4 py-3">
{canComplete ? (
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-ink-900"
checked={done}
onChange={() => void changeStatus(item, done ? "todo" : "done")}
aria-label={done ? "标记未完成" : "标记完成"}
/>
) : (
<span className="mt-1 h-4 w-4 rounded border border-ink-100" aria-hidden />
)}
<div className="min-w-0 flex-1">
<button
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
onClick={() => setOpenId(openId === item.id ? null : item.id)}
>
{item.title}
</button>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
{STATUS_LABEL[item.status]}
</span>
{due && (
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
截止 {due.text}
{due.overdue && !done ? "(逾期)" : ""}
</span>
)}
{item.tags.map((tag) => (
<span key={tag.id} className="rounded bg-ink-50 px-1.5 py-0.5 text-ink-700">
#{tag.name}
</span>
))}
</div>
{openId === item.id && (
<div className="mt-3 rounded-lg bg-ink-50 p-3 text-xs">
{detail && detail.id === item.id ? (
<>
{detail.body_md ? (
<p className="whitespace-pre-wrap text-ink-700">{detail.body_md}</p>
) : (
<p className="text-ink-500">没有备注。</p>
)}
<ul className="mt-3 space-y-1 text-ink-500">
{detail.activities.slice(0, 8).map((a) => (
<li key={a.id}>
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
</li>
))}
</ul>
</>
) : (
<p className="text-ink-500">加载中…</p>
)}
</div>
)}
</div>
<div className="flex shrink-0 gap-1">
{item.status === "inbox" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "todo")}>
收下
</button>
)}
{(item.status === "todo" || item.status === "blocked") && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "doing")}>
开工
</button>
)}
{item.status === "doing" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "blocked")}>
阻塞
</button>
)}
</div>
</div>
);
})}
</div>
{hasMore && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</div>
)}
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
</Layout>
);
}
+2 -2
View File
@@ -60,8 +60,8 @@ export function WorkspacePage() {
return (
<Layout>
<div className="mb-4 flex items-center gap-3 text-sm text-ink-500">
<Link to="/" className="hover:text-ink-900">
← 工作区
<Link to={`/w/${wsId}`} className="hover:text-ink-900">
← 任务
</Link>
{ws && <span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs">{ROLE_LABEL[ws.role]}</span>}
</div>
+1 -1
View File
@@ -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"}
{"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"}