Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
600131dcf5 | ||
|
|
fd18b2a403 | ||
|
|
b1dc3cffa6 | ||
|
|
30caaaf1d9 |
+81
-2
@@ -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. 健康与版本
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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 包)/
|
||||
|
||||
@@ -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 条黄金用例回归
|
||||
```
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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="存活探针")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:初稿已出,等评审");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user