- 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:
+206
@@ -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
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,别指向开发库**。
|
||||
|
||||
@@ -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};",
|
||||
)
|
||||
@@ -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="已创建")
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""任务域业务规则(与框架无关,方便单测)。"""
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user