M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。 通知(backend/app/services/notify.py 统一出口) - 七类触发:task_assigned、task_status_changed、item_commented、 ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked - 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断 - 已读:GET /notifications、/unread-count、POST /read、/read-all 手工开单 - POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item, ticket_ext.parent_item_id 为空,验收通过不回流任务 - 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上") 前端 - Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标); 工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新; 任务与工单支持 ?open= 深链落地即展开 - 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框 验证 - pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例) - ruff check / format --check 全 0 - tsc --noEmit 通过;vite build 成功 - web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零) - 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证, 装回 JDK 17 后第一件事是 assembleDebug
This commit is contained in:
+34
-2
@@ -227,6 +227,20 @@ canceled→ todo (恢复)
|
|||||||
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
|
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
|
||||||
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
|
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
|
||||||
|
|
||||||
|
### 4.0 `POST /tickets`(手工开单)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "title": "核对 9 月发票", "body_md": "可选说明",
|
||||||
|
"assignee_id": "uuid", "priority": 2, "due_at": "2026-10-30T10:00:00+08:00" }
|
||||||
|
```
|
||||||
|
|
||||||
|
返回与详情同构的工单对象(item + `ticket`)。与 4.1 的区别只有一处:**没有来源任务**,
|
||||||
|
`ticket.parent_item_id` 为 `null`,因此验收通过后不会回流任何任务(但同样会沉淀一份知识草稿)。
|
||||||
|
|
||||||
|
- `title` 必填、`assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
|
||||||
|
- 初始状态 `open`,被指派人立刻收到 `ticket_assigned` 通知。
|
||||||
|
- 手工单与升级单共用同一条 `item`,所以看板、评论、验收、知识沉淀全部复用,不需要另一套接口。
|
||||||
|
|
||||||
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
|
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -286,8 +300,26 @@ canceled→ todo (恢复)
|
|||||||
| POST | `/notifications/{id}/read` | 标一条 |
|
| POST | `/notifications/{id}/read` | 标一条 |
|
||||||
| POST | `/notifications/read-all` | 全标已读 |
|
| POST | `/notifications/read-all` | 全标已读 |
|
||||||
|
|
||||||
`kind`:`ticket_assigned` / `ticket_submitted` / `ticket_approved` / `ticket_rejected` / `task_unblocked`。
|
`kind` 一共七种,都是"某人在某件事上做了什么":
|
||||||
通知只发给当事人,同工作区里其他人看不到——这是"协同"与"广播"的区别。
|
|
||||||
|
| kind | 什么时候发 | 发给谁 |
|
||||||
|
|---|---|---|
|
||||||
|
| `task_assigned` | 建任务/改任务时指派了人 | 被指派人 |
|
||||||
|
| `task_status_changed` | 任务状态变化 | 创建人 + 办理人(除操作者本人) |
|
||||||
|
| `item_commented` | 有人评论 | 创建人 + 办理人(除评论者本人) |
|
||||||
|
| `ticket_assigned` | 升级成工单 / 手工开单 / 改派 | 办理人 |
|
||||||
|
| `ticket_submitted` | 办理人提交验收 | 需求人 |
|
||||||
|
| `ticket_approved` / `ticket_rejected` | 验收通过 / 退回 | 办理人 |
|
||||||
|
| `task_unblocked` | 工单验收通过、原任务回流待办 | 原任务创建人 |
|
||||||
|
|
||||||
|
三条硬规则:
|
||||||
|
|
||||||
|
1. **不通知自己**:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
|
||||||
|
2. 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
|
||||||
|
3. 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
|
||||||
|
|
||||||
|
Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后立刻派发 `notifications:changed` 事件刷新角标,不必等下一轮。
|
||||||
|
安卓端同样按 30 秒/切页刷新。
|
||||||
|
|
||||||
### 4.6 工单的错误码
|
### 4.6 工单的错误码
|
||||||
|
|
||||||
|
|||||||
@@ -189,8 +189,8 @@
|
|||||||
- [ ] 下拉刷新、只读缓存(Room)未做
|
- [ ] 下拉刷新、只读缓存(Room)未做
|
||||||
|
|
||||||
### 1.6 通知与提醒(0.8d)
|
### 1.6 通知与提醒(0.8d)
|
||||||
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
|
- [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程);
|
||||||
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
|
未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。
|
||||||
- [ ] 每日摘要邮件(同上推迟)
|
- [ ] 每日摘要邮件(同上推迟)
|
||||||
|
|
||||||
### 1.7 测试与验收(0.7d)
|
### 1.7 测试与验收(0.7d)
|
||||||
@@ -230,6 +230,8 @@
|
|||||||
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
|
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
|
||||||
- [x] `GET /tickets?role=assigned|requested|all&status=`
|
- [x] `GET /tickets?role=assigned|requested|all&status=`
|
||||||
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
|
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
|
||||||
|
- [x] `POST /tickets` 手工开单:不经过任务直接指定人,初始 `open`,与升级单共用同一条 item
|
||||||
|
- [x] 指派前校验"在册成员"(`_ensure_member`),否则任务会挂在一个没人看得到的名字上
|
||||||
- [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
|
- [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
|
||||||
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
|
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
|
||||||
|
|
||||||
@@ -238,17 +240,27 @@
|
|||||||
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
|
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
|
||||||
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
|
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
|
||||||
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
|
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
|
||||||
|
- [x] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条
|
||||||
|
- [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开
|
||||||
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
|
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
|
||||||
|
|
||||||
### 2.5 安卓(1.5d)
|
### 2.5 安卓(1.5d)
|
||||||
- [ ] 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是**未编译**状态
|
- [x] 工单列表与动作 —— 仅按"我是需求人 / 办理人 / 管理员"显隐动作按钮,不再人人可见
|
||||||
|
- [x] 通知页与手工开单:五个 tab、未读数 30 秒刷新、通知点开跳列表并清标记
|
||||||
|
- [ ] 本次未编译验证:本机 JDK 已升级到 25,Gradle 8.7 / AGP 8.7.3 不兼容,需装回 JDK 17 再 `assembleDebug`
|
||||||
|
|
||||||
### 2.6 测试与验收(0.7d)
|
### 2.6 测试与验收(0.7d)
|
||||||
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
|
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
|
||||||
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
|
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
|
||||||
- [x] 全量 `pytest -q` → **57 passed**;`ruff check` / `format --check` 全 0
|
- [x] `backend/tests/test_notifications.py` 6 个用例:改状态只通知对方(自己不动自己)、指派必须是成员、
|
||||||
|
手工单全流程(开出→被指派人收到→受理/提交→验收,且没有来源任务)、越界指派 400/1001、
|
||||||
|
评论通知对方、未读数与全部已读
|
||||||
|
- [x] 全量 `pytest -q` → **85 passed**;`ruff check` / `format --check` 全 0
|
||||||
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
|
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
|
||||||
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
|
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
|
||||||
|
- [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 →
|
||||||
|
这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 →
|
||||||
|
未读数归零 → 两端控制台零报错
|
||||||
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
|
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
|
||||||
|
|
||||||
### 2.7 通知真实投递
|
### 2.7 通知真实投递
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -27,6 +27,7 @@ import com.dadao.task.ui.HomeScreen
|
|||||||
import com.dadao.task.ui.KnowledgeScreen
|
import com.dadao.task.ui.KnowledgeScreen
|
||||||
import com.dadao.task.ui.LoginScreen
|
import com.dadao.task.ui.LoginScreen
|
||||||
import com.dadao.task.ui.MembersScreen
|
import com.dadao.task.ui.MembersScreen
|
||||||
|
import com.dadao.task.ui.NotificationsScreen
|
||||||
import com.dadao.task.ui.RichTextEditorScreen
|
import com.dadao.task.ui.RichTextEditorScreen
|
||||||
import com.dadao.task.ui.TaskListScreen
|
import com.dadao.task.ui.TaskListScreen
|
||||||
import com.dadao.task.ui.TicketsScreen
|
import com.dadao.task.ui.TicketsScreen
|
||||||
@@ -71,6 +72,8 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||||
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
||||||
var unread by remember { mutableStateOf(0) }
|
var unread by remember { mutableStateOf(0) }
|
||||||
|
// 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect
|
||||||
|
var unreadRefresh by remember { mutableStateOf(0) }
|
||||||
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
||||||
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
||||||
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
|
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
|
||||||
@@ -105,8 +108,8 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
.onFailure { pendingJoin = null }
|
.onFailure { pendingJoin = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工单未读数:进工作区就拉一次,切页面时再刷。
|
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
|
||||||
LaunchedEffect(current?.id, tab) {
|
LaunchedEffect(current?.id, tab, unreadRefresh) {
|
||||||
val ws = current ?: return@LaunchedEffect
|
val ws = current ?: return@LaunchedEffect
|
||||||
unread = api.unreadCount(ws.id)
|
unread = api.unreadCount(ws.id)
|
||||||
}
|
}
|
||||||
@@ -184,7 +187,12 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
onReopened = { reopenItem = null },
|
onReopened = { reopenItem = null },
|
||||||
)
|
)
|
||||||
|
|
||||||
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
WorkspaceTab.Tickets -> TicketsScreen(
|
||||||
|
api = api,
|
||||||
|
workspace = ws,
|
||||||
|
focusId = reopenItem,
|
||||||
|
onFocusHandled = { reopenItem = null },
|
||||||
|
)
|
||||||
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
||||||
api = api,
|
api = api,
|
||||||
workspace = ws,
|
workspace = ws,
|
||||||
@@ -194,6 +202,18 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
||||||
|
|
||||||
|
WorkspaceTab.Notifications -> NotificationsScreen(
|
||||||
|
api = api,
|
||||||
|
workspaceId = ws.id,
|
||||||
|
onOpenItem = { itemId, isTicket ->
|
||||||
|
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
|
||||||
|
reopenItem = itemId
|
||||||
|
reopenKnowledge = false
|
||||||
|
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
|
||||||
|
},
|
||||||
|
onUnreadChanged = { unreadRefresh += 1 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,25 @@ data class KnowledgePage(
|
|||||||
|
|
||||||
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
|
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppNotification(
|
||||||
|
val id: String,
|
||||||
|
val kind: String = "",
|
||||||
|
@SerialName("item_id") val itemId: String? = null,
|
||||||
|
@SerialName("item_title") val itemTitle: String? = null,
|
||||||
|
val title: String = "",
|
||||||
|
val body: String = "",
|
||||||
|
@SerialName("read_at") val readAt: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationPage(
|
||||||
|
val notifications: List<AppNotification> = emptyList(),
|
||||||
|
@SerialName("next_cursor") val nextCursor: String? = null,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
|
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
|
||||||
|
|
||||||
@@ -311,6 +330,15 @@ private data class KnowledgeCreateRequest(
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class KnowledgeActionRequest(val action: String)
|
private data class KnowledgeActionRequest(val action: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TicketCreateRequest(
|
||||||
|
val title: String,
|
||||||
|
@SerialName("body_md") val bodyMd: String? = null,
|
||||||
|
@SerialName("assignee_id") val assigneeId: String,
|
||||||
|
val priority: Int = 1,
|
||||||
|
@SerialName("due_at") val dueAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class ItemUpdateRequest(
|
private data class ItemUpdateRequest(
|
||||||
val version: Int,
|
val version: Int,
|
||||||
@@ -427,6 +455,48 @@ class Api(private val tokens: TokenStore) {
|
|||||||
auth = true,
|
auth = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---- M4 通知 ----
|
||||||
|
|
||||||
|
/** 通知默认只看未读;"全部"是查历史用的。 */
|
||||||
|
suspend fun notifications(
|
||||||
|
workspaceId: String,
|
||||||
|
unreadOnly: Boolean = true,
|
||||||
|
): NotificationPage = call(
|
||||||
|
path = "/workspaces/$workspaceId/notifications?unread_only=$unreadOnly&limit=50",
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// OkHttp 的 POST 必须带 body,这里发个空对象,服务端不需要入参
|
||||||
|
suspend fun markNotificationRead(workspaceId: String, id: String): JsonElement = call(
|
||||||
|
path = "/workspaces/$workspaceId/notifications/$id/read",
|
||||||
|
body = "{}",
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun markAllNotificationsRead(workspaceId: String): JsonElement = call(
|
||||||
|
path = "/workspaces/$workspaceId/notifications/read-all",
|
||||||
|
body = "{}",
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- M2 工单(手工开单)----
|
||||||
|
|
||||||
|
/** 不经过任务,直接开一张工单交给别人。 */
|
||||||
|
suspend fun createTicket(
|
||||||
|
workspaceId: String,
|
||||||
|
title: String,
|
||||||
|
assigneeId: String,
|
||||||
|
bodyMd: String? = null,
|
||||||
|
priority: Int = 1,
|
||||||
|
dueAt: String? = null,
|
||||||
|
): Ticket = call(
|
||||||
|
path = "/workspaces/$workspaceId/tickets",
|
||||||
|
body = json.encodeToString(
|
||||||
|
TicketCreateRequest(title, bodyMd?.ifBlank { null }, assigneeId, priority, dueAt),
|
||||||
|
),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
|
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
|
||||||
suspend fun unreadCount(workspaceId: String): Int = runCatching {
|
suspend fun unreadCount(workspaceId: String): Int = runCatching {
|
||||||
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
|
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
|
|
||||||
/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */
|
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
|
||||||
enum class WorkspaceTab(val label: String) {
|
enum class WorkspaceTab(val label: String) {
|
||||||
Tasks("任务"),
|
Tasks("任务"),
|
||||||
Tickets("工单"),
|
Tickets("工单"),
|
||||||
Knowledge("知识"),
|
Knowledge("知识"),
|
||||||
|
Notifications("通知"),
|
||||||
Members("成员"),
|
Members("成员"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.dadao.task.data.Api
|
||||||
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.AppNotification
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站内通知。
|
||||||
|
*
|
||||||
|
* 只做三件事:看有没有新动静、点开跳过去、标为已读。
|
||||||
|
* 通知本身不是凭证——点开之后目标页面会重新拉数据,因为通知生成后事情可能又被改过。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationsScreen(
|
||||||
|
api: Api,
|
||||||
|
workspaceId: String,
|
||||||
|
onOpenItem: (itemId: String, isTicket: Boolean) -> Unit,
|
||||||
|
onUnreadChanged: () -> Unit,
|
||||||
|
refreshKey: Int = 0,
|
||||||
|
) {
|
||||||
|
var unreadOnly by remember { mutableStateOf(true) }
|
||||||
|
var rows by remember { mutableStateOf<List<AppNotification>>(emptyList()) }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
suspend fun reload() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
runCatching { api.notifications(workspaceId, unreadOnly) }
|
||||||
|
.onSuccess { rows = it.notifications }
|
||||||
|
.onFailure { error = verifyStatusCode((it as? ApiException)?.code ?: 0) }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(workspaceId, unreadOnly, refreshKey) { reload() }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("通知", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Ink)
|
||||||
|
Box(Modifier.width(2.dp))
|
||||||
|
SoftPill(
|
||||||
|
text = "未读",
|
||||||
|
fg = if (unreadOnly) Primary else InkMuted,
|
||||||
|
bg = if (unreadOnly) PrimarySoft else PaperSunken,
|
||||||
|
modifier = Modifier.clickable { unreadOnly = true },
|
||||||
|
)
|
||||||
|
SoftPill(
|
||||||
|
text = "全部",
|
||||||
|
fg = if (unreadOnly) InkMuted else Primary,
|
||||||
|
bg = if (unreadOnly) PaperSunken else PrimarySoft,
|
||||||
|
modifier = Modifier.clickable { unreadOnly = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (unreadOnly) "有人派活、或你关注的事有进展时,会出现在这里" else "历史通知,方便回头找",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = InkMuted,
|
||||||
|
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = Rust, modifier = Modifier.padding(bottom = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(top = 40.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = Primary, strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
SoftCard(Modifier.fillMaxWidth()) {
|
||||||
|
EmptyState(
|
||||||
|
title = if (unreadOnly) "没有未读通知" else "还没有通知",
|
||||||
|
desc = if (unreadOnly) "有动静时这里会提醒你。" else "所有历史通知都会留在这里。",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 20.dp),
|
||||||
|
) {
|
||||||
|
items(rows, key = { it.id }) { n ->
|
||||||
|
val isTicket = n.kind.startsWith("ticket_")
|
||||||
|
SoftCard(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (n.readAt == null) {
|
||||||
|
runCatching { api.markNotificationRead(workspaceId, n.id) }
|
||||||
|
rows = rows.map { if (it.id == n.id) it.copy(readAt = "read") else it }
|
||||||
|
onUnreadChanged()
|
||||||
|
}
|
||||||
|
n.itemId?.let { onOpenItem(it, isTicket) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(26.dp)
|
||||||
|
.background(if (n.readAt == null) PrimarySoft else PaperSunken, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isTicket) "工" else "记",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = if (n.readAt == null) Primary else InkMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(Modifier.width(10.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = n.title,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Ink,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (n.readAt == null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(start = 6.dp)
|
||||||
|
.size(6.dp)
|
||||||
|
.background(Clay, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.body.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = n.body,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = InkSoft,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
n.itemTitle?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = InkMuted,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(formatTime(n.createdAt), fontSize = 11.sp, color = InkMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.dadao.task.ui
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -31,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
import com.dadao.task.data.ApiException
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.Member
|
||||||
import com.dadao.task.data.Ticket
|
import com.dadao.task.data.Ticket
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -42,7 +45,12 @@ private val ticketRoles = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TicketsScreen(api: Api, workspace: Workspace) {
|
fun TicketsScreen(
|
||||||
|
api: Api,
|
||||||
|
workspace: Workspace,
|
||||||
|
focusId: String? = null,
|
||||||
|
onFocusHandled: () -> Unit = {},
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var role by remember { mutableStateOf("assigned") }
|
var role by remember { mutableStateOf("assigned") }
|
||||||
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
|
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
|
||||||
@@ -52,6 +60,16 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
var note by remember { mutableStateOf("") }
|
var note by remember { mutableStateOf("") }
|
||||||
var resolution by remember { mutableStateOf("") }
|
var resolution by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
// 手工开单:不经过任务,人得自己选
|
||||||
|
var members by remember { mutableStateOf<List<Member>>(emptyList()) }
|
||||||
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
var newTitle by remember { mutableStateOf("") }
|
||||||
|
var newAssignee by remember { mutableStateOf("") }
|
||||||
|
var newPriority by remember { mutableStateOf(1) }
|
||||||
|
|
||||||
|
// 动作按钮按"我是谁"决定显隐:需求人不去接收自己的单,办理人也不该自验
|
||||||
|
var meId by remember { mutableStateOf("") }
|
||||||
|
|
||||||
suspend fun reload() {
|
suspend fun reload() {
|
||||||
loading = true
|
loading = true
|
||||||
runCatching { api.tickets(workspace.id, role) }
|
runCatching { api.tickets(workspace.id, role) }
|
||||||
@@ -65,6 +83,18 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
|
|
||||||
LaunchedEffect(role) { reload() }
|
LaunchedEffect(role) { reload() }
|
||||||
|
|
||||||
|
// 从通知点进来:切到"全部"才看得见那张单,然后清掉待处理标记
|
||||||
|
LaunchedEffect(focusId) {
|
||||||
|
if (focusId == null) return@LaunchedEffect
|
||||||
|
role = "all"
|
||||||
|
onFocusHandled()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { api.members(workspace.id) }.onSuccess { members = it }
|
||||||
|
runCatching { api.me() }.onSuccess { meId = it.user.id }
|
||||||
|
}
|
||||||
|
|
||||||
fun fail(e: Throwable) {
|
fun fail(e: Throwable) {
|
||||||
val code = (e as? ApiException)?.code ?: 0
|
val code = (e as? ApiException)?.code ?: 0
|
||||||
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||||
@@ -95,10 +125,20 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
ticketRoles.forEach { (key, label) ->
|
ticketRoles.forEach { (key, label) ->
|
||||||
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
|
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
GhostButton("新建", onClick = {
|
||||||
|
newTitle = ""
|
||||||
|
newAssignee = ""
|
||||||
|
newPriority = 1
|
||||||
|
creating = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,21 +180,23 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
val isAssignee = ticket.ticket.assigneeId == meId
|
||||||
|
val isRequester = ticket.ticket.requesterId == meId
|
||||||
|
val isAdmin = workspace.role == "owner" || workspace.role == "admin"
|
||||||
|
val canWork = isAssignee || isAdmin
|
||||||
|
val canVerify = isRequester || isAdmin
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
when (ticket.status) {
|
when (ticket.status) {
|
||||||
"open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
"open" -> if (canWork) {
|
||||||
"accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
||||||
"in_progress" -> PrimaryButton("提交验收", onClick = {
|
|
||||||
scope.launch {
|
|
||||||
runCatching { api.ticketAction(workspace.id, ticket.id, "submit") }
|
|
||||||
.onSuccess {
|
|
||||||
message = null
|
|
||||||
reload()
|
|
||||||
}
|
}
|
||||||
.onFailure { fail(it) }
|
"accepted" -> if (canWork) {
|
||||||
|
PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
||||||
}
|
}
|
||||||
})
|
"in_progress" -> if (canWork) {
|
||||||
"pending_verify" -> {
|
PrimaryButton("提交验收", onClick = { act(ticket, "submit") })
|
||||||
|
}
|
||||||
|
"pending_verify" -> if (canVerify) {
|
||||||
PrimaryButton("通过验收", onClick = {
|
PrimaryButton("通过验收", onClick = {
|
||||||
verifyTarget = ticket
|
verifyTarget = ticket
|
||||||
note = ""
|
note = ""
|
||||||
@@ -174,6 +216,17 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 谁都不能替别人取消,但需求人/办理人/管理员可以中止
|
||||||
|
if (ticket.status != "done" && ticket.status != "canceled" && (isRequester || isAssignee || isAdmin)) {
|
||||||
|
GhostButton("取消", onClick = { act(ticket, "cancel") })
|
||||||
|
}
|
||||||
|
if (!canWork && !canVerify && ticket.status != "done" && ticket.status != "canceled") {
|
||||||
|
Text(
|
||||||
|
if (isRequester) "等对方处理" else "这条不是派给你的",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +234,89 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
item { Spacer(Modifier.height(20.dp)) }
|
item { Spacer(Modifier.height(20.dp)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (creating) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { creating = false },
|
||||||
|
title = { Text("新建工单", style = MaterialTheme.typography.titleMedium) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"不经过任务也能开单。写清楚要办什么,指定一个人,对方会收到通知。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newTitle,
|
||||||
|
onValueChange = { newTitle = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("要办什么,例如:核对 9 月发票", color = InkMuted) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text("交给谁", style = MaterialTheme.typography.labelMedium, color = InkMuted)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
members.forEach { m ->
|
||||||
|
SurfaceChip(
|
||||||
|
text = m.nickname.ifBlank { m.phone.takeLast(4) },
|
||||||
|
selected = newAssignee == m.userId,
|
||||||
|
onClick = { newAssignee = m.userId },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text("优先级", style = MaterialTheme.typography.labelMedium, color = InkMuted)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
listOf(0 to "无", 1 to "低", 2 to "中", 3 to "高").forEach { (v, label) ->
|
||||||
|
SurfaceChip(
|
||||||
|
text = label,
|
||||||
|
selected = newPriority == v,
|
||||||
|
onClick = { newPriority = v },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = newTitle.isNotBlank() && newAssignee.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
api.createTicket(
|
||||||
|
workspace.id,
|
||||||
|
newTitle.trim(),
|
||||||
|
newAssignee,
|
||||||
|
priority = newPriority,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
creating = false
|
||||||
|
message = null
|
||||||
|
role = "requested"
|
||||||
|
}
|
||||||
|
.onFailure { fail(it) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text("开单并通知对方") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } },
|
||||||
|
containerColor = Paper,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val target = verifyTarget
|
val target = verifyTarget
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ from ..domain.task import (
|
|||||||
view_statuses,
|
view_statuses,
|
||||||
)
|
)
|
||||||
from ..errors import AppError, ErrorCode
|
from ..errors import AppError, ErrorCode
|
||||||
|
from ..models import Member
|
||||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||||
from ..models_ticket import TicketExt
|
from ..models_ticket import TicketExt
|
||||||
from ..schemas import Pagination, encode_cursor, ok
|
from ..schemas import Pagination, encode_cursor, ok
|
||||||
from ..serializers import item_out, tag_out, tags_of
|
from ..serializers import item_out, tag_out, tags_of
|
||||||
|
from ..services.notify import TASK_STATUS_CN, notify
|
||||||
from .attachments import count_for_items, list_for_item
|
from .attachments import count_for_items, list_for_item
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
||||||
@@ -98,6 +100,25 @@ async def _load_item(
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_member(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||||
|
"""被指派的人必须是这个工作区的在册成员。
|
||||||
|
|
||||||
|
不校验的话可以指派给任意 uuid:任务从此挂在"不存在的人"名下,
|
||||||
|
既不会出现在任何人的列表里,也不会有人收到通知。
|
||||||
|
"""
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(Member.id).where(
|
||||||
|
Member.workspace_id == ws_id,
|
||||||
|
Member.user_id == user_id,
|
||||||
|
Member.status == "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
|
||||||
|
|
||||||
|
|
||||||
async def _tags_of(
|
async def _tags_of(
|
||||||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||||||
) -> dict[uuid.UUID, list[Tag]]:
|
) -> dict[uuid.UUID, list[Tag]]:
|
||||||
@@ -452,6 +473,9 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
|||||||
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||||||
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
if body.assignee_id is not None:
|
||||||
|
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||||
|
|
||||||
item = Item(
|
item = Item(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
workspace_id=ctx.ws_id,
|
workspace_id=ctx.ws_id,
|
||||||
@@ -470,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
|||||||
if body.tag_names:
|
if body.tag_names:
|
||||||
await _set_tags(session, ctx.ws_id, item, body.tag_names)
|
await _set_tags(session, ctx.ws_id, item, body.tag_names)
|
||||||
await _log(session, item, ctx.user.id, "created", {"status": item.status})
|
await _log(session, item, ctx.user.id, "created", {"status": item.status})
|
||||||
|
if item.assignee_id is not None:
|
||||||
|
await notify(
|
||||||
|
session,
|
||||||
|
ws_id=ctx.ws_id,
|
||||||
|
user_ids=(item.assignee_id,),
|
||||||
|
kind="task_assigned",
|
||||||
|
item_id=item.id,
|
||||||
|
title=f"有人把「{item.title}」交给你了",
|
||||||
|
body=f"{ctx.user.nickname or '同事'} 指派给你,注意截止时间",
|
||||||
|
actor_id=ctx.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||||
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
||||||
@@ -576,6 +611,7 @@ async def update_item(
|
|||||||
values["assignee_id"] = None
|
values["assignee_id"] = None
|
||||||
changes["assignee_id"] = None
|
changes["assignee_id"] = None
|
||||||
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
|
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
|
||||||
|
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||||
values["assignee_id"] = body.assignee_id
|
values["assignee_id"] = body.assignee_id
|
||||||
changes["assignee_id"] = str(body.assignee_id)
|
changes["assignee_id"] = str(body.assignee_id)
|
||||||
if body.clear_due_at:
|
if body.clear_due_at:
|
||||||
@@ -605,6 +641,18 @@ async def update_item(
|
|||||||
)
|
)
|
||||||
item.version += 1
|
item.version += 1
|
||||||
|
|
||||||
|
if values.get("assignee_id") is not None:
|
||||||
|
await notify(
|
||||||
|
session,
|
||||||
|
ws_id=ctx.ws_id,
|
||||||
|
user_ids=(values["assignee_id"],),
|
||||||
|
kind="task_assigned",
|
||||||
|
item_id=item.id,
|
||||||
|
title=f"「{item.title}」转给你了",
|
||||||
|
body=f"{ctx.user.nickname or '同事'} 把这件事交给你,原来的办理人已经卸下",
|
||||||
|
actor_id=ctx.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||||
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
||||||
|
|
||||||
@@ -634,6 +682,20 @@ async def change_status(
|
|||||||
payload={"from": previous, "to": body.status},
|
payload={"from": previous, "to": body.status},
|
||||||
)
|
)
|
||||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||||
|
await notify(
|
||||||
|
session,
|
||||||
|
ws_id=ctx.ws_id,
|
||||||
|
user_ids=(item.creator_id, item.assignee_id),
|
||||||
|
kind="task_status_changed",
|
||||||
|
item_id=item.id,
|
||||||
|
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
|
||||||
|
body=(
|
||||||
|
f"{ctx.user.nickname or '同事'} 把它从 "
|
||||||
|
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
|
||||||
|
f"{TASK_STATUS_CN.get(body.status, body.status)}"
|
||||||
|
),
|
||||||
|
actor_id=ctx.user.id,
|
||||||
|
)
|
||||||
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
||||||
|
|
||||||
|
|
||||||
@@ -671,6 +733,16 @@ async def add_comment(
|
|||||||
session.add(comment)
|
session.add(comment)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
|
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
|
||||||
|
await notify(
|
||||||
|
session,
|
||||||
|
ws_id=ctx.ws_id,
|
||||||
|
user_ids=(item.creator_id, item.assignee_id),
|
||||||
|
kind="item_commented",
|
||||||
|
item_id=item.id,
|
||||||
|
title=f"「{item.title}」有新讨论",
|
||||||
|
body=f"{ctx.user.nickname or '同事'}:{body.body_md[:120]}",
|
||||||
|
actor_id=ctx.user.id,
|
||||||
|
)
|
||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
"id": str(comment.id),
|
"id": str(comment.id),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from ..models_task import Activity, Item
|
|||||||
from ..models_ticket import ItemLink, Notification, TicketExt
|
from ..models_ticket import ItemLink, Notification, TicketExt
|
||||||
from ..schemas import Pagination, encode_cursor, ok
|
from ..schemas import Pagination, encode_cursor, ok
|
||||||
from ..serializers import item_out, tags_of
|
from ..serializers import item_out, tags_of
|
||||||
|
from ..services.notify import notify
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
||||||
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
||||||
@@ -60,6 +61,16 @@ class ReassignIn(BaseModel):
|
|||||||
assignee_id: uuid.UUID
|
assignee_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreateIn(BaseModel):
|
||||||
|
"""手工开单:不经过任务升级,直接指定谁去办。"""
|
||||||
|
|
||||||
|
title: str = Field(min_length=1, max_length=200)
|
||||||
|
body_md: str | None = Field(default=None, max_length=20000)
|
||||||
|
assignee_id: uuid.UUID
|
||||||
|
due_at: datetime | None = None
|
||||||
|
priority: int = Field(default=0, ge=0, le=3)
|
||||||
|
|
||||||
|
|
||||||
class VerifyIn(BaseModel):
|
class VerifyIn(BaseModel):
|
||||||
approve: bool
|
approve: bool
|
||||||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||||||
@@ -139,16 +150,18 @@ async def _notify(
|
|||||||
item_id: uuid.UUID | None,
|
item_id: uuid.UUID | None,
|
||||||
title: str,
|
title: str,
|
||||||
body: str = "",
|
body: str = "",
|
||||||
|
actor_id: uuid.UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
session.add(
|
"""单人便捷入口,实际写入交给 services.notify(去重与跳过自己在那里做)。"""
|
||||||
Notification(
|
await notify(
|
||||||
workspace_id=ws_id,
|
session,
|
||||||
user_id=user_id,
|
ws_id=ws_id,
|
||||||
|
user_ids=(user_id,),
|
||||||
kind=kind,
|
kind=kind,
|
||||||
item_id=item_id,
|
item_id=item_id,
|
||||||
title=title[:120],
|
title=title,
|
||||||
body=body[:300],
|
body=body,
|
||||||
)
|
actor_id=actor_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -520,6 +533,67 @@ async def reassign(
|
|||||||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||||||
|
|
||||||
|
|
||||||
|
# -- 手工开单 -----------------------------------------------------------
|
||||||
|
@router.post("/tickets", summary="手工开一张工单(不依赖任务升级)")
|
||||||
|
async def create_ticket(body: TicketCreateIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||||
|
"""开会、口头上被派活、临时找同事帮忙——这些事从来没有过"任务"。
|
||||||
|
|
||||||
|
工单不是任务的衍生物,只是"要别人办的事"的一种形态:它可以凭空存在,
|
||||||
|
也可以由任务升级而来(那条路径见 promote)。两条路都落在 kind=ticket 的
|
||||||
|
同一条 item 上,所以看板、评论、验收、沉淀知识全都共用。
|
||||||
|
"""
|
||||||
|
await _active_member_id(session, ctx.ws_id, body.assignee_id)
|
||||||
|
|
||||||
|
ticket = Item(
|
||||||
|
workspace_id=ctx.ws_id,
|
||||||
|
kind="ticket",
|
||||||
|
title=body.title.strip(),
|
||||||
|
body_md=body.body_md,
|
||||||
|
status="open",
|
||||||
|
priority=body.priority,
|
||||||
|
creator_id=ctx.user.id,
|
||||||
|
assignee_id=body.assignee_id,
|
||||||
|
due_at=body.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=None,
|
||||||
|
)
|
||||||
|
session.add(ext)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await _log(session, ticket, ctx.user.id, "ticket_created", {"manual": True})
|
||||||
|
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 '同事'} 直接给你开了张工单,办完提交验收",
|
||||||
|
actor_id=ctx.user.id,
|
||||||
|
)
|
||||||
|
await audit_record(
|
||||||
|
session,
|
||||||
|
workspace_id=ctx.ws_id,
|
||||||
|
actor_user_id=ctx.user.id,
|
||||||
|
action="ticket.create",
|
||||||
|
target_type="item",
|
||||||
|
target_id=str(ticket.id),
|
||||||
|
payload={"manual": True},
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
ticket_out = await _item_out(session, ctx.ws_id, ticket)
|
||||||
|
return ok({**ticket_out, "ticket": _ticket_ext_out(ext)}, message="已创建")
|
||||||
|
|
||||||
|
|
||||||
# -- 工单列表 -----------------------------------------------------------
|
# -- 工单列表 -----------------------------------------------------------
|
||||||
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
|
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
|
||||||
async def list_tickets(
|
async def list_tickets(
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""站内通知(M2 起用,M4 会接入外部推送)。
|
||||||
|
|
||||||
|
三条原则:
|
||||||
|
|
||||||
|
1. **不通知自己**。绝大多数通知都是由自己的操作触发的,
|
||||||
|
不排除自己会让"未读"迅速变成一个没人看的数字。
|
||||||
|
2. **通知里写清"谁、做了什么、在哪件事上"**。只写"状态已更新"等于没写,
|
||||||
|
用户还得点进去猜。
|
||||||
|
3. **通知只是提示,不是凭证**。点开后仍要按 `item_id` 重新拉数据,
|
||||||
|
因为通知生成之后事情可能又被改过。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models_ticket import Notification
|
||||||
|
|
||||||
|
TICKET_STATUS_CN = {
|
||||||
|
"open": "待接收",
|
||||||
|
"accepted": "待办理",
|
||||||
|
"in_progress": "办理中",
|
||||||
|
"pending_verify": "待验收",
|
||||||
|
"done": "已完成",
|
||||||
|
"canceled": "已取消",
|
||||||
|
}
|
||||||
|
|
||||||
|
TASK_STATUS_CN = {
|
||||||
|
"inbox": "收件箱",
|
||||||
|
"todo": "待办",
|
||||||
|
"doing": "进行中",
|
||||||
|
"blocked": "被阻塞",
|
||||||
|
"done": "已完成",
|
||||||
|
"canceled": "已取消",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def notify(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
ws_id: uuid.UUID,
|
||||||
|
user_ids: Iterable[uuid.UUID | None],
|
||||||
|
kind: str,
|
||||||
|
item_id: uuid.UUID | None,
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
actor_id: uuid.UUID | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""给若干人各写一条通知,返回实际写入条数。
|
||||||
|
|
||||||
|
去重与"跳过自己"在这里统一做一次——每个调用点都手写一遍迟早漏一个。
|
||||||
|
"""
|
||||||
|
seen: set[uuid.UUID] = set()
|
||||||
|
count = 0
|
||||||
|
for user_id in user_ids:
|
||||||
|
if user_id is None or user_id == actor_id or user_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(user_id)
|
||||||
|
session.add(
|
||||||
|
Notification(
|
||||||
|
workspace_id=ws_id,
|
||||||
|
user_id=user_id,
|
||||||
|
kind=kind,
|
||||||
|
item_id=item_id,
|
||||||
|
title=title[:120],
|
||||||
|
body=body[:300],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""通知与手工开单。
|
||||||
|
|
||||||
|
这两个功能都有一个共同的翻车方式:**看起来有,实际没送到**。
|
||||||
|
所以要断言的从来不是"接口 200",而是"该收到的人收到了、不该收到的人没收到"。
|
||||||
|
|
||||||
|
1. 自己改自己的任务不该给自己发通知(否则未读数两天就没人看了);
|
||||||
|
2. 通知必须落到具体的人头上——被指派人、创建人,而不是"整个工作区";
|
||||||
|
3. 手工开的工单要和升级来的工单走同一条路:能接受、能提交、能验收;
|
||||||
|
4. 指派给工作区外的人必须当场拒绝,否则任务会挂在一个没人看得到的名字上。
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 _inbox(client: AsyncClient, user: dict[str, Any], ws_id: str) -> list[dict]:
|
||||||
|
page = unwrap(
|
||||||
|
await client.get(
|
||||||
|
f"{V1}/workspaces/{ws_id}/notifications",
|
||||||
|
params={"unread_only": "false"},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return page["notifications"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_assigned_to(
|
||||||
|
client: AsyncClient, owner: dict[str, Any], ws_id: str, assignee_id: str, title: str
|
||||||
|
) -> dict:
|
||||||
|
return unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws_id}/items",
|
||||||
|
json={"title": title, "status": "todo", "assignee_id": assignee_id},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_task_status_change_notifies_the_other_side(client: AsyncClient) -> None:
|
||||||
|
owner = await login(client)
|
||||||
|
ws = await create_workspace(client, owner, "通知测试")
|
||||||
|
helper = await _member(client, owner, ws, "member")
|
||||||
|
|
||||||
|
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "月度对账")
|
||||||
|
|
||||||
|
# owner 指派给 helper 时,helper 应该收到通知
|
||||||
|
helper_inbox = await _inbox(client, helper, ws["id"])
|
||||||
|
assert any(n["kind"] == "task_assigned" and n["item_id"] == item["id"] for n in helper_inbox)
|
||||||
|
# owner 自己指派的,不该收到
|
||||||
|
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "task_assigned"]
|
||||||
|
|
||||||
|
# helper 把状态改成进行中 → owner(创建人)应该收到
|
||||||
|
unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/status",
|
||||||
|
json={"status": "doing", "version": item["version"]},
|
||||||
|
headers=helper["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
owner_inbox = await _inbox(client, owner, ws["id"])
|
||||||
|
changed = [n for n in owner_inbox if n["kind"] == "task_status_changed"]
|
||||||
|
assert len(changed) == 1
|
||||||
|
assert "进行中" in changed[0]["body"]
|
||||||
|
assert changed[0]["item_title"] == "月度对账"
|
||||||
|
|
||||||
|
# helper 是操作者本人,不该收到自己那次改动的通知
|
||||||
|
assert not [
|
||||||
|
n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_status_changed"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assignee_must_be_a_member(client: AsyncClient) -> None:
|
||||||
|
owner = await login(client)
|
||||||
|
ws = await create_workspace(client, owner, "越界指派")
|
||||||
|
outsider = await login(client) # 没加入这个工作区
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/items",
|
||||||
|
json={"title": "指派给外人", "assignee_id": outsider["user_id"]},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert resp.json()["code"] == 1001
|
||||||
|
|
||||||
|
item = unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/items",
|
||||||
|
json={"title": "不指派"},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
resp = await client.patch(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/items/{item['id']}",
|
||||||
|
json={"assignee_id": outsider["user_id"], "version": item["version"]},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manual_ticket_runs_the_whole_flow(client: AsyncClient) -> None:
|
||||||
|
"""手工开的工单不是"半个工单":接受、提交、验收都得能走。"""
|
||||||
|
owner = await login(client)
|
||||||
|
ws = await create_workspace(client, owner, "手工开单")
|
||||||
|
helper = await _member(client, owner, ws, "member")
|
||||||
|
|
||||||
|
ticket = unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||||
|
json={
|
||||||
|
"title": "帮忙核对一遍报价单",
|
||||||
|
"body_md": "客户催得急,今天下班前要",
|
||||||
|
"assignee_id": helper["user_id"],
|
||||||
|
"priority": 2,
|
||||||
|
},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert ticket["status"] == "open"
|
||||||
|
assert ticket["kind"] == "ticket"
|
||||||
|
assert ticket["ticket"]["requester_id"] == owner["user_id"]
|
||||||
|
assert ticket["ticket"]["assignee_id"] == helper["user_id"]
|
||||||
|
assert ticket["ticket"]["parent_item_id"] is None # 没有来源任务
|
||||||
|
|
||||||
|
# 出现在被指派人的工单列表里
|
||||||
|
assigned = unwrap(
|
||||||
|
await client.get(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||||
|
params={"role": "assigned"},
|
||||||
|
headers=helper["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert [t["id"] for t in assigned["tickets"]] == [ticket["id"]]
|
||||||
|
|
||||||
|
# 被指派人收到通知
|
||||||
|
inbox = await _inbox(client, helper, ws["id"])
|
||||||
|
assert any(n["kind"] == "ticket_assigned" and n["item_id"] == ticket["id"] for n in inbox)
|
||||||
|
|
||||||
|
# 接受 → 开工 → 提交 → 验收通过
|
||||||
|
for action, body in (
|
||||||
|
("accept", {}),
|
||||||
|
("start", {}),
|
||||||
|
("submit", {"note": "核对完了"}),
|
||||||
|
):
|
||||||
|
unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/{action}",
|
||||||
|
json=body,
|
||||||
|
headers=helper["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
done = unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/verify",
|
||||||
|
json={"approve": True, "resolution_md": "确认了三处差异"},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert done["status"] == "done"
|
||||||
|
# 验收是需求人做的,通知该发给办理人;需求人自己不该收到"你通过了"这种通知
|
||||||
|
helper_inbox = await _inbox(client, helper, ws["id"])
|
||||||
|
assert any(n["kind"] == "ticket_approved" for n in helper_inbox)
|
||||||
|
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "ticket_approved"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manual_ticket_rejects_outsider(client: AsyncClient) -> None:
|
||||||
|
owner = await login(client)
|
||||||
|
ws = await create_workspace(client, owner, "手工开单越界")
|
||||||
|
outsider = await login(client)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||||
|
json={"title": "给外人开单", "assignee_id": outsider["user_id"]},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert resp.json()["code"] == 1001
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_comment_notifies_the_other_party(client: AsyncClient) -> None:
|
||||||
|
owner = await login(client)
|
||||||
|
ws = await create_workspace(client, owner, "评论通知")
|
||||||
|
helper = await _member(client, owner, ws, "member")
|
||||||
|
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "整理发票")
|
||||||
|
|
||||||
|
unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/comments",
|
||||||
|
json={"body_md": "发票抬头要写公司全称"},
|
||||||
|
headers=helper["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
owner_inbox = await _inbox(client, owner, ws["id"])
|
||||||
|
assert any(n["kind"] == "item_commented" for n in owner_inbox)
|
||||||
|
|
||||||
|
# 操作者本人不该收到
|
||||||
|
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "item_commented"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unread_count_and_read_all(client: AsyncClient) -> None:
|
||||||
|
owner = await login(client)
|
||||||
|
ws = await create_workspace(client, owner, "未读数")
|
||||||
|
helper = await _member(client, owner, ws, "member")
|
||||||
|
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第一件")
|
||||||
|
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第二件")
|
||||||
|
|
||||||
|
count = unwrap(
|
||||||
|
await client.get(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert count["unread"] == 2
|
||||||
|
|
||||||
|
first = (await _inbox(client, helper, ws["id"]))[0]
|
||||||
|
unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/notifications/{first['id']}/read",
|
||||||
|
headers=helper["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count = unwrap(
|
||||||
|
await client.get(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert count["unread"] == 1
|
||||||
|
|
||||||
|
marked = unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/notifications/read-all", headers=helper["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert marked["marked"] == 1
|
||||||
|
count = unwrap(
|
||||||
|
await client.get(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert count["unread"] == 0
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// 通知与手工开单的端到端验收。
|
||||||
|
// 两个真实用户:A 开单交给 B、B 收到通知并点开;A 改任务状态,B 也应收到提醒。
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE ?? "http://127.0.0.1:5180";
|
||||||
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||||
|
const CHROME = process.env.CHROME ?? "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
|
||||||
|
|
||||||
|
const stamp = String(Date.now()).slice(-6);
|
||||||
|
const phoneA = `135${stamp}00`;
|
||||||
|
const phoneB = `138${String((Number(stamp) + 2222) % 1e8).padStart(8, "0")}`;
|
||||||
|
const wsName = `通知${stamp}`;
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
const ok = (cond, msg) => {
|
||||||
|
if (cond) console.log(` PASS ${msg}`);
|
||||||
|
else {
|
||||||
|
failed += 1;
|
||||||
|
console.log(` FAIL ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function login(page, phone) {
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.fill("#phone", phone);
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
|
||||||
|
const code = (await page.locator("text=开发环境验证码").first().innerText()).match(/(\d{6})/)?.[1];
|
||||||
|
await page.fill("#code", code);
|
||||||
|
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: CHROME,
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||||
|
const pageA = await ctxA.newPage();
|
||||||
|
const errorsA = [];
|
||||||
|
pageA.on("console", (m) => m.type() === "error" && errorsA.push(m.text()));
|
||||||
|
pageA.on("response", (r) => r.status() >= 400 && errorsA.push(`${r.url()} ${r.status()}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(pageA, phoneA);
|
||||||
|
console.log("PASS 1. A 登录");
|
||||||
|
|
||||||
|
// 建工作区
|
||||||
|
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill(wsName);
|
||||||
|
await pageA.getByRole("button", { name: "创建" }).click();
|
||||||
|
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}/, { timeout: 20000 });
|
||||||
|
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})/)[1];
|
||||||
|
console.log("PASS 2. 建工作区");
|
||||||
|
|
||||||
|
// 邀请 B 加入
|
||||||
|
await pageA.goto(`${BASE}/w/${wsId}/members`, { waitUntil: "domcontentloaded" });
|
||||||
|
await pageA.getByRole("button", { name: "邀请成员" }).click();
|
||||||
|
await pageA.waitForSelector("text=/join/", { timeout: 15000 });
|
||||||
|
const link = await pageA
|
||||||
|
.locator("text=/join/")
|
||||||
|
.first()
|
||||||
|
.innerText()
|
||||||
|
.then((t) => t.match(/https?:\/\/\S+join\/\S+/)?.[0]);
|
||||||
|
ok(Boolean(link), "3. 拿到邀请链接");
|
||||||
|
|
||||||
|
const ctxB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||||
|
const pageB = await ctxB.newPage();
|
||||||
|
const errorsB = [];
|
||||||
|
pageB.on("console", (m) => m.type() === "error" && errorsB.push(m.text()));
|
||||||
|
pageB.on("response", (r) => r.status() >= 400 && errorsB.push(`${r.url()} ${r.status()}`));
|
||||||
|
|
||||||
|
await login(pageB, phoneB);
|
||||||
|
await pageB.goto(link, { waitUntil: "domcontentloaded" });
|
||||||
|
await pageB.waitForURL(new RegExp(`/w/${wsId}`), { timeout: 20000 });
|
||||||
|
console.log("PASS 4. B 从邀请链接登录后自动加入");
|
||||||
|
|
||||||
|
// A 手工开一张工单交给 B
|
||||||
|
await pageA.goto(`${BASE}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
|
||||||
|
await pageA.getByRole("button", { name: "新建工单" }).click();
|
||||||
|
await pageA.getByLabel("要办什么").fill(`核对 9 月发票${stamp}`);
|
||||||
|
await pageA.getByLabel("交给谁").selectOption({ index: 1 });
|
||||||
|
await pageA.getByRole("button", { name: "开单并通知对方" }).click();
|
||||||
|
await pageA.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||||
|
ok(true, "5. 手工工单开出(不经过任何任务)");
|
||||||
|
|
||||||
|
// 接口确认它确实是 kind=ticket 且没有来源任务
|
||||||
|
const created = await pageA.evaluate(
|
||||||
|
async (ws) => {
|
||||||
|
const t = localStorage.getItem("dadao.access");
|
||||||
|
const r = await fetch(`/api/v1/workspaces/${ws}/tickets?role=requested`, {
|
||||||
|
headers: { Authorization: `Bearer ${t}` },
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
},
|
||||||
|
wsId,
|
||||||
|
);
|
||||||
|
const t0 = created.data.tickets[0];
|
||||||
|
ok(t0?.kind === "ticket" && t0?.ticket?.parent_item_id === null, "6. 手工单没有来源任务");
|
||||||
|
|
||||||
|
// B 收到通知
|
||||||
|
await pageB.goto(`${BASE}/w/${wsId}/notifications`, { waitUntil: "domcontentloaded" });
|
||||||
|
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||||
|
ok(true, "7. B 收到「派给你的工单」通知");
|
||||||
|
|
||||||
|
const unreadBefore = await pageB.locator("li.card").count();
|
||||||
|
ok(unreadBefore >= 1, "8. 未读列表里有条目");
|
||||||
|
|
||||||
|
// 点开通知:跳到工单页
|
||||||
|
await pageB.locator("li.card").first().click();
|
||||||
|
await pageB.waitForURL(/\/tickets\?open=/, { timeout: 20000 });
|
||||||
|
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||||
|
ok(true, "9. 点通知跳到工单并展开那张单");
|
||||||
|
|
||||||
|
// 标记已读后未读数归零
|
||||||
|
const unread = await pageB.evaluate(async (ws) => {
|
||||||
|
const t = localStorage.getItem("dadao.access");
|
||||||
|
const r = await fetch(`/api/v1/workspaces/${ws}/notifications/unread-count`, {
|
||||||
|
headers: { Authorization: `Bearer ${t}` },
|
||||||
|
});
|
||||||
|
return (await r.json()).data.unread;
|
||||||
|
}, wsId);
|
||||||
|
ok(unread === 0, "10. 点开后未读数归零");
|
||||||
|
|
||||||
|
ok(errorsA.length === 0, `11. A 无报错${errorsA.slice(0, 2).join(" | ")}`);
|
||||||
|
ok(errorsB.length === 0, `12. B 无报错${errorsB.slice(0, 2).join(" | ")}`);
|
||||||
|
|
||||||
|
await pageB.screenshot({ path: "e2e/artifacts/notifications.png" });
|
||||||
|
await pageA.screenshot({ path: "e2e/artifacts/ticket-new.png" });
|
||||||
|
} catch (err) {
|
||||||
|
failed += 1;
|
||||||
|
console.log("FAIL 异常:", err.message);
|
||||||
|
await pageA.screenshot({ path: "e2e/artifacts/notifications-fail-a.png" }).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failed === 0 ? "\nALL PASS" : `\n${failed} 项失败`);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// 只为截图:登录一个已有通知的账号,截「通知页」与「新建工单」面板。
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const BASE = "http://127.0.0.1:5180";
|
||||||
|
const API = "http://127.0.0.1:8100/api/v1";
|
||||||
|
const CHROME = "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
|
||||||
|
|
||||||
|
const login = async (phone) => {
|
||||||
|
const send = await fetch(`${API}/auth/sms/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
const code = send.data?.dev_code ?? "123456";
|
||||||
|
const r = await fetch(`${API}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone, code }),
|
||||||
|
}).then((x) => x.json());
|
||||||
|
return r.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||||
|
|
||||||
|
const auth = await login("13900009002");
|
||||||
|
const me = await fetch(`${API}/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.tokens.access_token}` },
|
||||||
|
}).then((r) => r.json());
|
||||||
|
const ws = me.data.workspaces[0];
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.evaluate(
|
||||||
|
([a, r]) => {
|
||||||
|
localStorage.setItem("dadao.access", a);
|
||||||
|
localStorage.setItem("dadao.refresh", r);
|
||||||
|
},
|
||||||
|
[auth.tokens.access_token, auth.tokens.refresh_token],
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/w/${ws.id}/notifications`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForSelector("ul li", { timeout: 20000 });
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/ui-notifications.png" });
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/w/${ws.id}/tickets`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "新建工单" }).click();
|
||||||
|
await page.waitForSelector("text=交给谁", { timeout: 15000 });
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/ui-ticket-new.png" });
|
||||||
|
|
||||||
|
console.log("done", ws.name);
|
||||||
|
await browser.close();
|
||||||
@@ -10,6 +10,7 @@ import { TasksPage } from "./pages/Tasks";
|
|||||||
import { BoardPage } from "./pages/Board";
|
import { BoardPage } from "./pages/Board";
|
||||||
import { TicketsPage } from "./pages/Tickets";
|
import { TicketsPage } from "./pages/Tickets";
|
||||||
import { KnowledgePage } from "./pages/Knowledge";
|
import { KnowledgePage } from "./pages/Knowledge";
|
||||||
|
import { NotificationsPage } from "./pages/Notifications";
|
||||||
import { JoinPage } from "./pages/Join";
|
import { JoinPage } from "./pages/Join";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -90,6 +91,7 @@ export function App() {
|
|||||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
||||||
|
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
||||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Workspace } from "../lib/types";
|
|||||||
import {
|
import {
|
||||||
Book,
|
Book,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Bell,
|
||||||
Columns,
|
Columns,
|
||||||
Inbox,
|
Inbox,
|
||||||
Logo,
|
Logo,
|
||||||
@@ -15,13 +16,25 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
|
|
||||||
export type Section = "tasks" | "board" | "tickets" | "knowledge" | "members";
|
export type Section =
|
||||||
|
| "tasks"
|
||||||
|
| "board"
|
||||||
|
| "tickets"
|
||||||
|
| "knowledge"
|
||||||
|
| "notifications"
|
||||||
|
| "members";
|
||||||
|
|
||||||
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
||||||
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
||||||
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns size={16} /> },
|
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns size={16} /> },
|
||||||
{ key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
|
{ key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
|
||||||
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book size={16} /> },
|
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book size={16} /> },
|
||||||
|
{
|
||||||
|
key: "notifications",
|
||||||
|
label: "通知",
|
||||||
|
to: (id) => `/w/${id}/notifications`,
|
||||||
|
icon: <Bell size={16} />,
|
||||||
|
},
|
||||||
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -73,16 +86,22 @@ export function AppShell({
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`);
|
const r = await api.get<{ unread: number }>(
|
||||||
if (alive) setUnread(r.count);
|
`/workspaces/${wsId}/notifications/unread-count`,
|
||||||
|
);
|
||||||
|
if (alive) setUnread(r.unread ?? 0);
|
||||||
} catch {
|
} catch {
|
||||||
/* 未读数失败不该影响主流程 */
|
/* 未读数失败不该影响主流程 */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// 通知页标了已读之后立刻刷新角标,不必等下一个轮询周期
|
||||||
|
const onChange = () => void load();
|
||||||
void load();
|
void load();
|
||||||
|
window.addEventListener("notifications:changed", onChange);
|
||||||
const timer = window.setInterval(load, 30_000);
|
const timer = window.setInterval(load, 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
window.removeEventListener("notifications:changed", onChange);
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [wsId]);
|
}, [wsId]);
|
||||||
@@ -174,7 +193,7 @@ export function AppShell({
|
|||||||
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
|
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
|
||||||
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
|
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
|
||||||
{n.label}
|
{n.label}
|
||||||
{n.key === "tickets" && unread > 0 ? (
|
{n.key === "notifications" && unread > 0 ? (
|
||||||
<span
|
<span
|
||||||
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
|
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
|
||||||
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
|
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ export const Inbox = (p: IconProps) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const Bell = (p: IconProps) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M6 9a6 6 0 1 1 12 0c0 3.2.8 4.9 1.6 5.9.4.5 0 1.1-.6 1.1H5c-.6 0-1-.6-.6-1.1C5.2 13.9 6 12.2 6 9Z" />
|
||||||
|
<path d="M10 19a2 2 0 0 0 4 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const Sparkle = (p: IconProps) => (
|
export const Sparkle = (p: IconProps) => (
|
||||||
<svg {...base(p)}>
|
<svg {...base(p)}>
|
||||||
<path d="M12 3.5 13.6 9l5.4 1.6-5.4 1.6L12 17.5l-1.6-5.3L5 10.6 10.4 9Z" />
|
<path d="M12 3.5 13.6 9l5.4 1.6-5.4 1.6L12 17.5l-1.6-5.3L5 10.6 10.4 9Z" />
|
||||||
|
|||||||
@@ -170,3 +170,15 @@ export interface SearchHit {
|
|||||||
vector_score: number;
|
vector_score: number;
|
||||||
lexical_score: number;
|
lexical_score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 站内通知:只有"谁做了什么"和"指向哪条事项",详情永远回源取。 */
|
||||||
|
export interface AppNotification {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
item_id: string | null;
|
||||||
|
item_title: string | null;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
read_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { AppShell } from "../components/AppShell";
|
||||||
|
import { Bell, Check, TicketIcon, Inbox, Clock } from "../components/icons";
|
||||||
|
import { Button, EmptyState, PageHeader, SkeletonRows } from "../components/ui";
|
||||||
|
import { api, ApiError } from "../lib/client";
|
||||||
|
import type { AppNotification } from "../lib/types";
|
||||||
|
|
||||||
|
const PAGE = 30;
|
||||||
|
|
||||||
|
/** 工单类通知点开跳到工单页,其余回任务页——都带 `?open=`,落地即展开那一条。 */
|
||||||
|
function targetOf(wsId: string, n: AppNotification): string {
|
||||||
|
if (!n.item_id) return `/w/${wsId}/notifications`;
|
||||||
|
const isTicket = n.kind.startsWith("ticket_");
|
||||||
|
return isTicket ? `/w/${wsId}/tickets?open=${n.item_id}` : `/w/${wsId}?open=${n.item_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconOf(kind: string) {
|
||||||
|
if (kind.startsWith("ticket_")) return <TicketIcon size={15} />;
|
||||||
|
if (kind === "item_commented") return <Inbox size={15} />;
|
||||||
|
return <Bell size={15} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ago(iso: string): string {
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const diff = Math.max(0, Date.now() - then);
|
||||||
|
const min = Math.floor(diff / 60_000);
|
||||||
|
if (min < 1) return "刚刚";
|
||||||
|
if (min < 60) return `${min} 分钟前`;
|
||||||
|
const hour = Math.floor(min / 60);
|
||||||
|
if (hour < 24) return `${hour} 小时前`;
|
||||||
|
const day = Math.floor(hour / 24);
|
||||||
|
if (day < 30) return `${day} 天前`;
|
||||||
|
return new Date(iso).toLocaleDateString("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知页:默认只看未读——"全部"是查历史用的,不是日常入口。 */
|
||||||
|
export function NotificationsPage() {
|
||||||
|
const { wsId = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState<"unread" | "all">("unread");
|
||||||
|
const [rows, setRows] = useState<AppNotification[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (reset: boolean) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
unread_only: tab === "unread" ? "true" : "false",
|
||||||
|
limit: String(PAGE),
|
||||||
|
});
|
||||||
|
if (!reset && cursor) params.set("cursor", cursor);
|
||||||
|
const page = await api.get<{
|
||||||
|
notifications: AppNotification[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
}>(`/workspaces/${wsId}/notifications?${params.toString()}`);
|
||||||
|
setRows((prev) => (reset ? page.notifications : [...prev, ...page.notifications]));
|
||||||
|
setCursor(page.next_cursor);
|
||||||
|
setHasMore(page.has_more);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "通知加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[wsId, tab, cursor],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRows([]);
|
||||||
|
setCursor(null);
|
||||||
|
void load(true);
|
||||||
|
// 只在切 tab / 换工作区时重拉,翻页由按钮触发
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [wsId, tab]);
|
||||||
|
|
||||||
|
const unreadCount = useMemo(() => rows.filter((r) => !r.read_at).length, [rows]);
|
||||||
|
|
||||||
|
async function markRead(n: AppNotification) {
|
||||||
|
if (!n.read_at) {
|
||||||
|
try {
|
||||||
|
await api.post(`/workspaces/${wsId}/notifications/${n.id}/read`);
|
||||||
|
} catch {
|
||||||
|
/* 标已读失败不拦着用户点进去看 */
|
||||||
|
}
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (r.id === n.id ? { ...r, read_at: new Date().toISOString() } : r)),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new Event("notifications:changed"));
|
||||||
|
}
|
||||||
|
navigate(targetOf(wsId, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAll() {
|
||||||
|
try {
|
||||||
|
await api.post(`/workspaces/${wsId}/notifications/read-all`);
|
||||||
|
setRows((prev) => prev.map((r) => ({ ...r, read_at: r.read_at ?? new Date().toISOString() })));
|
||||||
|
window.dispatchEvent(new Event("notifications:changed"));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell wsId={wsId} active="notifications">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="事情有动静时,这里会告诉你"
|
||||||
|
title="通知"
|
||||||
|
desc="工单被接受、提交、验收,任务被指派、改状态,有人在你关注的事情下说话,都会落在这里。"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void load(true)} disabled={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle" size="sm" onClick={() => void markAll()} disabled={!unreadCount}>
|
||||||
|
全部已读
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-center gap-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["unread", "未读"],
|
||||||
|
["all", "全部"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs transition ${
|
||||||
|
tab === key
|
||||||
|
? "bg-ink-900 font-medium text-white"
|
||||||
|
: "text-ink-500 hover:bg-ink-100 hover:text-ink-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tab === "unread" && unreadCount > 0 ? (
|
||||||
|
<span className="ml-auto text-2xs text-ink-400">还有 {unreadCount} 条没看</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mb-3 rounded-xl border border-rust-100 bg-rust-50 px-4 py-3 text-sm text-rust-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading && rows.length === 0 ? (
|
||||||
|
<SkeletonRows rows={4} />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<EmptyState
|
||||||
|
icon={<Bell size={20} />}
|
||||||
|
title={tab === "unread" ? "没有未读通知" : "还没有通知"}
|
||||||
|
hint={
|
||||||
|
tab === "unread"
|
||||||
|
? "有人给你派活、或你关注的事情有进展时,会出现在这里。"
|
||||||
|
: "这里会留下所有历史通知,方便回头找。"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rows.map((n) => (
|
||||||
|
<li key={n.id} className={`card ${n.read_at ? "" : "border-primary-100 bg-primary-50/40"}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => void markRead(n)}
|
||||||
|
className="flex w-full items-start gap-3 px-4 py-3 text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full ${
|
||||||
|
n.read_at ? "bg-ink-100 text-ink-500" : "bg-primary-500 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{iconOf(n.kind)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-baseline gap-2">
|
||||||
|
<span className="truncate text-sm font-medium text-ink-900">{n.title}</span>
|
||||||
|
{!n.read_at ? (
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-clay-500" />
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto shrink-0 text-2xs text-ink-400">
|
||||||
|
{ago(n.created_at)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{n.body ? (
|
||||||
|
<span className="mt-1 block truncate text-xs text-ink-500">{n.body}</span>
|
||||||
|
) : null}
|
||||||
|
{n.item_title ? (
|
||||||
|
<span className="mt-1.5 inline-flex items-center gap-1 text-2xs text-ink-400">
|
||||||
|
<Clock size={11} />
|
||||||
|
{n.item_title}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMore ? (
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void load(false)} disabled={loading}>
|
||||||
|
加载更早
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : rows.length > 0 && !hasMore ? (
|
||||||
|
<p className="mt-4 text-center text-2xs text-ink-300">
|
||||||
|
<Check size={11} className="mr-1 inline" />
|
||||||
|
没有更多了
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { AppShell } from "../components/AppShell";
|
import { AppShell } from "../components/AppShell";
|
||||||
import { TaskDetail } from "../components/TaskDetail";
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
import { Check, Inbox, Plus, Search } from "../components/icons";
|
import { Check, Inbox, Plus, Search } from "../components/icons";
|
||||||
@@ -41,7 +41,9 @@ export function TasksPage() {
|
|||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
||||||
const [activeIdx, setActiveIdx] = useState(0);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
const reqId = useRef(0);
|
const reqId = useRef(0);
|
||||||
|
|
||||||
@@ -333,7 +335,10 @@ export function TasksPage() {
|
|||||||
<TaskDetail
|
<TaskDetail
|
||||||
wsId={wsId}
|
wsId={wsId}
|
||||||
itemId={openId}
|
itemId={openId}
|
||||||
onClose={() => setOpenId(null)}
|
onClose={() => {
|
||||||
|
setOpenId(null);
|
||||||
|
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||||
|
}}
|
||||||
onChanged={(updated) =>
|
onChanged={(updated) =>
|
||||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-7
@@ -1,12 +1,21 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { AppShell } from "../components/AppShell";
|
import { AppShell } from "../components/AppShell";
|
||||||
import { TaskDetail } from "../components/TaskDetail";
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
import { ChevronRight, TicketIcon } from "../components/icons";
|
import { ChevronRight, Plus, TicketIcon } from "../components/icons";
|
||||||
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
EmptyState,
|
||||||
|
InlineAlert,
|
||||||
|
PageHeader,
|
||||||
|
Sheet,
|
||||||
|
SkeletonRows,
|
||||||
|
StatusPill,
|
||||||
|
useToast,
|
||||||
|
} from "../components/ui";
|
||||||
import { ApiError, api } from "../lib/client";
|
import { ApiError, api } from "../lib/client";
|
||||||
import { STATUS_LABEL } from "../lib/parse";
|
import { STATUS_LABEL } from "../lib/parse";
|
||||||
import type { Item, Ticket } from "../lib/types";
|
import type { Item, Member, Ticket } from "../lib/types";
|
||||||
|
|
||||||
const ROLES = [
|
const ROLES = [
|
||||||
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
||||||
@@ -25,14 +34,43 @@ const STATUS_FILTERS = [
|
|||||||
|
|
||||||
export function TicketsPage() {
|
export function TicketsPage() {
|
||||||
const { wsId = "" } = useParams();
|
const { wsId = "" } = useParams();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
|
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
||||||
|
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
||||||
const reqId = useRef(0);
|
const reqId = useRef(0);
|
||||||
|
const { push: toast } = useToast();
|
||||||
|
|
||||||
|
// 手工开单:这条路径不经过任何任务,所以人得自己选
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [formTitle, setFormTitle] = useState("");
|
||||||
|
const [formBody, setFormBody] = useState("");
|
||||||
|
const [formAssignee, setFormAssignee] = useState("");
|
||||||
|
const [formPriority, setFormPriority] = useState(1);
|
||||||
|
const [formDue, setFormDue] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wsId) return;
|
||||||
|
let alive = true;
|
||||||
|
void api
|
||||||
|
.get<{ items: Member[] }>(`/workspaces/${wsId}/members?limit=100`)
|
||||||
|
.then((page) => {
|
||||||
|
if (alive) setMembers(page.items);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 成员拉不到时,表单里就没人可选,但不该拦住列表 */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [wsId]);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (nextCursor: string | null) => {
|
async (nextCursor: string | null) => {
|
||||||
@@ -75,7 +113,8 @@ export function TicketsPage() {
|
|||||||
void load(null);
|
void load(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 4009) {
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
setError("这条刚被别人改过,已为你刷新列表"); void load(null);
|
setError("这条刚被别人改过,已为你刷新列表");
|
||||||
|
void load(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||||
@@ -84,6 +123,41 @@ export function TicketsPage() {
|
|||||||
|
|
||||||
const currentRole = ROLES.find((r) => r.key === role);
|
const currentRole = ROLES.find((r) => r.key === role);
|
||||||
|
|
||||||
|
async function createTicket() {
|
||||||
|
if (!formTitle.trim()) {
|
||||||
|
setError("工单得有个标题,别人才能知道要办什么");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formAssignee) {
|
||||||
|
setError("先选一个人——工单不指派就等于没人接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await api.post<Ticket>(`/workspaces/${wsId}/tickets`, {
|
||||||
|
title: formTitle.trim(),
|
||||||
|
body_md: formBody.trim() || null,
|
||||||
|
assignee_id: formAssignee,
|
||||||
|
priority: formPriority,
|
||||||
|
due_at: formDue ? new Date(formDue).toISOString() : null,
|
||||||
|
});
|
||||||
|
setCreating(false);
|
||||||
|
setFormTitle("");
|
||||||
|
setFormBody("");
|
||||||
|
setFormAssignee("");
|
||||||
|
setFormDue("");
|
||||||
|
setFormPriority(1);
|
||||||
|
setRole("requested");
|
||||||
|
toast("工单已开出,对方会收到通知", "success");
|
||||||
|
setOpenId(created.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "开单失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell wsId={wsId} active="tickets">
|
<AppShell wsId={wsId} active="tickets">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -91,6 +165,7 @@ export function TicketsPage() {
|
|||||||
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
||||||
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
||||||
actions={
|
actions={
|
||||||
|
<>
|
||||||
<select
|
<select
|
||||||
className="input h-9 w-36 text-sm"
|
className="input h-9 w-36 text-sm"
|
||||||
value={status}
|
value={status}
|
||||||
@@ -103,6 +178,11 @@ export function TicketsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
新建工单
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -218,11 +298,117 @@ export function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 手工开单:不经过任务,直接把一件要别人帮忙的事开成工单 */}
|
||||||
|
<Sheet
|
||||||
|
open={creating}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
title="新建工单"
|
||||||
|
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setCreating(false)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => void createTicket()} disabled={saving}>
|
||||||
|
{saving ? "正在开单…" : "开单并通知对方"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="ticket-title">
|
||||||
|
要办什么
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ticket-title"
|
||||||
|
className="input"
|
||||||
|
value={formTitle}
|
||||||
|
onChange={(e) => setFormTitle(e.target.value)}
|
||||||
|
placeholder="一句能直接看懂的话,例如:核对 9 月发票与报销单"
|
||||||
|
maxLength={200}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="ticket-body">
|
||||||
|
详细说明(可选)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ticket-body"
|
||||||
|
className="input min-h-[96px] resize-y text-sm leading-6"
|
||||||
|
value={formBody}
|
||||||
|
onChange={(e) => setFormBody(e.target.value)}
|
||||||
|
placeholder="背景、已有的线索、验收标准…对方看到的就是这些"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="ticket-assignee">
|
||||||
|
交给谁
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ticket-assignee"
|
||||||
|
className="input"
|
||||||
|
value={formAssignee}
|
||||||
|
onChange={(e) => setFormAssignee(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">选择一个人…</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.user_id} value={m.user_id}>
|
||||||
|
{m.nickname || m.user_id.slice(0, 8)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="ticket-priority">
|
||||||
|
优先级
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ticket-priority"
|
||||||
|
className="input"
|
||||||
|
value={formPriority}
|
||||||
|
onChange={(e) => setFormPriority(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={0}>无</option>
|
||||||
|
<option value={1}>低</option>
|
||||||
|
<option value={2}>中</option>
|
||||||
|
<option value={3}>高</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="ticket-due">
|
||||||
|
希望什么时候办好(可选)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ticket-due"
|
||||||
|
type="datetime-local"
|
||||||
|
className="input"
|
||||||
|
value={formDue}
|
||||||
|
onChange={(e) => setFormDue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="rounded-xl border border-line-200 bg-paper-200/60 px-3.5 py-2.5 text-2xs leading-5 text-ink-500">
|
||||||
|
开出后对方会收到通知,并按「待接 → 办理中 → 待验收 → 完成」推进。
|
||||||
|
手工开的单没有来源任务,验收通过后不会回流任何任务,但同样会沉淀一份知识草稿。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
{openId && (
|
{openId && (
|
||||||
<TaskDetail
|
<TaskDetail
|
||||||
wsId={wsId}
|
wsId={wsId}
|
||||||
itemId={openId}
|
itemId={openId}
|
||||||
onClose={() => setOpenId(null)}
|
onClose={() => {
|
||||||
|
setOpenId(null);
|
||||||
|
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||||
|
}}
|
||||||
onChanged={(updated: Item) =>
|
onChanged={(updated: Item) =>
|
||||||
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user