Compare commits

..
4 Commits
Author SHA1 Message Date
dadao 600131dcf5 docs(m2): 补工单接口契约、M2 清单与状态表
CI / backend (push) Canceled after 0s
- API.md 新增第 4 节:升级/动作/列表/关系/通知与 6xxx 错误码
- ROADMAP 补 M2 全部条目与 DOD,验收用例加自验与回流两条
- DEVELOPMENT 记两条新坑:PowerShell 批量改写会毁中文、npm.ps1 被执行策略拦
- README 状态表把工单从待做改成已通,测试数 57
2026-10-28 13:20:31 +08:00
dadao fd18b2a403 feat(android): 工单列表与动作,任务页可进工单
- Api 增 tickets/ticketAction/verifyTicket/promote,状态机交给服务端,客户端只发动作
- TicketsScreen:派给我的 / 我发起的、按状态给接收/开始/提交/通过/驳回、6004 与 6003 给出人话提示
- 仍未编译验证(本机无 JDK/Gradle),见 android/README.md
2026-10-28 13:20:31 +08:00
dadao b1dc3cffa6 feat(web): 工单页与升级面板,修掉视图竞态与标签缺失导致的崩溃
- 新增 /w/:wsId/tickets:派给我的 / 我发起的、状态过滤、列表内联快操作
- 详情抽屉按角色显示受理/开工/提交/验收/驳回/改派/取消;任务态底部可升级成工单
- 导航抽成 WorkspaceNav,带未读角标(30 秒对齐,操作后立刻刷新)
- 修竞态:切视图/切 tab 连发请求时,慢的回包会把新结果盖掉(reqId 序号丢弃过期响应)
- 登录支持 ?next= 站内回跳,邀请链接不再'登录完掉回首页'
- e2e/tickets.mjs:双人双上下文的完整闭环,8 项断言全绿
2026-10-28 13:20:18 +08:00
dadao 30caaaf1d9 feat(ticket): 工单协同后端——升级不搬数据、受理到验收、站内通知
- item.kind='ticket' + ticket_ext 1:1,迁移 0003 同时重写 item 状态约束为任务∪工单并集
- 工单状态机与动作权限:受理/开工/提交限办理人,验收限需求人(办理人不能自验)
- 升级时原任务转 blocked,验收通过后自动回流 todo,事件流两侧都留痕
- item_link(父子/相关)与 notification(只发当事人),三张表均开 RLS
- 抽出 serializers.py 复用任务序列化,修复工单响应缺 tags 导致前端白屏
- 评论与 PATCH 放开给工单;conftest 清库前终止残留事务,避免 TRUNCATE 死等锁
- tests/test_tickets.py 11 个用例,全量 57 passed
2026-10-28 13:20:07 +08:00
30 changed files with 3047 additions and 108 deletions
+81 -2
View File
@@ -1,4 +1,4 @@
# 接口约定(M0–M1 已实现)
# 接口约定(M0–M2 已实现)
> 只写**已经在跑**的接口。写完没验证的东西不进这份文档。
> 前缀 `/api/v1`。所有响应都是同一个信封,**HTTP 200 不代表业务成功**。
@@ -222,7 +222,86 @@ canceled→ todo (恢复)
---
## 4. 健康与版本
## 4. 工单 `/workspaces/{ws_id}`
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
```json
{ "assignee_id": "uuid", "title": "可选覆盖", "body_md": "可选补充",
"due_at": "2026-10-30T10:00:00+08:00", "priority": 2, "version": 3 }
```
返回 `{ "task": {...}, "ticket": {...} }`。
- 原任务**留在原地**变成 `blocked`,不搬数据——时间线完整保留。
- 一个任务只能升级一次,重复升级 → `6002`。
- `assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
- 传 `version` 走乐观锁,`4009` 表示任务刚被别人改过。
- 被指派人立刻收到 `ticket_assigned` 通知。
### 4.2 工单动作
| 方法 | 路径 | 谁可以 | 前置状态 → 结果 |
|---|---|---|---|
| POST | `/items/{item_id}/ticket/accept` | 办理人 / admin+ | open → accepted |
| POST | `/items/{item_id}/ticket/start` | 办理人 / admin+ | accepted → in_progress |
| POST | `/items/{item_id}/ticket/submit` | 办理人 / admin+ | in_progress → pending_verify |
| POST | `/items/{item_id}/ticket/verify` | **需求人** / admin+ | pending_verify → done / in_progress |
| POST | `/items/{item_id}/ticket/reassign` | 需求人 / admin+ | 任意未完成 → open(清空受理时间) |
| POST | `/items/{item_id}/ticket/cancel` | 需求人 / 办理人 / admin+ | 未完成 → canceled |
`verify` 的请求体:`{ "approve": true, "note": "可选说明", "resolution_md": "验收结论" }`。
- 驳回时 `approve=false`,回到 `in_progress`(不是 `open`——人还在做,不必重新认领),`reject_count` 加一。
- **办理人不能验收自己的工单**,否则 `6004`。这是唯一一条"就算 owner 也不通融"的规则之外的角色限制。
- `resolution_md` 是 M3 知识草稿的原料,尽量写。
- 前置状态不对 → `6003`,`details` 同时给 `allowed`(当前状态还能去哪)与 `allowed_from`(该动作要求的前置状态)。
### 4.3 工单列表 `GET /tickets`
| 参数 | 说明 |
|---|---|
| `role` | `assigned`(派给我的,默认)/ `requested`(我发起的)/ `all` |
| `status` | 可重复,如 `?status=open&status=accepted` |
| `limit` / `cursor` | 与任务列表一致 |
### 4.4 事项关系 `GET|POST /items/{item_id}/links`
```json
{ "to_item_id": "uuid", "link_type": "parent" }
```
`GET` 返回 `{ "children": [...], "parents": [...], "related": [...] }`。
`link_type` 只有 `parent` / `related`,**方向是 from 是 to 的子项**;自环 `5005`、重复挂 `5005`、对方不存在 `5004`、跨租户 `5001`。
### 4.5 通知 `/workspaces/{ws_id}/notifications`
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/notifications?unread_only=true` | 我的通知,新的在前 |
| GET | `/notifications/unread-count` | `{ "unread": 3 }`,轮询用 |
| POST | `/notifications/{id}/read` | 标一条 |
| POST | `/notifications/read-all` | 全标已读 |
`kind`:`ticket_assigned` / `ticket_submitted` / `ticket_approved` / `ticket_rejected` / `task_unblocked`。
通知只发给当事人,同工作区里其他人看不到——这是"协同"与"广播"的区别。
### 4.6 工单的错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 6001 | 404 | 工单不存在,或不是工单 |
| 6002 | 409 | 这个任务已经升级过了 |
| 6003 | 409 | 当前状态不允许这个动作 |
| 6004 | 403 | 你没有权限做这个动作(例如自验) |
| 6005 | 404 | 通知不存在,或不是你的 |
---
## 5. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
+3 -1
View File
@@ -135,6 +135,8 @@ VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/d
| `docker compose` 报 `unknown command` | 只装了 compose v1 | 用 `docker-compose` |
| Docker 起不来 | Docker Desktop 未启动 / 需要管理员 | 先启动 Desktop,`docker info` 有输出再继续 |
| 迁移报 `cannot insert multiple commands into a prepared statement` | asyncpg 走预备语句,一次只能一条 SQL | 多语句 DDL 拆成每语句一次 `op.execute()` |
| 源文件里的中文变成 `宸ュ崟鍩?` | 用 PowerShell `Get-Content`/`Set-Content` 批量改文件:读时按 GBK 解码,写回 UTF-8,不可逆 | **含中文的源文件只能用编辑器/`edit` 工具改**;真要写脚本,必须显式 `[IO.File]::ReadAllText($p, [Text.Encoding]::UTF8)` + `WriteAllText(..., UTF8Encoding($false))` |
| `npm` 报 `无法加载文件 npm.ps1,因为在此系统上禁止运行脚本` | PowerShell 执行策略 | 用 `npm.cmd`,或直接 `node node_modules/vite/bin/vite.js build` |
### 团队共享开发库(fnsvr-01:8432)
@@ -149,7 +151,7 @@ VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/d
角色:`dadao_owner`(迁移用,库 owner)与 `dadao_app`(应用用,非 superuser / 无 BYPASSRLS)。
连接串见 `backend/.env.example` 里的注释行,直接拷进 `backend/.env` 即可。
已验证:在该库上 `alembic upgrade head` 成功,`pytest` **33 passed**(服务端 PG 17.6 vs 本机 16.4,两版都过)。
已验证:在该库上 `alembic upgrade head`(到 0003)成功,`pytest` **57 passed**(服务端 PG 17.6 vs 本机 16.4,两版都过)。
> ⚠️ **这台服务器没有 pgvector**(`pg_available_extensions` 里只有 `pg_trgm`、`pgcrypto`)。
> M3 知识库要做向量检索的话,必须先解决扩展来源:装 pgvector(需要服务器 root 装 OS 包)/
+59 -7
View File
@@ -1,4 +1,4 @@
# M0–M1 实施清单
# 实施清单(M0–M2)
> 来源:`2.Design/PRODUCT-PLAN.md` v1.0 第 12 节
> 粒度:每项 0.3–1.5 人日,2 人并行 ≈ 2 周一个里程碑
@@ -211,26 +211,78 @@
---
## 关键验收用例(M0–M1 必须通过)
## M2 · 工单协同(第 5–6 周,10 人日)
### 2.1 数据(1d)
- [x] `item.kind='ticket'` 落地;迁移 `0003_tickets.py` **重写 item 的状态约束**为任务∪工单并集
(旧约束只允许任务六态,工单的 `open/accepted/...` 会被直接拦下)
- [x] `ticket_ext`(`item_id` 既是主键又是外键,天然 1:1):需求人/办理人/原任务指针/受理·提交·验收时间/退回次数/验收结论
- [x] `item_link`(父子、相关,禁止自环)与 `notification`(只发给当事人)
- [x] 三张表全部 `ENABLE ROW LEVEL SECURITY` + 同租户策略 + GRANT 给应用角色
### 2.2 领域(1.5d)
- [x] 工单状态机 `open→accepted→in_progress→pending_verify→done`(可 `canceled`),默认拒绝
- [x] 动作→角色规则:受理/开工/提交限办理人;**验收限需求人,办理人不能自验**(`6004`);改派/取消限需求人或办理人
- [x] 驳回回 `in_progress` 而不是 `open`,`reject_count` 累加
- [x] 升级不搬数据:原任务变 `blocked`,验收通过后自动回流 `todo`
### 2.3 API(1.5d)
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
- [x] `GET /tickets?role=assigned|requested|all&status=`
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
- [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
### 2.4 Web(3d)
- [x] 工单页 `/w/:wsId/tickets`:派给我的 / 我发起的 / 全部,状态过滤,列表内联快操作
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
### 2.5 安卓(1.5d)
- [ ] 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是**未编译**状态
### 2.6 测试与验收(0.7d)
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
- [x] 全量 `pytest -q` → **57 passed**;`ruff check` / `format --check` 全 0
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
### 2.7 通知真实投递
- [ ] 站内通知已有;微信/飞书推送推迟到 M6(M2 只要"在系统里能看到该我办了")
### M2 完成定义(DOD)
- [x] 两个人能跑完一次真实协助:升级 → 受理 → 办理 → 提交 → 验收 → 原任务自动解锁
- [x] 办理人无法自验、跨租户看不到工单、通知只给当事人
- [ ] 真实用一周:所有"需要别人帮忙"的事都走工单,不再私聊催(需要真实团队)
---
## 关键验收用例(M0–M2 必须通过)
| # | 用例 | 期望 | 现状 |
|---|---|---|---|
| 1 | 租户 A 用户请求租户 B 的 workspace id | 3003 未加入(不泄露存在性) | ✅ 已覆盖 |
| 2 | 同一手机号登录两次 | 同一个 user_id,不重复建号 | ✅ 已覆盖 |
| 3 | 被移出团队的成员拿旧 token 请求 | 3003;refresh 也失效 | [~] access 已拒,refresh 不可撤销(见 0.6) |
| 4 | 并发两次提交同一任务的不同状态 | 一次成功,一次 409 并可重试 | [ ] M1 |
| 4 | 并发两次提交同一任务的不同状态 | 一次成功,一次 409 并可重试 | ✅ 已覆盖(版本冲突用例) |
| 5 | 网络中断后重新提交 | 幂等键保证不产生两条 | ✅ 已覆盖 |
| 6 | 删除任务后全局搜索 | 搜不到,且回收站可恢复 | [ ] M3 |
| 7 | 停掉 Redis | `/health` 变 503,业务接口给明确错误而非 500 | [ ] dev 用内存实现,未做 |
| 8 | 部署后 `/version` | commit 与仓库 HEAD 一致 | [~] 机制已有,等部署验证 |
| 9 | 办理人验收自己的工单 | 403 `6004`,不能自己给自己过 | ✅ 已覆盖 |
| 10 | 工单验收通过后原任务 | 自动从"被阻塞"回到"待办",事件流留痕 | ✅ 已覆盖(含 e2e) |
---
## 下一步排期建议
```
已完成 0.1–0.9(骨架、认证、租户隔离、接口规范)+ 0.13 两端壳
待补 PR 模板 / CI 在 Gitea 实跑 / 0.14 微信 spike / Web 构建验证
第3周 0.11 部署 + 0.12 契约 + 1.1–1.3 任务域后端
第4周 1.4 Web 四视图 + 自己开始 dogfooding
已完成 0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同(含双人 e2e)
待补 0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑 / 安卓编译链
第7–8周 M3 知识:knowledge_doc + chunk、切片与索引、工单关闭生成草稿、带引用问答
第9–10周 M4 AI:结构化录入、人物工作侧写、主动提醒、20 条黄金用例回归
```
+4 -2
View File
@@ -21,11 +21,13 @@
|---|---|
| 手机号验证码登录、刷新令牌、幂等请求 | ✅ 已实现并有测试 |
| 工作区 / 成员 / 邀请码加入,跨租户隔离(RLS) | ✅ 同上 |
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,46 个测试全绿 |
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,57 个测试全绿 |
| Web 列表 / 看板拖拽 / 详情侧栏编辑 / 键盘流 / 快速录入语法 | ✅ e2e 7 项断言全绿,构建通过 |
| 1000 条任务下的首屏性能 | ✅ 实测 P50 38–55ms(目标 < 300ms) |
| 安卓端任务列表与详情 | ⚠️ 代码已写,**本机无 JDK/Gradle,未编译验证** |
| 工单、知识库、LLM 能力 | ⬜ M2–M4 |
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web 已通,双人 e2e 8 项全绿 |
| 后端测试 | ✅ 57 passed,ruff 干净 |
| 知识库、LLM 能力 | ⬜ M3–M4 |
## 快速开始
@@ -20,6 +20,7 @@ import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
class MainActivity : ComponentActivity() {
@@ -35,7 +36,7 @@ class MainActivity : ComponentActivity() {
}
}
private enum class Screen { Splash, Login, Home, Tasks }
private enum class Screen { Splash, Login, Home, Tasks, Tickets }
@Composable
private fun AppRoot() {
@@ -77,7 +78,20 @@ private fun AppRoot() {
if (ws == null) {
screen = Screen.Home
} else {
TaskListScreen(api = api, workspace = ws, onBack = { screen = Screen.Home })
TaskListScreen(
api = api,
workspace = ws,
onBack = { screen = Screen.Home },
onOpenTickets = { screen = Screen.Tickets },
)
}
}
Screen.Tickets -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
TicketsScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks })
}
}
}
@@ -129,6 +129,39 @@ data class Invitation(
@SerialName("expires_at") val expiresAt: String,
)
/** 与 backend/app/models_ticket.py 的 `ticket_ext` 对齐(只取手机端要显示的)。 */
@Serializable
data class TicketInfo(
@SerialName("requester_id") val requesterId: String = "",
@SerialName("assignee_id") val assigneeId: String = "",
@SerialName("parent_item_id") val parentItemId: String? = null,
@SerialName("reject_count") val rejectCount: Int = 0,
@SerialName("resolution_md") val resolutionMd: String = "",
)
/**
* 工单 = 一条 kind=ticket 的 item + 一份 ticket 扩展。
* 结构上就是 [Item] 多一个 ticket 字段,所以不要另起一套字段名。
*/
@Serializable
data class Ticket(
val id: String,
val title: String,
@SerialName("body_md") val bodyMd: String = "",
val status: String,
val priority: Int = 1,
@SerialName("due_at") val dueAt: String? = null,
@SerialName("version") val version: Int = 1,
val ticket: TicketInfo = TicketInfo(),
)
@Serializable
data class TicketPage(
val tickets: List<Ticket> = emptyList(),
@SerialName("next_cursor") val nextCursor: String? = null,
@SerialName("has_more") val hasMore: Boolean = false,
)
private data class SmsRequest(val phone: String)
private data class LoginRequest(val phone: String, val code: String)
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
@@ -219,6 +252,57 @@ class Api(private val tokens: TokenStore) {
auth = true,
)
// ---- M2 工单 ----
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
suspend fun tickets(workspaceId: String, role: String = "assigned"): TicketPage =
call(path = "/workspaces/$workspaceId/tickets?role=$role", auth = true)
/**
* 工单动作。状态机在服务端,这里只发"做什么",不自己算目标状态——
* 客户端如果自己判断,规则一改就会和前端的按钮显隐对不上。
*/
suspend fun ticketAction(
workspaceId: String,
itemId: String,
action: String,
body: Map<String, Any> = emptyMap(),
): Ticket = call(
path = "/workspaces/$workspaceId/items/$itemId/ticket/$action",
body = json.encodeToString(body),
auth = true,
)
suspend fun verifyTicket(
workspaceId: String,
itemId: String,
approve: Boolean,
note: String = "",
resolution: String = "",
): Ticket = ticketAction(
workspaceId,
itemId,
"verify",
mapOf("approve" to approve, "note" to note, "resolution_md" to resolution),
)
/** 把任务升级成工单:原任务留在原地变"被阻塞",不搬数据。 */
suspend fun promote(
workspaceId: String,
itemId: String,
assigneeId: String,
version: Int? = null,
): JsonElement = call(
path = "/workspaces/$workspaceId/items/$itemId/promote",
body = json.encodeToString(
buildMap {
put("assignee_id", assigneeId)
if (version != null) put("version", version)
},
),
auth = true,
)
private suspend inline fun <reified T> call(
path: String,
body: String? = null,
@@ -72,6 +72,7 @@ fun TaskListScreen(
api: Api,
workspace: Workspace,
onBack: () -> Unit,
onOpenTickets: () -> Unit = {},
) {
val items = remember { mutableStateListOf<Item>() }
var view by remember { mutableStateOf("today") }
@@ -127,6 +128,7 @@ fun TaskListScreen(
TopAppBar(
title = { Text(workspace.name) },
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
actions = { TextButton(onClick = onOpenTickets) { Text("工单") } },
)
},
) { padding ->
@@ -0,0 +1,199 @@
package com.dadao.task.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Ticket
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
private val ROLES = listOf(
"assigned" to "派给我的",
"requested" to "我发起的",
)
private val TICKET_STATUS_LABEL = mapOf(
"open" to "待接收",
"accepted" to "待办理",
"in_progress" to "办理中",
"pending_verify" to "待验收",
"done" to "已完成",
"canceled" to "已取消",
)
/**
* 工单列表(移动端只做"该我办的"与"我发起的"两栏)。
*
* 为什么手机端也必须有:需要别人帮忙时,人往往不在电脑前。
* 只做查看不做动作的工单页等于把流程卡在桌面端,等于没有协同。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TicketsScreen(
api: Api,
workspace: Workspace,
onBack: () -> Unit,
) {
val tickets = remember { mutableStateListOf<Ticket>() }
var role by remember { mutableStateOf("assigned") }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
suspend fun reload() {
loading = true
error = null
try {
val page = api.tickets(workspace.id, role)
tickets.clear()
tickets.addAll(page.tickets)
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
}
}
LaunchedEffect(role) { reload() }
suspend fun act(ticket: Ticket, action: String, approve: Boolean = false) {
try {
val updated = if (action == "verify") {
api.verifyTicket(workspace.id, ticket.id, approve)
} else {
api.ticketAction(workspace.id, ticket.id, action)
}
val index = tickets.indexOfFirst { it.id == updated.id }
if (index >= 0) tickets[index] = updated
// 办完就不该再占着"待我处理"的位置
if (updated.status == "done" && role == "assigned") tickets.removeAll { it.id == updated.id }
} catch (e: ApiException) {
error = when (e.code) {
6004 -> "这个动作得由需求人来点(自己不能验收自己的工单)"
6003 -> "当前状态不允许这个操作,可能刚被别人推进了一步"
else -> e.message
}
reload()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("${workspace.name} · 工单") },
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ROLES.forEach { (key, label) ->
FilterChip(
selected = role == key,
onClick = { role = key },
label = { Text(label) },
)
}
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
when {
loading -> Text("加载中…")
tickets.isEmpty() -> Text(
if (role == "assigned") "没有需要你出手的事。" else "你还没找人帮过忙。",
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(tickets, key = { it.id }) { ticket ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text(ticket.title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(
buildString {
append(TICKET_STATUS_LABEL[ticket.status] ?: ticket.status)
if (ticket.ticket.rejectCount > 0) {
append(" · 已退回 ").append(ticket.ticket.rejectCount).append(" 次")
}
},
style = MaterialTheme.typography.bodySmall,
)
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (ticket.status) {
"open" -> ActionButton("接收") {
scope.launch { act(ticket, "accept") }
}
"accepted" -> ActionButton("开始") {
scope.launch { act(ticket, "start") }
}
"in_progress" -> ActionButton("提交验收") {
scope.launch { act(ticket, "submit") }
}
"pending_verify" -> {
ActionButton("通过") {
scope.launch { act(ticket, "verify", approve = true) }
}
ActionButton("驳回") {
scope.launch { act(ticket, "verify", approve = false) }
}
}
else -> Unit
}
Spacer(Modifier.weight(1f))
if (ticket.status != "done" && ticket.status != "canceled") {
TextButton(onClick = { scope.launch { act(ticket, "cancel") } }) {
Text("取消")
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun ActionButton(label: String, onClick: () -> Unit) {
TextButton(onClick = onClick) { Text(label) }
}
+181
View File
@@ -0,0 +1,181 @@
"""M2 工单域:ticket_ext / item_link / notification + 扩宽 item.status 取值。
三个需要注意的点:
1. **item 的 status CHECK 必须重写**。0002 里只允许任务的六个状态,
工单的 `open/accepted/in_progress/pending_verify` 会直接被约束拦下。
这里改成任务 ∪ 工单的并集,而不是按 kind 做条件约束——PostgreSQL 的 CHECK 不能引用其它行,
而"任务不该有工单状态"这件事由状态机(`app/domain/*.py`)保证,不靠数据库。
2. **新表同样开 RLS**。通知是"谁的"、工单附加信息是"哪个租户的",漏一张就等于开了一个洞。
3. **downgrade 会删掉所有工单数据**(kind='ticket' 的 item 及其扩展)。这是有意的:
旧约束容纳不下工单状态,不回删就无法回到 0002。已在函数注释里写明。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
TICKET_STATUSES = ("open", "accepted", "in_progress", "pending_verify", "done", "canceled")
def _ws_fk() -> sa.Column:
return sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
)
def _user_fk(name: str, *, ondelete: str, nullable: bool = False) -> sa.Column:
return sa.Column(
name,
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete=ondelete),
nullable=nullable,
)
def _item_fk(name: str, *, ondelete: str, nullable: bool = False) -> sa.Column:
return sa.Column(
name,
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete=ondelete),
nullable=nullable,
)
def upgrade() -> None:
# 1) 放宽 item.status:任务 ∪ 工单
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
statuses = ",".join(f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES))
op.execute(
f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))"
)
# 2) 工单扩展:item_id 既是主键也是外键,天然 1:1
op.create_table(
"ticket_ext",
sa.Column(
"item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="CASCADE"),
primary_key=True,
),
_ws_fk(),
_user_fk("requester_id", ondelete="RESTRICT"),
_user_fk("assignee_id", ondelete="RESTRICT"),
_item_fk("parent_item_id", ondelete="SET NULL", nullable=True),
sa.Column("accepted_at", sa.DateTime(timezone=True)),
sa.Column("submitted_at", sa.DateTime(timezone=True)),
sa.Column("verified_at", sa.DateTime(timezone=True)),
_user_fk("verified_by", ondelete="SET NULL", nullable=True),
sa.Column("reject_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("resolution_md", sa.Text, nullable=False, server_default=""),
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"),
sa.CheckConstraint("reject_count >= 0", name="ck_ticket_reject_count"),
)
op.create_index("ix_ticket_ws_assignee", "ticket_ext", ["workspace_id", "assignee_id"])
op.create_index("ix_ticket_ws_requester", "ticket_ext", ["workspace_id", "requester_id"])
# 3) 事项关系(子任务 / 相关)
op.create_table(
"item_link",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
_item_fk("from_item_id", ondelete="CASCADE"),
_item_fk("to_item_id", ondelete="CASCADE"),
sa.Column("link_type", sa.String(16), nullable=False, server_default="related"),
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"),
sa.UniqueConstraint("from_item_id", "to_item_id", "link_type", name="uq_item_link"),
sa.CheckConstraint("from_item_id <> to_item_id", name="ck_item_link_not_self"),
sa.CheckConstraint("link_type IN ('parent','related')", name="ck_item_link_type"),
)
op.create_index("ix_item_link_ws_from", "item_link", ["workspace_id", "from_item_id"])
op.create_index("ix_item_link_ws_to", "item_link", ["workspace_id", "to_item_id"])
# 4) 站内通知
op.create_table(
"notification",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
_user_fk("user_id", ondelete="CASCADE"),
sa.Column("kind", sa.String(32), nullable=False),
_item_fk("item_id", ondelete="CASCADE", nullable=True),
sa.Column("title", sa.String(120), nullable=False),
sa.Column("body", sa.String(300), nullable=False, server_default=""),
sa.Column("read_at", sa.DateTime(timezone=True)),
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"),
)
op.create_index("ix_notification_ws_user_read", "notification", ["workspace_id", "user_id", "read_at"])
op.create_index("ix_notification_ws_created", "notification", ["workspace_id", "created_at"])
for statement in _POLICY_STATEMENTS:
op.execute(statement)
for statement in _GRANT_STATEMENTS:
op.execute(statement)
def downgrade() -> None:
# 旧约束容纳不下工单状态,回退必须先清掉工单数据(含它们的 ticket_ext)。
# 这是破坏性操作,只在回滚迁移时发生;生产环境回滚前请先导出 kanban/ticket 数据。
op.execute("DELETE FROM item WHERE kind = 'ticket'")
for table in TENANT_TABLES_0003:
op.execute(f"DROP POLICY IF EXISTS {table}_isolation ON {table}")
op.drop_index("ix_notification_ws_created", table_name="notification")
op.drop_index("ix_notification_ws_user_read", table_name="notification")
op.drop_table("notification")
op.drop_index("ix_item_link_ws_to", table_name="item_link")
op.drop_index("ix_item_link_ws_from", table_name="item_link")
op.drop_table("item_link")
op.drop_index("ix_ticket_ws_requester", table_name="ticket_ext")
op.drop_index("ix_ticket_ws_assignee", table_name="ticket_ext")
op.drop_table("ticket_ext")
statuses = ",".join(f"'{s}'" for s in TASK_STATUSES)
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
op.execute(
f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))"
)
_POLICY_STATEMENTS: tuple[str, ...] = tuple(
statement
for table in TENANT_TABLES_0003
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_0003)} TO {APP_ROLE};",
)
+34 -46
View File
@@ -28,7 +28,9 @@ from ..domain.task import (
)
from ..errors import AppError, ErrorCode
from ..models_task import Activity, Comment, Item, ItemTag, Tag
from ..models_ticket import TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tag_out, tags_of
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
@@ -76,36 +78,20 @@ class TagIn(BaseModel):
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,
}
# ── 序列化(与工单接口共用一份,两份拼装迟早会不一致)─────────────────────
_tag_out = tag_out
_item_out = item_out
# ── 内部工具 ────────────────────────────────────────────────────────────
async def _load_item(session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID) -> Item:
async def _load_item(
session: AsyncSession,
ws_id: uuid.UUID,
item_id: uuid.UUID,
kinds: tuple[str, ...] = ("task",),
) -> Item:
item = await session.get(Item, item_id)
if item is None or item.workspace_id != ws_id or item.kind != "task":
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
# 跨租户 / 不存在 / 不是任务,对外一律"不存在",不泄漏别人有没有这条数据
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
return item
@@ -114,20 +100,7 @@ async def _load_item(session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
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
return await tags_of(session, ws_id, item_ids)
def _normalize_tag_names(names: list[str]) -> list[str]:
@@ -415,9 +388,9 @@ async def board(
)
@router.get("/{item_id}", summary="任务详情(含最近 50 条事件)")
@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)
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
tags = await _tags_of(session, ctx.ws_id, [item.id])
activities = (
(
@@ -442,6 +415,20 @@ async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> d
}
for a in activities
]
if item.kind == "ticket":
ext = await session.get(TicketExt, item.id)
if ext is not None:
data["ticket"] = {
"requester_id": str(ext.requester_id),
"assignee_id": str(ext.assignee_id),
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
"accepted_at": ext.accepted_at,
"submitted_at": ext.submitted_at,
"verified_at": ext.verified_at,
"verified_by": str(ext.verified_by) if ext.verified_by else None,
"reject_count": ext.reject_count,
"resolution_md": ext.resolution_md,
}
return ok(data)
@@ -556,11 +543,12 @@ async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep)
)
@router.patch("/{item_id}", summary="修改任务字段(乐观锁)")
@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)
# 工单也能改标题/优先级/截止/标签;状态必须走 /ticket/* 的动作接口
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
values: dict[str, Any] = {}
changes: dict[str, Any] = {}
@@ -662,7 +650,7 @@ async def delete_item(
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)
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
comment = Comment(
workspace_id=ctx.ws_id,
item_id=item.id,
@@ -686,7 +674,7 @@ async def add_comment(
@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)
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
rows = (
(
await session.execute(
+763
View File
@@ -0,0 +1,763 @@
"""工单域接口(M2):升级、指派、办理、验收、回流、通知、事项关系。
一段流程长什么样:
任务卡住了
-> POST /items/{task_id}/promote {assignee_id} 任务变 blocked,生成一条工单(open)
-> 被指派人 POST /items/{ticket_id}/ticket/accept accepted
-> POST /items/{ticket_id}/ticket/start in_progress
-> POST /items/{ticket_id}/ticket/submit pending_verify(提需求的人收到通知)
-> POST /items/{ticket_id}/ticket/verify {approve} done,原任务自动 blocked -> todo
驳回走同一个入口:verify {approve: false, note: "..."} -> 回到 in_progress,reject_count + 1。
每一步都写 activity;通知只是提醒,状态永远以 item 为准。
"""
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, and_, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record
from ..deps import MemberDep, SessionDep, TenantCtx
from ..domain.ticket import (
CANCELABLE,
NOW_FIELD,
ensure_can_act,
returned_task_status,
target_status,
)
from ..errors import AppError, ErrorCode
from ..models import Member
from ..models_task import Activity, Item
from ..models_ticket import ItemLink, Notification, TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tags_of
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
MAX_REJECT_NOTE = 500
# -- 入参 ---------------------------------------------------------------
class PromoteIn(BaseModel):
assignee_id: uuid.UUID
title: str | None = Field(default=None, max_length=200)
body_md: str | None = Field(default=None, max_length=20000)
due_at: datetime | None = None
priority: int | None = Field(default=None, ge=0, le=3)
version: int | None = Field(default=None, description="原任务的版本,给了就做乐观锁")
class ReassignIn(BaseModel):
assignee_id: uuid.UUID
class VerifyIn(BaseModel):
approve: bool
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
resolution_md: str = Field(
default="", max_length=20000, description="验收通过时写的结论,M3 用它长知识"
)
class ActionIn(BaseModel):
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
version: int | None = None
class LinkIn(BaseModel):
to_item_id: uuid.UUID
link_type: str = "related"
# -- 内部工具 -----------------------------------------------------------
async def _load_item(
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, kinds: tuple[str, ...]
) -> Item:
item = await session.get(Item, item_id)
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
# 跨租户 / 不存在 / 类型不符:对外一律"不存在"
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
if item.deleted_at is not None:
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
return item
async def _load_ext(session: AsyncSession, item_id: uuid.UUID) -> TicketExt:
ext = await session.get(TicketExt, item_id)
if ext is None:
raise AppError(ErrorCode.TICKET_NOT_FOUND, "这不是一条工单")
return ext
async def _active_member_id(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
exists = (
await session.execute(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
).first()
if exists is None:
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
async def _log(
session: AsyncSession,
item: Item,
actor_id: uuid.UUID | None,
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 _notify(
session: AsyncSession,
*,
ws_id: uuid.UUID,
user_id: uuid.UUID,
kind: str,
item_id: uuid.UUID | None,
title: str,
body: str = "",
) -> None:
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
)
def _ticket_ext_out(ext: TicketExt) -> dict[str, Any]:
return {
"requester_id": str(ext.requester_id),
"assignee_id": str(ext.assignee_id),
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
"accepted_at": ext.accepted_at,
"submitted_at": ext.submitted_at,
"verified_at": ext.verified_at,
"verified_by": str(ext.verified_by) if ext.verified_by else None,
"reject_count": ext.reject_count,
"resolution_md": ext.resolution_md,
}
async def _item_out(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
"""单条序列化。标签必须一起带上——前端把 `item.tags` 当数组用,
少这个字段整页会白屏(M2 首版就是这么炸的)。"""
grouped = await tags_of(session, ws_id, [item.id])
return item_out(item, grouped.get(item.id))
async def _items_out(
session: AsyncSession, ws_id: uuid.UUID, items: list[Item]
) -> list[dict[str, Any]]:
grouped = await tags_of(session, ws_id, [i.id for i in items])
return [item_out(i, grouped.get(i.id)) for i in items]
async def _ticket_out(
session: AsyncSession, ws_id: uuid.UUID, ticket: Item, ext: TicketExt
) -> dict:
return ok({**await _item_out(session, ws_id, ticket), "ticket": _ticket_ext_out(ext)})
async def _patch_item(
session: AsyncSession,
item: Item,
values: dict[str, Any],
*,
expect_version: int | None,
) -> None:
"""带版本的 UPDATE:给了 version 就必须匹配,否则 409 让客户端刷新。"""
where = [Item.id == item.id, Item.workspace_id == item.workspace_id]
if expect_version is not None:
where.append(Item.version == expect_version)
result = await session.execute(
update(Item)
.where(*where)
.values(**values, version=Item.version + 1, updated_at=datetime.now(timezone.utc))
)
if result.rowcount == 0:
await session.refresh(item)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这条工单刚被改过,请刷新后重试",
details={"current_version": item.version},
)
await session.refresh(item)
# -- 升级 ---------------------------------------------------------------
@router.post("/items/{item_id}/promote", summary="把任务升级成工单")
async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session: SessionDep) -> dict:
task = await _load_item(session, ctx.ws_id, item_id, ("task",))
if task.status in ("done", "canceled"):
raise AppError(
ErrorCode.ITEM_STATUS_INVALID,
f"「{task.status}」的任务不能再升级成工单",
details={"status": task.status},
)
existing = (
await session.execute(
select(TicketExt.item_id).where(
TicketExt.workspace_id == ctx.ws_id, TicketExt.parent_item_id == task.id
)
)
).first()
if existing is not None:
raise AppError(ErrorCode.TICKET_ALREADY_EXISTS, "这条任务已经升级过工单了")
await _active_member_id(session, ctx.ws_id, body.assignee_id)
ticket = Item(
workspace_id=ctx.ws_id,
kind="ticket",
title=(body.title or task.title)[:200],
body_md=body.body_md if body.body_md is not None else task.body_md,
status="open",
priority=body.priority if body.priority is not None else task.priority,
creator_id=ctx.user.id,
assignee_id=body.assignee_id,
due_at=body.due_at or task.due_at,
)
session.add(ticket)
await session.flush()
ext = TicketExt(
item_id=ticket.id,
workspace_id=ctx.ws_id,
requester_id=ctx.user.id,
assignee_id=body.assignee_id,
parent_item_id=task.id,
)
session.add(ext)
await session.flush()
# 原任务确实卡住了,进 blocked;不能静默把用户的待办改成别的状态
await _patch_item(session, task, {"status": "blocked"}, expect_version=body.version)
await _log(session, task, ctx.user.id, "promoted_to_ticket", {"ticket_id": str(ticket.id)})
await _log(session, ticket, ctx.user.id, "ticket_created", {"parent_item_id": str(task.id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"你被指派了一条工单:{ticket.title}",
body=f"{ctx.user.nickname or '同事'} 请你处理,完成后提交验收",
)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="ticket.promote",
target_type="item",
target_id=str(ticket.id),
payload={"parent_item_id": str(task.id)},
)
await session.flush()
task_out, ticket_out = await _items_out(session, ctx.ws_id, [task, ticket])
return ok({"task": task_out, "ticket": {**ticket_out, "ticket": _ticket_ext_out(ext)}})
# -- 办理/验收 ----------------------------------------------------------
async def _advance(
ctx: TenantCtx,
session: AsyncSession,
item_id: uuid.UUID,
*,
action: str,
note: str = "",
approve: bool | None = None,
resolution_md: str = "",
version: int | None = None,
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
target = target_status(action, ticket.status)
ensure_can_act(
action=action,
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
now = datetime.now(timezone.utc)
previous = ticket.status
values: dict[str, Any] = {"status": target}
if target == "done":
values["completed_at"] = now
ext.verified_at = now
ext.verified_by = ctx.user.id
if resolution_md:
ext.resolution_md = resolution_md
if target == "in_progress" and action == "reject":
ext.reject_count = ext.reject_count + 1
stamp_field = NOW_FIELD.get(target)
if stamp_field and action != "reject":
setattr(ext, stamp_field, now)
await _patch_item(session, ticket, values, expect_version=version)
payload: dict[str, Any] = {"action": action, "from": previous, "to": target}
if note:
payload["note"] = note
await _log(session, ticket, ctx.user.id, f"ticket_{action}", payload)
# 通知对家:验收找提需求的人,其余找办理人
if action == "submit":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.requester_id,
kind="ticket_submitted",
item_id=ticket.id,
title=f"工单待你验收:{ticket.title}",
body=note or "办理人已提交,请确认结果",
)
elif action == "approve":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_approved",
item_id=ticket.id,
title=f"工单已通过验收:{ticket.title}",
body=note or "谢谢,这事结了",
)
elif action == "reject":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_rejected",
item_id=ticket.id,
title=f"工单被退回:{ticket.title}",
body=note or "请按反馈再改一版",
)
# 验收通过 -> 原任务闭环回流
returned = returned_task_status(target)
if returned and ext.parent_item_id:
parent = await session.get(Item, ext.parent_item_id)
if parent is not None and parent.workspace_id == ctx.ws_id and parent.status == "blocked":
parent.status = returned
parent.version += 1
parent.updated_at = now
await _log(
session,
parent,
ctx.user.id,
"ticket_done",
{"ticket_id": str(ticket.id), "status": returned},
)
await _notify(
session,
ws_id=ctx.ws_id,
user_id=parent.creator_id,
kind="task_unblocked",
item_id=parent.id,
title=f"任务可以继续了:{parent.title}",
body="它对应的工单已验收通过",
)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
@router.post("/items/{item_id}/ticket/accept", summary="接受工单")
async def accept(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="accept")
@router.post("/items/{item_id}/ticket/start", summary="开始办理")
async def start(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="start")
@router.post("/items/{item_id}/ticket/submit", summary="提交验收")
async def submit(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="submit", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/verify", summary="验收:通过或驳回")
async def verify(item_id: uuid.UUID, body: VerifyIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx,
session,
item_id,
action="approve" if body.approve else "reject",
note=body.note,
approve=body.approve,
resolution_md=body.resolution_md,
)
@router.post("/items/{item_id}/ticket/cancel", summary="取消工单")
async def cancel(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="cancel", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/reassign", summary="改派给同事")
async def reassign(
item_id: uuid.UUID, body: ReassignIn, ctx: MemberDep, session: SessionDep
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
ensure_can_act(
action="reassign",
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
if ticket.status not in CANCELABLE:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"「{ticket.status}」的工单不能改派",
details={"allowed_from": list(CANCELABLE)},
)
if body.assignee_id == ext.assignee_id:
return await _ticket_out(session, ctx.ws_id, ticket, ext)
await _active_member_id(session, ctx.ws_id, body.assignee_id)
await _patch_item(
session,
ticket,
{"status": "open", "assignee_id": body.assignee_id, "completed_at": None},
expect_version=None,
)
ext.assignee_id = body.assignee_id
ext.accepted_at = None
ext.submitted_at = None
await _log(session, ticket, ctx.user.id, "ticket_reassigned", {"to": str(body.assignee_id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"转给你一条工单:{ticket.title}",
body="请先接收,再开始办理",
)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
# -- 工单列表 -----------------------------------------------------------
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
async def list_tickets(
ctx: MemberDep,
session: SessionDep,
role: Annotated[str, Query(description="assigned|requested|all")] = "assigned",
status: Annotated[list[str] | None, Query(description="按状态过滤,可重复")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt: Select = (
select(Item, TicketExt)
.join(TicketExt, TicketExt.item_id == Item.id)
.where(Item.workspace_id == ctx.ws_id, Item.kind == "ticket", Item.deleted_at.is_(None))
)
if role == "assigned":
stmt = stmt.where(TicketExt.assignee_id == ctx.user.id)
elif role == "requested":
stmt = stmt.where(TicketExt.requester_id == ctx.user.id)
elif role != "all":
raise AppError(ErrorCode.BAD_REQUEST, "role 只能是 assigned / requested / all")
if status:
stmt = stmt.where(Item.status.in_(status))
stmt = stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Item.updated_at < parsed_cursor.created_at,
and_(Item.updated_at == parsed_cursor.created_at, Item.id < parsed_cursor.id),
)
)
rows = (await session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
next_cursor = (
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
)
tickets = await _items_out(session, ctx.ws_id, [item for item, _ in rows])
return ok(
{
"tickets": [
{**out, "ticket": _ticket_ext_out(ext)}
for out, (_, ext) in zip(tickets, rows, strict=True)
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
# -- 事项关系(子任务 / 相关)------------------------------------------
@router.get("/items/{item_id}/links", summary="这条事项的子项与相关项")
async def list_links(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
# parent 边的方向:from 是 to 的子项。所以"我的子项"= 指向我、且类型为 parent 的 from。
children = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.from_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
parents = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.to_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.from_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
related = (
await session.execute(
select(ItemLink, Item)
.join(
Item,
or_(
and_(ItemLink.from_item_id == item_id, Item.id == ItemLink.to_item_id),
and_(ItemLink.to_item_id == item_id, Item.id == ItemLink.from_item_id),
),
)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.link_type == "related",
or_(ItemLink.from_item_id == item_id, ItemLink.to_item_id == item_id),
)
)
).all()
async def _pack(rows: list[Any]) -> list[dict[str, Any]]:
packed = await _items_out(session, ctx.ws_id, [item for _, item in rows])
return [
{"link_id": str(link.id), "link_type": link.link_type, "item": out}
for (link, _), out in zip(rows, packed, strict=True)
]
return ok(
{
"children": await _pack(children),
"parents": await _pack(parents),
"related": await _pack(related),
}
)
@router.post("/items/{item_id}/links", summary="把另一条事项挂成子项/相关项")
async def create_link(
item_id: uuid.UUID, body: LinkIn, ctx: MemberDep, session: SessionDep
) -> dict:
if body.link_type not in ("parent", "related"):
raise AppError(ErrorCode.BAD_REQUEST, "link_type 只能是 parent / related")
if body.to_item_id == item_id:
raise AppError(ErrorCode.LINK_CONFLICT, "不能把自己挂到自己下面")
await _load_item(session, ctx.ws_id, body.to_item_id, ("task", "ticket"))
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
existing = (
await session.execute(
select(ItemLink).where(
ItemLink.from_item_id == body.to_item_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == body.link_type,
)
)
).scalar_one_or_none()
if existing is not None:
raise AppError(ErrorCode.LINK_CONFLICT, "这条关系已经存在")
link = ItemLink(
workspace_id=ctx.ws_id,
from_item_id=body.to_item_id,
to_item_id=item_id,
link_type=body.link_type,
)
session.add(link)
await session.flush()
return ok({"id": str(link.id), "link_type": link.link_type})
@router.delete("/items/{item_id}/links/{link_id}", summary="解除关系")
async def delete_link(
item_id: uuid.UUID, link_id: uuid.UUID, ctx: MemberDep, session: SessionDep
) -> dict:
link = await session.get(ItemLink, link_id)
if (
link is None
or link.workspace_id != ctx.ws_id
or item_id
not in (
link.from_item_id,
link.to_item_id,
)
):
raise AppError(ErrorCode.LINK_NOT_FOUND, "关系不存在")
await session.delete(link)
return ok({"deleted": True})
# -- 通知 ---------------------------------------------------------------
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
return {
"id": str(n.id),
"kind": n.kind,
"item_id": str(n.item_id) if n.item_id else None,
"item_title": item_title,
"title": n.title,
"body": n.body,
"read_at": n.read_at,
"created_at": n.created_at,
}
@notify_router.get("", summary="我的通知(默认只看未读)")
async def list_notifications(
ctx: MemberDep,
session: SessionDep,
unread_only: bool = True,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt = select(Notification).where(
Notification.workspace_id == ctx.ws_id, Notification.user_id == ctx.user.id
)
if unread_only:
stmt = stmt.where(Notification.read_at.is_(None))
stmt = stmt.order_by(Notification.created_at.desc(), Notification.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Notification.created_at < parsed_cursor.created_at,
and_(
Notification.created_at == parsed_cursor.created_at,
Notification.id < parsed_cursor.id,
),
)
)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = list(rows[:limit])
titles: dict[uuid.UUID, str] = {}
item_ids = [n.item_id for n in rows if n.item_id]
if item_ids:
for item_id, title in (
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
).all():
titles[item_id] = title
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
return ok(
{
"notifications": [
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
@notify_router.get("/unread-count", summary="未读数(导航角标用)")
async def unread_count(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import func as sa_func
total = (
await session.execute(
select(sa_func.count(Notification.id)).where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
)
).scalar_one()
return ok({"unread": int(total)})
@notify_router.post("/{notification_id}/read", summary="标记已读")
async def mark_read(notification_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
n = await session.get(Notification, notification_id)
if n is None or n.workspace_id != ctx.ws_id or n.user_id != ctx.user.id:
raise AppError(ErrorCode.NOTIFICATION_NOT_FOUND, "通知不存在")
if n.read_at is None:
n.read_at = datetime.now(timezone.utc)
return ok({"read_at": n.read_at})
@notify_router.post("/read-all", summary="全部标记已读")
async def mark_all_read(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import update as sa_update
result = await session.execute(
sa_update(Notification)
.where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
return ok({"marked": result.rowcount})
__all__ = ["notify_router", "router"]
+138
View File
@@ -0,0 +1,138 @@
"""工单状态机与权限判定(纯函数,不碰 ORM 以外的世界)。
状态机:
open ──accept──▶ accepted ──start──▶ in_progress ──submit──▶ pending_verify
▲ │
└────────reject────────────┘
│approve
▼
done
`canceled` 可以从任何未终结状态进入;`done` 与 `canceled` 是终态。
两条刻意的规则:
1. **驳回不是打回 open,而是回 in_progress**。办理人已经在做了,让他重新"认领"一次纯属折腾;
驳回次数单独计数(`reject_count`),用来提示"这事返工过"。
2. **谁也不能替别人验收**,除了管理员/所有者。提需求的人验收是这套流程的核心信任点,
如果办理人能自己点"通过",工单就退化成"打个勾"。
"""
from __future__ import annotations
import uuid
from ..errors import AppError, ErrorCode
TICKET_TRANSITIONS: dict[str, tuple[str, ...]] = {
"open": ("accepted", "canceled"),
"accepted": ("in_progress", "canceled"),
"in_progress": ("pending_verify", "canceled"),
"pending_verify": ("done", "in_progress", "canceled"),
"done": (),
"canceled": (),
}
# 动作 → (允许的前置状态, 目标状态);cancel 特殊处理
ACTION_RULES: dict[str, tuple[tuple[str, ...], str]] = {
"accept": (("open",), "accepted"),
"start": (("accepted",), "in_progress"),
"submit": (("in_progress",), "pending_verify"),
"approve": (("pending_verify",), "done"),
"reject": (("pending_verify",), "in_progress"),
}
CANCELABLE = ("open", "accepted", "in_progress", "pending_verify")
NOW_FIELD: dict[str, str] = {
"accepted": "accepted_at",
"pending_verify": "submitted_at",
"done": "verified_at",
}
def ensure_transition(current: str, target: str) -> None:
allowed = TICKET_TRANSITIONS.get(current, ())
if target not in allowed:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"工单当前是「{current}」,不能直接变成「{target}」",
details={"from": current, "to": target, "allowed": list(allowed)},
)
def target_status(action: str, current: str) -> str:
"""动作 → 目标状态,顺带校验前置状态。"""
if action == "cancel":
if current not in CANCELABLE:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"「{current}」的工单不能取消",
details={"from": current, "allowed": list(CANCELABLE)},
)
return "canceled"
if action not in ACTION_RULES:
raise AppError(ErrorCode.BAD_REQUEST, f"未知动作:{action}")
allowed_from, target = ACTION_RULES[action]
if current not in allowed_from:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"工单当前是「{current}」,不能执行「{action}」",
# allowed 回答"现在还能去哪儿",allowed_from 回答"这个动作要什么前置状态":
# 客户端两种都要用得上,别让它自己拼。
details={
"from": current,
"action": action,
"allowed": list(TICKET_TRANSITIONS.get(current, ())),
"allowed_from": list(allowed_from),
},
)
return target
def ensure_can_act(
*,
action: str,
actor_id: uuid.UUID,
role: str,
assignee_id: uuid.UUID | None,
requester_id: uuid.UUID | None,
) -> None:
"""谁能做这个动作。
管理员/所有者可以代任何人操作(人休假、离职时的兜底),
普通成员只能动自己的位置上的事。
"""
is_admin = role in ("owner", "admin")
is_assignee = assignee_id is not None and actor_id == assignee_id
is_requester = requester_id is not None and actor_id == requester_id
if action in ("accept", "start", "submit"):
allowed = is_assignee or is_admin
hint = "只有被指派的人才能操作这条工单"
elif action in ("approve", "reject"):
allowed = is_requester or is_admin
hint = "只有提出这条工单的人(或管理员)才能验收"
elif action == "cancel":
allowed = is_requester or is_assignee or is_admin
hint = "只有提出人、办理人或管理员能取消"
else:
allowed = is_admin or is_requester
hint = "权限不足"
if not allowed:
raise AppError(ErrorCode.TICKET_FORBIDDEN, hint, details={"action": action})
def returned_task_status(ticket_status: str) -> str | None:
"""工单状态变化后,原任务该回到什么状态;None 表示不动。
闭环回流只发生在验收通过那一刻——这是"事情真的办完了"的唯一信号。
取消不回填:取消意味着不办了,原任务继续躺在 blocked 里等人处理才对。
"""
if ticket_status == "done":
return "todo"
return None
+16
View File
@@ -40,6 +40,15 @@ class ErrorCode(IntEnum):
ITEM_NOT_FOUND = 5001
ITEM_STATUS_INVALID = 5002
TAG_NOT_FOUND = 5003
LINK_NOT_FOUND = 5004
LINK_CONFLICT = 5005
# 6xxx 工单域
TICKET_NOT_FOUND = 6001
TICKET_ALREADY_EXISTS = 6002
TICKET_STATUS_INVALID = 6003
TICKET_FORBIDDEN = 6004
NOTIFICATION_NOT_FOUND = 6005
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
@@ -67,6 +76,13 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.ITEM_NOT_FOUND: 404,
ErrorCode.ITEM_STATUS_INVALID: 409,
ErrorCode.TAG_NOT_FOUND: 404,
ErrorCode.LINK_NOT_FOUND: 404,
ErrorCode.LINK_CONFLICT: 409,
ErrorCode.TICKET_NOT_FOUND: 404,
ErrorCode.TICKET_ALREADY_EXISTS: 409,
ErrorCode.TICKET_STATUS_INVALID: 409,
ErrorCode.TICKET_FORBIDDEN: 403,
ErrorCode.NOTIFICATION_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, items, workspaces
from .api import auth, items, tickets, workspaces
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -102,6 +102,8 @@ 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.include_router(tickets.router, prefix=settings.api_prefix)
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+4 -2
View File
@@ -133,9 +133,10 @@ class AuditLog(Base):
# 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加)
#
# 任务域的表在 models_task.py —— 它 import 本模块的 Base,所以只能在文件末尾反向 import,
# 否则循环导入。
# 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base,
# 所以只能在文件末尾反向 import,否则循环导入。
from .models_task import TASK_TENANT_TABLES # noqa: E402
from .models_ticket import TICKET_TENANT_TABLES # noqa: E402
TENANT_TABLES: tuple[str, ...] = (
"workspace",
@@ -143,4 +144,5 @@ TENANT_TABLES: tuple[str, ...] = (
"invitation",
"audit_log",
*TASK_TENANT_TABLES,
*TICKET_TENANT_TABLES,
)
+145
View File
@@ -0,0 +1,145 @@
"""工单域模型(M2):ticket_ext / item_link / notification。
三条设计约束(对应 PRODUCT-PLAN 的 D1/D2):
1. **工单不是新表,是 `item` 的一个 kind**。标题、正文、截止、标签、评论、事件流
全部复用,M1 写的每一处代码对工单依然成立;`ticket_ext` 只补"谁请谁、验没验收"。
2. **升级不是搬家**。原任务留在原地并进入 `blocked`(它确实被卡住了),工单是**新建**的一条
item,通过 `TicketExt.parent_item_id` 指回去。工单验收通过时把原任务从 blocked 拉回 todo,
这就是"闭环回流"——任务那条时间线完整保留,历史不被搬来搬去。
3. **通知只记录"发生了什么",不做已读以外的状态机**。通知看错、漏看是常态,
真正的状态永远以 item 为准,通知只是提醒,不能变成第二份真相。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column
from .models import Base, Timestamps, new_uuid
# 工单状态机(与任务共用 item.status 这一列,但取值集合不同)
TICKET_STATUSES: tuple[str, ...] = (
"open",
"accepted",
"in_progress",
"pending_verify",
"done",
"canceled",
)
LINK_TYPES: tuple[str, ...] = ("parent", "related")
class TicketExt(Base, Timestamps):
"""工单的附加信息,与 item 一比一。"""
__tablename__ = "ticket_ext"
__table_args__ = (
Index("ix_ticket_ws_assignee", "workspace_id", "assignee_id"),
Index("ix_ticket_ws_requester", "workspace_id", "requester_id"),
CheckConstraint("reject_count >= 0", name="ck_ticket_reject_count"),
)
# item_id 既是主键又是外键:一条 item 最多一条 ticket_ext,天然 1:1
item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
# 谁提的这件事(下单的人)
requester_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 谁在办(冗余一份,免得每次查工单都要回 item 表;两处必须一致,改指派时同时写)
assignee_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 从哪条任务升级来的;独立创建的工单为空
parent_item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
verified_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
# 被驳回次数:不是为了考核,是为了让提需求的人知道"这事返工过几次"
reject_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 办完的结论:写什么由办理人决定,M3 从这里长知识草稿
resolution_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
class ItemLink(Base, Timestamps):
"""事项之间的关系。M2 只做两种:父子(子任务)与相关。
`from_item_id → to_item_id` 是有方向的:parent 表示 from 是 to 的子项。
"""
__tablename__ = "item_link"
__table_args__ = (
UniqueConstraint("from_item_id", "to_item_id", "link_type", name="uq_item_link"),
CheckConstraint("from_item_id <> to_item_id", name="ck_item_link_not_self"),
CheckConstraint("link_type IN ('parent','related')", name="ck_item_link_type"),
Index("ix_item_link_ws_from", "workspace_id", "from_item_id"),
Index("ix_item_link_ws_to", "workspace_id", "to_item_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
)
from_item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
to_item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
link_type: Mapped[str] = mapped_column(String(16), nullable=False, default="related")
class Notification(Base, Timestamps):
"""站内通知。收件人维度,不是工作区里的公共消息流。"""
__tablename__ = "notification"
__table_args__ = (
Index("ix_notification_ws_user_read", "workspace_id", "user_id", "read_at"),
Index("ix_notification_ws_created", "workspace_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
)
user_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE")
)
title: Mapped[str] = mapped_column(String(120), nullable=False)
body: Mapped[str] = mapped_column(String(300), nullable=False, default="")
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
TICKET_TENANT_TABLES: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
+61
View File
@@ -0,0 +1,61 @@
"""统一的 item / tag 序列化。
为什么要单独一份:工单接口最初自己拼了一份 dict,忘了 `tags` 字段,
前端列表把 `item.tags` 当数组用,一升级成工单就整页白屏。
任何返回 item 的接口都必须用这里的 `item_out`,并用 `tags_of` 把标签一起取回来。
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .models_task import Item, ItemTag, Tag
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 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
+11
View File
@@ -55,6 +55,17 @@ async def clean_database(migrated_database: None) -> AsyncIterator[None]:
settings.resolved_migration_database_url(), poolclass=NullPool
)
async with owner_engine.begin() as conn:
# 上一次跑挂掉(Ctrl-C、进程被杀)会留下 idle in transaction 的连接,
# 它握着行锁,TRUNCATE 就会一直等——不是测试慢,是被锁住了。
# 测试库是独占的,直接踢掉除自己以外的会话,再给个 lock_timeout 兜底。
await conn.execute(
text(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = current_database() "
"AND pid <> pg_backend_pid() AND state <> 'idle'"
)
)
await conn.execute(text("SET lock_timeout = '15s'"))
await conn.execute(text(f"TRUNCATE {_TABLES} CASCADE"))
await owner_engine.dispose()
reset_store_for_tests()
+493
View File
@@ -0,0 +1,493 @@
"""M2 工单域测试:升级、指派、办理、验收、回流、权限、通知。
重点不是"能跑通",而是这几条容易做错的:
1. 升级后**原任务必须变 blocked**(否则用户以为这事还在自己手上);
2. 验收通过后**原任务自动回到 todo**(闭环回流,这是整个产品的卖点);
3. 驳回**回 in_progress 而不是 open**,且 `reject_count` 加一;
4. **办理人不能自己验收**(否则工单退化成打勾);
5. 通知发给对的人:提交 → 提需求的人;通过/驳回 → 办理人。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
async def _member(
client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member"
) -> dict[str, Any]:
"""拉一个新人进工作区。"""
user = await login(client)
inv = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"]
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/join",
json={"code": inv["code"]},
headers=user["headers"],
)
)
return user
async def _task(
client: AsyncClient, user: dict[str, Any], ws_id: str, title: str = "整理需求"
) -> dict:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items",
json={"title": title, "status": "todo"},
headers=user["headers"],
)
)
@pytest.mark.asyncio
async def test_promote_creates_ticket_and_blocks_task(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "工单测试")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
data = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
# 原任务:还是任务,但被卡住了
assert data["task"]["id"] == task["id"]
assert data["task"]["status"] == "blocked"
assert data["task"]["kind"] == "task"
# 工单:新的一条 item,指回原任务
ticket = data["ticket"]
assert ticket["id"] != task["id"]
assert ticket["kind"] == "ticket"
assert ticket["status"] == "open"
assert ticket["ticket"]["requester_id"] == owner["user_id"]
assert ticket["ticket"]["assignee_id"] == helper["user_id"]
assert ticket["ticket"]["parent_item_id"] == task["id"]
# 工单不出现在任务列表里(两条流各看各的)
tasks = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items?view=all", headers=owner["headers"])
)
assert [t["id"] for t in tasks["items"]] == [task["id"]]
# 被指派人收到通知
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=helper["headers"])
)
assert [n["kind"] for n in notes["notifications"]] == ["ticket_assigned"]
assert notes["notifications"][0]["item_id"] == ticket["id"]
@pytest.mark.asyncio
async def test_full_flow_reopens_task_on_approval(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "闭环")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "等运维开通权限")
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
base = f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket"
assert (
unwrap(await client.post(f"{base}/accept", headers=helper["headers"]))["status"]
== "accepted"
)
assert (
unwrap(await client.post(f"{base}/start", headers=helper["headers"]))["status"]
== "in_progress"
)
submitted = unwrap(
await client.post(f"{base}/submit", json={"note": "权限已开通"}, headers=helper["headers"])
)
assert submitted["status"] == "pending_verify"
# 提需求的人收到验收通知
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=owner["headers"])
)
assert notes["notifications"][0]["kind"] == "ticket_submitted"
done = unwrap(
await client.post(
f"{base}/verify",
json={"approve": True, "resolution_md": "权限已开通,验证通过"},
headers=owner["headers"],
)
)
assert done["status"] == "done"
assert done["ticket"]["verified_by"] == owner["user_id"]
assert done["ticket"]["resolution_md"] == "权限已开通,验证通过"
# 闭环回流:原任务从 blocked 回到 todo,并且能看到回流事件
reopened = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items/{task['id']}", headers=owner["headers"])
)
assert reopened["status"] == "todo"
assert reopened["completed_at"] is None
actions = [a["action"] for a in reopened["activities"]]
assert "promoted_to_ticket" in actions
assert "ticket_done" in actions
@pytest.mark.asyncio
async def test_reject_goes_back_to_in_progress_and_counts(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "驳回")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
base = f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket"
await client.post(f"{base}/accept", headers=helper["headers"])
await client.post(f"{base}/start", headers=helper["headers"])
await client.post(f"{base}/submit", json={}, headers=helper["headers"])
rejected = unwrap(
await client.post(
f"{base}/verify",
json={"approve": False, "note": "截图里没看到生效"},
headers=owner["headers"],
)
)
assert rejected["status"] == "in_progress"
assert rejected["ticket"]["reject_count"] == 1
assert rejected["ticket"]["verified_at"] is None
# 原任务仍在 blocked(驳回不算办完)
still = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items/{task['id']}", headers=owner["headers"])
)
assert still["status"] == "blocked"
# 办理人收到驳回通知
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=helper["headers"])
)
kinds = [n["kind"] for n in notes["notifications"]]
assert "ticket_rejected" in kinds
@pytest.mark.asyncio
async def test_assignee_cannot_self_approve(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "越权")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
base = f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket"
await client.post(f"{base}/accept", headers=helper["headers"])
await client.post(f"{base}/start", headers=helper["headers"])
await client.post(f"{base}/submit", json={}, headers=helper["headers"])
resp = await client.post(f"{base}/verify", json={"approve": True}, headers=helper["headers"])
assert resp.json()["code"] == 6004 # TICKET_FORBIDDEN
@pytest.mark.asyncio
async def test_illegal_transition_reports_allowed(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "状态机")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/submit",
json={},
headers=helper["headers"],
)
body = resp.json()
assert body["code"] == 6003
assert body["data"]["from"] == "open"
# allowed 回答"现在还能去哪儿",allowed_from 回答"这个动作要什么前置状态"
assert body["data"]["allowed"] == ["accepted", "canceled"]
assert body["data"]["allowed_from"] == ["in_progress"]
@pytest.mark.asyncio
async def test_promote_requires_member_assignee_and_no_double(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "升级校验")
task = await _task(client, owner, ws["id"])
stranger = await login(client) # 不在工作区
bad = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": stranger["user_id"]},
headers=owner["headers"],
)
assert bad.json()["code"] == 1001
helper = await _member(client, owner, ws)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
twice = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
assert twice.json()["code"] == 6002
@pytest.mark.asyncio
async def test_ticket_list_scopes(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "列表")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "等设计稿")
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
mine_as_helper = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets?role=assigned", headers=helper["headers"]
)
)
assert len(mine_as_helper["tickets"]) == 1
assert mine_as_helper["tickets"][0]["title"] == "等设计稿"
requested = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets?role=requested", headers=owner["headers"]
)
)
assert len(requested["tickets"]) == 1
# 只按状态过滤
empty = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets?role=assigned&status=done",
headers=helper["headers"],
)
)
assert empty["tickets"] == []
@pytest.mark.asyncio
async def test_cross_tenant_ticket_invisible(client: AsyncClient) -> None:
alice = await login(client)
bob = await login(client)
ws_a = await create_workspace(client, alice, "A 公司")
ws_b = await create_workspace(client, bob, "B 公司")
helper = await _member(client, alice, ws_a)
task = await _task(client, alice, ws_a["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws_a['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=alice["headers"],
)
)["ticket"]
# 用 B 工作区去访问 A 的工单:一律"不存在",不泄漏存在性
detail = await client.get(
f"{V1}/workspaces/{ws_b['id']}/items/{ticket['id']}", headers=bob["headers"]
)
assert detail.json()["code"] in (5001, 3003)
act = await client.post(
f"{V1}/workspaces/{ws_b['id']}/items/{ticket['id']}/ticket/accept", headers=bob["headers"]
)
assert act.json()["code"] in (5001, 3003)
# A 的工单列表在 B 里是空的
listing = unwrap(
await client.get(f"{V1}/workspaces/{ws_b['id']}/tickets?role=all", headers=bob["headers"])
)
assert listing["tickets"] == []
@pytest.mark.asyncio
async def test_links_parent_child_and_related(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "关系")
parent = await _task(client, owner, ws["id"], "上线新版")
child = await _task(client, owner, ws["id"], "写发布说明")
other = await _task(client, owner, ws["id"], "相关:监控告警")
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links",
json={"to_item_id": child["id"], "link_type": "parent"},
headers=owner["headers"],
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links",
json={"to_item_id": other["id"], "link_type": "related"},
headers=owner["headers"],
)
)
links = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links", headers=owner["headers"]
)
)
assert [c["item"]["id"] for c in links["children"]] == [child["id"]]
assert [r["item"]["id"] for r in links["related"]] == [other["id"]]
assert links["parents"] == []
# 从子项看,父项在 parents 里
from_child = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/items/{child['id']}/links", headers=owner["headers"]
)
)
assert [p["item"]["id"] for p in from_child["parents"]] == [parent["id"]]
# 重复挂同一条关系 → 冲突
dup = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links",
json={"to_item_id": child["id"], "link_type": "parent"},
headers=owner["headers"],
)
assert dup.json()["code"] == 5005
@pytest.mark.asyncio
async def test_notifications_read_flow(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "通知")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 1
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=helper["headers"])
)
note_id = notes["notifications"][0]["id"]
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/notifications/{note_id}/read", headers=helper["headers"]
)
)
after = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert after["unread"] == 0
# 别人的通知看不到(同工作区的另一个人)
other = await _member(client, owner, ws)
stranger_notes = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications?unread_only=false", headers=other["headers"]
)
)
assert stranger_notes["notifications"] == []
@pytest.mark.asyncio
async def test_ticket_payload_always_has_tags(client: AsyncClient) -> None:
"""回归:工单接口曾经自己拼 item 字典,漏了 `tags`,
前端 `item.tags.map(...)` 直接白屏。任何返回 item 的接口都得带 tags 数组。"""
owner = await login(client)
ws = await create_workspace(client, owner, "标签回归")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "带标签的任务")
tagged = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}",
json={"tag_names": ["权限", "运维"], "version": task["version"]},
headers=owner["headers"],
)
)
data = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"], "version": tagged["version"]},
headers=owner["headers"],
)
)
# 工单是独立的一条事项,标签不自动继承;关键是字段必须在、而且必须是数组
assert isinstance(data["task"]["tags"], list)
assert isinstance(data["ticket"]["tags"], list)
assert sorted(t["name"] for t in tagged["tags"]) == ["权限", "运维"]
# 列表与详情也要一致
listing = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/tickets?role=all", headers=owner["headers"])
)
assert isinstance(listing["tickets"][0]["tags"], list)
# 详情接口(任务与工单走同一个入口)都必须带 tags
task_detail = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items/{task['id']}", headers=owner["headers"])
)
assert sorted(t["name"] for t in task_detail["tags"]) == ["权限", "运维"]
ticket_detail = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/items/{data['ticket']['id']}", headers=owner["headers"]
)
)
assert isinstance(ticket_detail["tags"], list)
+2 -1
View File
@@ -76,7 +76,8 @@ try {
await page.getByRole("button", { name: "写周报初稿" }).click();
const drawer = page.locator("aside");
await drawer.waitFor();
await drawer.locator("select").selectOption("2");
// 抽屉里现在有两个 select(优先级 + 升级工单选人),取第一个才是优先级
await drawer.locator("select").first().selectOption("2");
await page.waitForTimeout(500);
const commentBox = drawer.getByPlaceholder("补充一句进展…");
await commentBox.fill("E2E:初稿已出,等评审");
+196
View File
@@ -0,0 +1,196 @@
/**
* M2 工单闭环的真浏览器用例(两个用户,两个浏览器上下文)。
*
* A 建任务 → 升级成工单指给 B → B 接收/开始/提交验收 → A 通过验收 → 原任务回流到待办
*
* 跑之前先起后端(8100)与前端(5180):
* node e2e/tickets.mjs
*/
import { existsSync, mkdirSync } from "node:fs";
import { chromium } from "playwright-core";
const APP = process.env.APP_URL ?? "http://127.0.0.1:5180";
const CHROME =
process.env.CHROME_PATH ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
const SHOT_DIR = new URL("./artifacts/", import.meta.url).pathname.replace(/^\//, "");
if (!existsSync(CHROME)) {
console.error(`找不到 Chromium:${CHROME}\n用 CHROME_PATH 指定可执行文件`);
process.exit(2);
}
mkdirSync(SHOT_DIR, { recursive: true });
let step = 0;
function pass(message) {
console.log(` ✓ ${++step}. ${message}`);
}
function fail(message) {
console.error(` ✗ ${message}`);
process.exit(1);
}
const consoleErrors = [];
function watch(page, tag) {
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(`[${tag}] ${msg.text()}`);
});
page.on("pageerror", (err) => consoleErrors.push(`[${tag}] ${String(err)}`));
page.on("response", (res) => {
if (res.status() >= 400) consoleErrors.push(`[${tag}] HTTP ${res.status()} ${res.url()}`);
});
page.setDefaultTimeout(15_000);
}
async function fillLogin(page, phone) {
await page.locator("#phone").fill(phone);
await page.getByRole("button", { name: "获取验证码" }).click();
const text = await page.locator("text=开发环境验证码").first().innerText();
const code = text.match(/(\d{6})/)?.[1];
if (!code) fail(`没读到开发验证码:${text}`);
await page.locator("#code").fill(code);
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
}
async function login(page, phone) {
await page.goto(`${APP}/login`, { waitUntil: "domcontentloaded" });
await fillLogin(page, phone);
await page.waitForURL(`${APP}/`);
}
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const ctxA = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const ctxB = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
watch(pageA, "A");
watch(pageB, "B");
if (process.env.DEBUG_NET) {
for (const [tag, pg] of [["A", pageA], ["B", pageB]]) {
pg.on("response", async (res) => {
if (!res.url().includes("/api/v1/workspaces/")) return;
const text = await res.text().catch(() => "");
console.log(` [net ${tag}] ${res.status()} ${res.url().replace(APP, "")} ${text.slice(0, 180)}`);
});
}
}
const stamp = String(Date.now()).slice(-8);
const phoneA = `137${stamp}`;
const phoneB = `136${stamp}`;
try {
// 1. A 登录并建工作区
await login(pageA, phoneA);
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill("E2E 工单组");
await pageA.getByRole("button", { name: "创建" }).click();
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}$/);
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})$/)[1];
await pageA.getByRole("heading", { name: "任务" }).waitFor();
pass("A 登录并建工作区");
// 2. A 建一条会卡住的任务(先切到「全部」,因为「今天」只按到期时间算)
const composer = pageA.getByPlaceholder(/加一件要做的事/);
await composer.fill("开通测试账号权限");
await composer.press("Enter");
await pageA.getByRole("button", { name: "全部", exact: true }).click();
await pageA.locator("div.card > div").filter({ hasText: "开通测试账号权限" }).first().waitFor();
pass("A 新建任务「开通测试账号权限」");
// 3. A 生成邀请码,B 落地加入
await pageA.getByRole("link", { name: "成员" }).click();
await pageA.getByRole("button", { name: "邀请成员" }).click();
const inviteText = await pageA.locator("p.break-all").first().innerText();
if (!inviteText.includes("/join/")) fail(`没拿到邀请链接:${inviteText}`);
await pageB.goto(inviteText.trim(), { waitUntil: "domcontentloaded" });
// 未登录会被送到 /login?next=/join/...,登录后要回到邀请页而不是工作区首页
await pageB.waitForURL(/\/login\?next=/);
await fillLogin(pageB, phoneB);
await pageB.waitForURL(new RegExp(`/w/${wsId}`));
await pageB.getByRole("heading", { name: "任务" }).waitFor();
pass("B 从邀请链接登录后自动加入工作区");
// 4. A 把任务升级成工单,指给 B
await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" });
await pageA.getByRole("button", { name: "全部", exact: true }).click();
await pageA.getByRole("button", { name: "开通测试账号权限" }).click();
const drawerA = pageA.locator("aside");
await drawerA.waitFor();
const memberPicker = drawerA.locator("select").nth(1);
await memberPicker.selectOption({ index: 1 });
await drawerA.getByRole("button", { name: "升级成工单" }).click();
await drawerA.locator("text=已把工单交给").first().waitFor();
await pageA.screenshot({ path: `${SHOT_DIR}/promote.png` });
await drawerA.getByRole("button", { name: "关闭", exact: true }).click();
const blockedRow = await pageA
.locator("div.card > div")
.filter({ hasText: "开通测试账号权限" })
.first()
.innerText();
if (!blockedRow.includes("被阻塞")) fail(`升级后任务没变成被阻塞:${blockedRow}`);
pass("任务升级成工单后自身进入「被阻塞」");
// 5. B 在工单页接收 → 开始 → 提交验收
await pageB.goto(`${APP}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
await pageB.getByRole("heading", { name: "工单" }).waitFor();
const ticketCard = pageB.locator("li.card").filter({ hasText: "开通测试账号权限" }).first();
await ticketCard.waitFor();
await ticketCard.getByRole("button", { name: "接收", exact: true }).click();
await ticketCard.getByRole("button", { name: "开始", exact: true }).waitFor();
await ticketCard.getByRole("button", { name: "开始", exact: true }).click();
await ticketCard.getByRole("button", { name: "详情", exact: true }).click();
const drawerB = pageB.locator("aside");
await drawerB.waitFor();
await drawerB.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("已经开了只读账号");
await drawerB.getByRole("button", { name: "提交验收" }).click();
await drawerB.locator("text=待验收").first().waitFor();
await pageB.screenshot({ path: `${SHOT_DIR}/ticket-submitted.png` });
pass("B 接收、开始办理并提交验收");
// 6. A 在「我发起的」里验收通过
await pageA.goto(`${APP}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
await pageA.getByRole("button", { name: "我发起的", exact: true }).click();
const mineCard = pageA.locator("li.card").filter({ hasText: "开通测试账号权限" }).first();
await mineCard.waitFor();
await mineCard.getByRole("button", { name: "详情", exact: true }).click();
const drawerVerify = pageA.locator("aside");
await drawerVerify.waitFor();
await drawerVerify.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("确认可用");
await drawerVerify.getByRole("button", { name: "通过验收" }).click();
await drawerVerify.locator("text=已完成").first().waitFor();
pass("A 验收通过,工单完成");
await pageA.screenshot({ path: `${SHOT_DIR}/ticket-verified.png` });
// 7. 闭环回流:原任务回到待办
await pageA.getByRole("button", { name: "关闭", exact: true }).click();
await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" });
const activeView = pageA.getByRole("button", { name: "进行中" });
await activeView.click();
const reflowed = await pageA
.locator("div.card > div")
.filter({ hasText: "开通测试账号权限" })
.first()
.innerText();
if (!reflowed.includes("待办")) fail(`工单完成后原任务没回流到待办:${reflowed}`);
pass("工单验收通过后,原任务自动回流到「待办」");
if (consoleErrors.length > 0) {
fail(`浏览器控制台有报错:\n${consoleErrors.join("\n")}`);
}
pass("两个上下文控制台均无报错");
console.log(`\n全部通过,截图在 ${SHOT_DIR}`);
await browser.close();
} catch (err) {
await pageA.screenshot({ path: `${SHOT_DIR}/tickets-failure.png` }).catch(() => {});
const drawerText = await pageA.locator("aside").innerText().catch(() => "");
if (drawerText) console.error(`\n[抽屉内容]\n${drawerText}`);
if (consoleErrors.length > 0) console.error(`\n[控制台]\n${consoleErrors.join("\n")}`);
const bodyText = await pageA.locator("body").innerText().catch(() => "");
console.error(`\n[页面文本]\n${bodyText.slice(0, 600)}`);
console.error(`\n失败:${err?.message ?? err}`);
await browser.close();
process.exit(1);
}
+2
View File
@@ -7,6 +7,7 @@ import { HomePage } from "./pages/Home";
import { WorkspacePage } from "./pages/Workspace";
import { TasksPage } from "./pages/Tasks";
import { BoardPage } from "./pages/Board";
import { TicketsPage } from "./pages/Tickets";
import { JoinPage } from "./pages/Join";
interface AuthState {
@@ -84,6 +85,7 @@ export function App() {
<Route path="/" element={<HomePage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+185 -8
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../App";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
import type { Activity, Item, ItemDetail } from "../lib/types";
import type { Activity, Item, ItemDetail, Member, Page, Role, TicketInfo } from "../lib/types";
interface Comment {
id: string;
@@ -10,12 +11,16 @@ interface Comment {
created_at: string;
}
type TicketAction = "accept" | "start" | "submit" | "cancel" | "reassign" | "verify";
interface Props {
wsId: string;
itemId: string;
onClose: () => void;
onChanged: (item: Item) => void;
onDeleted: (id: string) => void;
/** 工单状态变化后通知父级刷新角标 */
onTicketChanged?: () => void;
}
function toLocalInput(iso: string | null): string {
@@ -25,7 +30,15 @@ function toLocalInput(iso: string | null): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Props) {
export function TaskDetail({
wsId,
itemId,
onClose,
onChanged,
onDeleted,
onTicketChanged,
}: Props) {
const { user } = useAuth();
const [item, setItem] = useState<ItemDetail | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
@@ -34,9 +47,16 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
const [comments, setComments] = useState<Comment[]>([]);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [role, setRole] = useState<Role>("member");
const [members, setMembers] = useState<Member[]>([]);
const [promoteTo, setPromoteTo] = useState("");
const [note, setNote] = useState("");
const base = `/workspaces/${wsId}/items/${itemId}`;
const isAdmin = role === "owner" || role === "admin";
const hydrate = useCallback((next: ItemDetail) => {
setItem(next);
@@ -54,6 +74,13 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
if (alive) hydrate(detail);
const page = await api.get<{ items: Comment[] }>(`${base}/comments`);
if (alive) setComments(page.items);
const ws = await api.get<{ role: Role }>(`/workspaces/${wsId}`);
if (alive) setRole(ws.role);
const roster = await api.get<Page<Member>>(`/workspaces/${wsId}/members`);
if (alive) {
setMembers(roster.items);
setPromoteTo(roster.items.find((m) => m.user_id !== user?.id)?.user_id ?? "");
}
} catch (err) {
if (alive) setError(err instanceof ApiError ? err.message : "加载失败");
}
@@ -61,12 +88,9 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
return () => {
alive = false;
};
}, [base, hydrate]);
}, [base, hydrate, wsId, user?.id]);
async function patch(
values: Record<string, unknown>,
label: string,
): Promise<boolean> {
async function patch(values: Record<string, unknown>, label: string): Promise<boolean> {
if (!item) return false;
setBusy(true);
setError(null);
@@ -154,6 +178,98 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
}
}
/** 任务 -> 工单。成功后原任务变「被阻塞」,这条抽屉继续显示任务本身。 */
async function promote() {
if (!item || !promoteTo) return;
setBusy(true);
setError(null);
try {
const result = await api.post<{ task: Item; ticket: Item }>(`${base}/promote`, {
assignee_id: promoteTo,
version: item.version,
});
onChanged(result.task);
hydrate({ ...(item as ItemDetail), ...result.task, activities: item.activities });
void refreshActivities(result.task);
const who = members.find((m) => m.user_id === promoteTo)?.nickname ?? "同事";
setNotice(`已把工单交给 ${who},这条任务现在处于「被阻塞」;对方验收通过后会自动回来。`);
onTicketChanged?.();
} catch (err) {
setError(err instanceof ApiError ? err.message : "升级失败");
} finally {
setBusy(false);
}
}
async function ticketAction(action: TicketAction, extra: Record<string, unknown> = {}) {
if (!item) return;
setBusy(true);
setError(null);
try {
const updated = await api.post<Item & { ticket: TicketInfo }>(
`${base}/ticket/${action}`,
{ note, version: item.version, ...extra },
);
onChanged(updated);
hydrate({ ...(item as ItemDetail), ...updated, activities: item.activities });
void refreshActivities(updated);
setNote("");
onTicketChanged?.();
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("这条工单刚被改过,已刷新为最新内容");
hydrate(await api.get<ItemDetail>(base));
} else {
setError(err instanceof ApiError ? err.message : "操作失败");
}
} finally {
setBusy(false);
}
}
const ticket = item?.kind === "ticket" ? item.ticket : undefined;
const isAssignee = !!ticket && !!user && ticket.assignee_id === user.id;
const isRequester = !!ticket && !!user && ticket.requester_id === user.id;
function actionButtons(t: TicketInfo) {
const buttons: { key: TicketAction | "approve" | "reject"; label: string; show: boolean }[] = [
{ key: "accept", label: "接收", show: t && isAssignee && item?.status === "open" },
{ key: "start", label: "开始办理", show: isAssignee && item?.status === "accepted" },
{ key: "submit", label: "提交验收", show: isAssignee && item?.status === "in_progress" },
{ key: "approve", label: "通过验收", show: isRequester || isAdmin },
{ key: "reject", label: "驳回", show: isRequester || isAdmin },
{
key: "cancel",
label: "取消工单",
show:
(isRequester || isAssignee || isAdmin) &&
["open", "accepted", "in_progress", "pending_verify"].includes(item?.status ?? ""),
},
];
const verifyOnly = item?.status !== "pending_verify";
return (
<div className="flex flex-wrap gap-2">
{buttons
.filter((b) => b.show && !(verifyOnly && (b.key === "approve" || b.key === "reject")))
.map((b) => (
<button
key={b.key}
className={b.key === "cancel" || b.key === "reject" ? "btn-ghost" : "btn-primary"}
disabled={busy}
onClick={() => {
if (b.key === "approve") void ticketAction("verify", { approve: true });
else if (b.key === "reject") void ticketAction("verify", { approve: false });
else void ticketAction(b.key);
}}
>
{b.label}
</button>
))}
</div>
);
}
return (
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
<aside
@@ -166,6 +282,7 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
<>
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
<span>
{item.kind === "ticket" ? "工单 · " : ""}
{STATUS_LABEL[item.status]} · 版本 {item.version}
</span>
<div className="flex gap-2">
@@ -178,6 +295,33 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
</div>
</div>
{ticket && (
<div className="mb-4 rounded-lg border border-ink-100 bg-ink-50 p-3 text-xs text-ink-700">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>
需求人:{members.find((m) => m.user_id === ticket.requester_id)?.nickname ?? "—"}
</span>
<span>
办理人:{members.find((m) => m.user_id === ticket.assignee_id)?.nickname ?? "—"}
</span>
{ticket.reject_count > 0 && <span>退回 {ticket.reject_count} 次</span>}
{ticket.parent_item_id && <span>由任务升级而来</span>}
</div>
{ticket.resolution_md && (
<p className="mt-2 whitespace-pre-wrap text-ink-900">验收结论:{ticket.resolution_md}</p>
)}
<div className="mt-3 space-y-2">
<input
className="input"
placeholder="给对方的说明(提交或验收时一并发过去)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{actionButtons(ticket)}
</div>
</div>
)}
<input
className="input text-base font-medium"
value={title}
@@ -236,8 +380,38 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
/>
</label>
{item.kind === "task" && !["done", "canceled"].includes(item.status) && (
<div className="mt-4 rounded-lg border border-ink-100 p-3">
<p className="text-sm font-medium">这件事卡在别人手里?升级成工单</p>
<p className="mt-1 text-xs text-ink-500">
升级后这条任务会变成「被阻塞」,工单办好并验收通过后自动回到待办,不用你自己盯。
</p>
<div className="mt-2 flex gap-2">
<select
className="input flex-1"
value={promoteTo}
onChange={(e) => setPromoteTo(e.target.value)}
>
<option value="">选择找谁办…</option>
{members
.filter((m) => m.status === "active")
.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.nickname}
{m.role === "guest" ? "(访客)" : ""}
</option>
))}
</select>
<button className="btn-primary" disabled={busy || !promoteTo} onClick={() => void promote()}>
升级成工单
</button>
</div>
</div>
)}
{notice && <p className="mt-3 text-sm text-emerald-700">{notice}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{busy && <p className="mt-3 text-xs text-ink-500">保存中…</p>}
{busy && <p className="mt-3 text-xs text-ink-500">处理中…</p>}
<h3 className="mt-6 text-sm font-medium">评论({comments.length})</h3>
<ul className="mt-2 space-y-2">
@@ -273,6 +447,9 @@ export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Prop
{a.action === "status_changed" && a.payload
? ` ${String(a.payload.from ?? "")} → ${String(a.payload.to ?? "")}`
: ""}
{a.action === "ticket_submit" && a.payload?.note
? ` · ${String(a.payload.note)}`
: ""}
</li>
))}
</ul>
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { api } from "../lib/client";
const TABS = [
{ path: "", label: "任务" },
{ path: "/board", label: "看板" },
{ path: "/tickets", label: "工单" },
{ path: "/members", label: "成员" },
];
/**
* 工作区内的导航条。
*
* 未读数为什么放在这里:用户在哪个页面都可能被指派工单,
* 角标必须跟着走,所以让它自己拉一次、再每 30 秒对齐一次。
* `refreshKey` 用来在"刚处理完一条工单"后立刻刷新角标。
*/
export function WorkspaceNav({
wsId,
refreshKey = 0,
}: {
wsId: string;
refreshKey?: number;
}) {
const { pathname } = useLocation();
const [unread, setUnread] = useState(0);
const base = `/w/${wsId}`;
useEffect(() => {
let alive = true;
const load = () => {
api
.get<{ unread: number }>(`/workspaces/${wsId}/notifications/unread-count`)
.then((r) => {
if (alive) setUnread(r.unread);
})
.catch(() => {
/* 角标失败不打扰用户 */
});
};
void load();
const timer = window.setInterval(load, 30_000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [wsId, refreshKey]);
return (
<nav className="flex items-center gap-4 text-sm">
{TABS.map((tab) => {
const active =
tab.path === "" ? pathname === base : pathname.startsWith(base + tab.path);
return (
<Link
key={tab.label}
to={base + tab.path}
className={
active
? "font-medium text-ink-900"
: "text-ink-500 transition hover:text-ink-900"
}
>
{tab.label}
{tab.path === "/tickets" && unread > 0 && (
<span className="ml-1 rounded-full bg-accent-500 px-1.5 text-[10px] leading-4 text-white">
{unread > 99 ? "99+" : unread}
</span>
)}
</Link>
);
})}
<Link to="/" className="text-ink-500 transition hover:text-ink-900">
切换
</Link>
</nav>
);
}
+15 -6
View File
@@ -1,4 +1,4 @@
import type { ItemStatus } from "./types";
import type { ItemStatus, TaskStatus } from "./types";
const PRIORITY_ALIASES: Record<string, number> = {
"!0": 0,
@@ -53,17 +53,26 @@ export const ROLE_LABEL: Record<string, string> = {
guest: "访客",
};
export const STATUS_LABEL: Record<ItemStatus, string> = {
export const STATUS_LABEL: Record<string, string> = {
// 任务
inbox: "收件箱",
todo: "待办",
doing: "进行中",
done: "已完成",
blocked: "被阻塞",
canceled: "已取消",
// 工单(与任务的 done/canceled 共用)
open: "待接收",
accepted: "待办理",
in_progress: "办理中",
pending_verify: "待验收",
};
/** 状态机前端影子:只用来置灰拖拽目标,服务端仍会再校验一次。 */
export const ALLOWED: Record<ItemStatus, ItemStatus[]> = {
/**
* 任务状态机的前端影子:只用来置灰拖拽目标,服务端仍会再校验一次。
* 工单的状态机在服务端(动作驱动),前端不做影子,直接看按钮。
*/
export const ALLOWED: Record<string, TaskStatus[]> = {
inbox: ["todo", "canceled"],
todo: ["inbox", "doing", "done", "canceled"],
doing: ["todo", "done", "blocked", "canceled"],
@@ -72,8 +81,8 @@ export const ALLOWED: Record<ItemStatus, ItemStatus[]> = {
canceled: ["todo"],
};
export function canTransition(from: ItemStatus, to: ItemStatus): boolean {
return from === to || (ALLOWED[from] ?? []).includes(to);
export function canTransition(from: string, to: string): boolean {
return from === to || (ALLOWED[from] ?? []).includes(to as TaskStatus);
}
export const PRIORITY_LABEL: Record<number, string> = {
+38 -1
View File
@@ -39,7 +39,16 @@ export interface Invitation {
expires_at: string;
}
export type ItemStatus = "inbox" | "todo" | "doing" | "done" | "blocked" | "canceled";
export type TaskStatus = "inbox" | "todo" | "doing" | "done" | "blocked" | "canceled";
export type TicketStatus =
| "open"
| "accepted"
| "in_progress"
| "pending_verify"
| "done"
| "canceled";
export type ItemStatus = TaskStatus | TicketStatus;
export interface Tag {
id: string;
@@ -74,4 +83,32 @@ export interface Activity {
export interface ItemDetail extends Item {
activities: Activity[];
ticket?: TicketInfo;
}
export interface TicketInfo {
requester_id: string;
assignee_id: string;
parent_item_id: string | null;
accepted_at: string | null;
submitted_at: string | null;
verified_at: string | null;
verified_by: string | null;
reject_count: number;
resolution_md: string;
}
export interface Ticket extends Item {
ticket: TicketInfo;
}
export interface Notification {
id: string;
kind: string;
item_id: string | null;
item_title: string | null;
title: string;
body: string;
read_at: string | null;
created_at: string;
}
+4 -10
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse";
import type { Item, ItemStatus } from "../lib/types";
@@ -24,7 +25,7 @@ interface BatchResult {
}
export function BoardPage() {
const { wsId } = useParams<{ wsId: string }>();
const { wsId = "" } = useParams<{ wsId: string }>();
const [columns, setColumns] = useState<Record<string, Item[]>>({});
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dragId, setDragId] = useState<string | null>(null);
@@ -143,14 +144,7 @@ export function BoardPage() {
拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会直接拒绝并说明原因。
</p>
</div>
<div className="flex items-center gap-3 text-sm">
<Link to={`/w/${wsId}`} className="text-ink-500 hover:text-ink-900">
列表
</Link>
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
成员
</Link>
</div>
<WorkspaceNav wsId={wsId} />
</div>
{selectedItems.length > 0 && (
+14 -2
View File
@@ -1,13 +1,25 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api, session, type Tokens } from "../lib/client";
import type { User } from "../lib/types";
/**
* 登录后回哪儿:`?next=` 给邀请链接用。
* 只接受站内路径(以单个 `/` 开头),避免被拼成 `//evil.com` 的外跳。
*/
function safeNext(raw: string | null): string | null {
if (!raw) return null;
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
return raw;
}
export function LoginPage() {
const { reload } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const next = safeNext(params.get("next"));
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [devCode, setDevCode] = useState<string | null>(null);
@@ -45,7 +57,7 @@ export function LoginPage() {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code });
session.save(data.tokens);
await reload();
navigate("/", { replace: true });
navigate(next ?? "/", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "登录失败");
} finally {
+14 -17
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
import type { Item, ItemStatus, Page } from "../lib/types";
@@ -14,10 +15,10 @@ const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
];
const STATUS_STYLE: Record<ItemStatus, string> = {
const STATUS_STYLE: Record<string, string> = {
inbox: "bg-ink-100 text-ink-700",
todo: "bg-ink-100 text-ink-700",
doing: "bg-accent/15 text-accent",
doing: "bg-accent-500/15 text-accent-600",
done: "bg-emerald-100 text-emerald-700",
blocked: "bg-amber-100 text-amber-700",
canceled: "bg-ink-100 text-ink-500 line-through",
@@ -31,7 +32,7 @@ function dueLabel(iso: string | null): { text: string; overdue: boolean } | null
}
export function TasksPage() {
const { wsId } = useParams<{ wsId: string }>();
const { wsId = "" } = useParams<{ wsId: string }>();
const [view, setView] = useState("today");
const [items, setItems] = useState<Item[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
@@ -42,26 +43,32 @@ export function TasksPage() {
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [activeIdx, setActiveIdx] = useState(0);
const reqId = useRef(0);
const current = VIEWS.find((v) => v.key === view);
const load = useCallback(
async (nextCursor?: string | null) => {
if (!wsId) return;
// 换视图/改搜索词会连发多个请求,慢的那个后到会把新结果覆盖掉。
// 每次请求记一个号,回来时不是最新号就整包丢弃。
const mine = ++reqId.current;
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}`);
if (mine !== reqId.current) return;
setItems((prev) => (nextCursor ? [...prev, ...page.items] : page.items));
setCursor(page.next_cursor);
setHasMore(page.has_more);
setError(null);
} catch (err) {
if (mine !== reqId.current) return;
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
if (mine === reqId.current) setLoading(false);
}
},
[wsId, view, query],
@@ -147,17 +154,7 @@ export function TasksPage() {
<span className="ml-2">j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭</span>
</p>
</div>
<div className="flex items-center gap-3 text-sm">
<Link to={`/w/${wsId}/board`} className="text-ink-500 hover:text-ink-900">
看板
</Link>
<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>
<WorkspaceNav wsId={wsId} />
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
+201
View File
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { TaskDetail } from "../components/TaskDetail";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { ApiError, api } from "../lib/client";
import { STATUS_LABEL } from "../lib/parse";
import type { Item, Ticket } from "../lib/types";
const ROLES = [
{ key: "assigned", label: "派给我的" },
{ key: "requested", label: "我发起的" },
{ key: "all", label: "全部" },
] as const;
const STATUS_FILTERS = [
{ key: "", label: "全部状态" },
{ key: "open", label: "待接收" },
{ key: "accepted", label: "待办理" },
{ key: "in_progress", label: "办理中" },
{ key: "pending_verify", label: "待验收" },
{ key: "done", label: "已完成" },
] as const;
export function TicketsPage() {
const { wsId = "" } = useParams();
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
const [status, setStatus] = useState<string>("");
const [tickets, setTickets] = useState<Ticket[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [badge, setBadge] = useState(0);
const reqId = useRef(0);
const load = useCallback(
async (nextCursor: string | null) => {
// 切 tab / 切状态会连发请求,慢的回来会盖掉新结果,用序号丢弃过期响应
const mine = ++reqId.current;
setLoading(true);
setError(null);
try {
const qs = new URLSearchParams({ role });
if (status) qs.set("status", status);
if (nextCursor) qs.set("cursor", nextCursor);
const page = await api.get<{ tickets: Ticket[]; next_cursor: string | null; has_more: boolean }>(
`/workspaces/${wsId}/tickets?${qs.toString()}`,
);
if (mine !== reqId.current) return;
setTickets((prev) => (nextCursor ? [...prev, ...page.tickets] : page.tickets));
setCursor(page.has_more ? page.next_cursor : null);
} catch (err) {
if (mine !== reqId.current) return;
setError(err instanceof ApiError ? err.message : "工单加载失败");
} finally {
if (mine === reqId.current) setLoading(false);
}
},
[wsId, role, status],
);
useEffect(() => {
void load(null);
}, [load]);
async function quickAction(ticket: Ticket, action: string, body: Record<string, unknown> = {}) {
try {
await api.post(`/workspaces/${wsId}/items/${ticket.id}/ticket/${action}`, {
version: ticket.version,
...body,
});
setBadge((n) => n + 1);
void load(null);
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("这条刚被别人改过,已为你刷新列表");
void load(null);
return;
}
setError(err instanceof ApiError ? err.message : "操作失败");
}
}
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">
待接 → 办理中 → 待验收 → 完成;验收通过后,当初卡住的那条任务会自动回到待办
</p>
</div>
<WorkspaceNav wsId={wsId} refreshKey={badge} />
</div>
<div className="mb-3 flex flex-wrap items-center gap-2">
{ROLES.map((r) => (
<button
key={r.key}
onClick={() => setRole(r.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
role === r.key
? "bg-ink-900 text-white"
: "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{r.label}
</button>
))}
<select
className="input ml-auto w-36"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{STATUS_FILTERS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</div>
{error && <p className="mb-3 rounded-lg bg-red-50 p-2 text-sm text-red-700">{error}</p>}
{tickets.length === 0 && !loading && (
<div className="card text-center text-sm text-ink-500">
这里还空着。任务卡在别人手里时,在任务详情里点「升级成工单」就会出现在这儿。
</div>
)}
<ul className="space-y-2">
{tickets.map((t) => (
<li key={t.id} className="card">
<div className="flex items-start justify-between gap-3">
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(t.id)}>
<p className="truncate text-sm font-medium">{t.title}</p>
<p className="mt-1 text-xs text-ink-500">
{STATUS_LABEL[t.status]} · 更新 {new Date(t.updated_at).toLocaleString("zh-CN")}
{t.ticket.reject_count > 0 ? ` · 退回 ${t.ticket.reject_count} 次` : ""}
{t.due_at
? ` · 截止 ${new Date(t.due_at).toLocaleDateString("zh-CN")}`
: ""}
</p>
</button>
<div className="flex shrink-0 gap-2 text-xs">
{t.status === "open" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "accept")}>
接收
</button>
)}
{t.status === "accepted" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "start")}>
开始
</button>
)}
{t.status === "pending_verify" && (
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "verify", { approve: true })}>
通过
</button>
)}
<button className="btn-ghost px-2 py-1" onClick={() => setOpenId(t.id)}>
详情
</button>
</div>
</div>
</li>
))}
</ul>
{cursor && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</div>
)}
{openId && (
<TaskDetail
wsId={wsId}
itemId={openId}
onClose={() => setOpenId(null)}
onChanged={(updated: Item) =>
setTickets((prev) =>
prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)),
)
}
onDeleted={(id) => {
setTickets((prev) => prev.filter((t) => t.id !== id));
setOpenId(null);
}}
onTicketChanged={() => {
setBadge((n) => n + 1);
void load(null);
}}
/>
)}
</Layout>
);
}