diff --git a/2.Design/API.md b/2.Design/API.md index 2c2969c..50f20cb 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -227,6 +227,20 @@ canceled→ todo (恢复) 工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。 所以详情仍然走 `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`(任务升级成工单) ```json @@ -286,8 +300,26 @@ canceled→ todo (恢复) | POST | `/notifications/{id}/read` | 标一条 | | 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 工单的错误码 diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index a8a07bb..d9b2e13 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -189,8 +189,8 @@ - [ ] 下拉刷新、只读缓存(Room)未做 ### 1.6 通知与提醒(0.8d) -- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收), - M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。 +- [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程); + 未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。 - [ ] 每日摘要邮件(同上推迟) ### 1.7 测试与验收(0.7d) @@ -230,6 +230,8 @@ - [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}` - [x] `GET /tickets?role=assigned|requested|all&status=` - [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}` +- [x] `POST /tickets` 手工开单:不经过任务直接指定人,初始 `open`,与升级单共用同一条 item +- [x] 指派前校验"在册成员"(`_ensure_member`),否则任务会挂在一个没人看得到的名字上 - [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all` - [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"** @@ -238,17 +240,27 @@ - [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数 - [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞") - [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新) +- [x] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条 +- [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开 - [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页" ### 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) - [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、 自验 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 项断言全绿: 升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错 +- [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 → + 这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 → + 未读数归零 → 两端控制台零报错 - [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿 ### 2.7 通知真实投递 diff --git a/2.Design/screens/android-notifications.png b/2.Design/screens/android-notifications.png new file mode 100644 index 0000000..fb92a68 Binary files /dev/null and b/2.Design/screens/android-notifications.png differ diff --git a/2.Design/screens/android-ticket-new.png b/2.Design/screens/android-ticket-new.png new file mode 100644 index 0000000..7eadf1a Binary files /dev/null and b/2.Design/screens/android-ticket-new.png differ diff --git a/2.Design/screens/android-tickets-role.png b/2.Design/screens/android-tickets-role.png new file mode 100644 index 0000000..874449b Binary files /dev/null and b/2.Design/screens/android-tickets-role.png differ diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt index cc9e504..e70e46b 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -27,6 +27,7 @@ import com.dadao.task.ui.HomeScreen import com.dadao.task.ui.KnowledgeScreen import com.dadao.task.ui.LoginScreen import com.dadao.task.ui.MembersScreen +import com.dadao.task.ui.NotificationsScreen import com.dadao.task.ui.RichTextEditorScreen import com.dadao.task.ui.TaskListScreen import com.dadao.task.ui.TicketsScreen @@ -71,6 +72,8 @@ private fun AppRoot(joinUri: String?) { var current by remember { mutableStateOf(null) } var tab by remember { mutableStateOf(WorkspaceTab.Tasks) } var unread by remember { mutableStateOf(0) } + // 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect + var unreadRefresh by remember { mutableStateOf(0) } // 打开富文本编辑器时非空;关掉就恢复原来的界面 var editing by remember { mutableStateOf(null) } // 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉 @@ -105,8 +108,8 @@ private fun AppRoot(joinUri: String?) { .onFailure { pendingJoin = null } } - // 工单未读数:进工作区就拉一次,切页面时再刷。 - LaunchedEffect(current?.id, tab) { + // 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新 + LaunchedEffect(current?.id, tab, unreadRefresh) { val ws = current ?: return@LaunchedEffect unread = api.unreadCount(ws.id) } @@ -184,7 +187,12 @@ private fun AppRoot(joinUri: String?) { 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( api = api, workspace = ws, @@ -194,6 +202,18 @@ private fun AppRoot(joinUri: String?) { ) 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 }, + ) } } } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index d303ea9..97761f5 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -258,6 +258,25 @@ data class KnowledgePage( // ---- 请求体。每个都要 @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 = emptyList(), + @SerialName("next_cursor") val nextCursor: String? = null, + @SerialName("has_more") val hasMore: Boolean = false, +) + @Serializable private data class UnreadCount(@SerialName("unread") val unread: Int = 0) @@ -311,6 +330,15 @@ private data class KnowledgeCreateRequest( @Serializable 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 private data class ItemUpdateRequest( val version: Int, @@ -427,6 +455,48 @@ class Api(private val tokens: TokenStore) { 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,不要因为角标让整页报错。 */ suspend fun unreadCount(workspaceId: String): Int = runCatching { call(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread diff --git a/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt b/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt index 0e1e639..d424c22 100644 --- a/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt +++ b/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt @@ -29,11 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.dadao.task.data.Workspace -/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */ +/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */ enum class WorkspaceTab(val label: String) { Tasks("任务"), Tickets("工单"), Knowledge("知识"), + Notifications("通知"), Members("成员"), } diff --git a/android/app/src/main/java/com/dadao/task/ui/NotificationsScreen.kt b/android/app/src/main/java/com/dadao/task/ui/NotificationsScreen.kt new file mode 100644 index 0000000..96f19fb --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/NotificationsScreen.kt @@ -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>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt index 6106468..1c3bb19 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt @@ -1,5 +1,6 @@ package com.dadao.task.ui +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog @@ -31,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.dadao.task.data.Api import com.dadao.task.data.ApiException +import com.dadao.task.data.Member import com.dadao.task.data.Ticket import com.dadao.task.data.Workspace import kotlinx.coroutines.launch @@ -42,7 +45,12 @@ private val ticketRoles = listOf( ) @Composable -fun TicketsScreen(api: Api, workspace: Workspace) { +fun TicketsScreen( + api: Api, + workspace: Workspace, + focusId: String? = null, + onFocusHandled: () -> Unit = {}, +) { val scope = rememberCoroutineScope() var role by remember { mutableStateOf("assigned") } var tickets by remember { mutableStateOf>(emptyList()) } @@ -52,6 +60,16 @@ fun TicketsScreen(api: Api, workspace: Workspace) { var note by remember { mutableStateOf("") } var resolution by remember { mutableStateOf("") } + // 手工开单:不经过任务,人得自己选 + var members by remember { mutableStateOf>(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() { loading = true runCatching { api.tickets(workspace.id, role) } @@ -65,6 +83,18 @@ fun TicketsScreen(api: Api, workspace: Workspace) { 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) { val code = (e as? ApiException)?.code ?: 0 message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" } @@ -95,10 +125,20 @@ fun TicketsScreen(api: Api, workspace: Workspace) { modifier = Modifier.padding(top = 4.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) -> 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)) + 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)) { when (ticket.status) { - "open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") }) - "accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") }) - "in_progress" -> PrimaryButton("提交验收", onClick = { - scope.launch { - runCatching { api.ticketAction(workspace.id, ticket.id, "submit") } - .onSuccess { - message = null - reload() - } - .onFailure { fail(it) } - } - }) - "pending_verify" -> { + "open" -> if (canWork) { + PrimaryButton("接收", onClick = { act(ticket, "accept") }) + } + "accepted" -> if (canWork) { + PrimaryButton("开始办理", onClick = { act(ticket, "start") }) + } + "in_progress" -> if (canWork) { + PrimaryButton("提交验收", onClick = { act(ticket, "submit") }) + } + "pending_verify" -> if (canVerify) { PrimaryButton("通过验收", onClick = { verifyTarget = ticket 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)) } } + 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 if (target != null) { AlertDialog( diff --git a/backend/app/api/items.py b/backend/app/api/items.py index 3163e64..59831d5 100644 --- a/backend/app/api/items.py +++ b/backend/app/api/items.py @@ -27,10 +27,12 @@ from ..domain.task import ( view_statuses, ) from ..errors import AppError, ErrorCode +from ..models import Member from ..models_task import Activity, Comment, Item, ItemTag, Tag from ..models_ticket import TicketExt from ..schemas import Pagination, encode_cursor, ok from ..serializers import item_out, tag_out, tags_of +from ..services.notify import TASK_STATUS_CN, notify from .attachments import count_for_items, list_for_item router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"]) @@ -98,6 +100,25 @@ async def _load_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( session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID] ) -> 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} 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( id=uuid.uuid4(), workspace_id=ctx.ws_id, @@ -470,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) - if body.tag_names: await _set_tags(session, ctx.ws_id, item, body.tag_names) await _log(session, item, ctx.user.id, "created", {"status": item.status}) + 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]) return ok(_item_out(item, tags.get(item.id)), message="已创建") @@ -576,6 +611,7 @@ async def update_item( values["assignee_id"] = None changes["assignee_id"] = None 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 changes["assignee_id"] = str(body.assignee_id) if body.clear_due_at: @@ -605,6 +641,18 @@ async def update_item( ) 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]) return ok(_item_out(item, tags.get(item.id)), message="已保存") @@ -634,6 +682,20 @@ async def change_status( payload={"from": previous, "to": body.status}, ) 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="状态已更新") @@ -671,6 +733,16 @@ async def add_comment( session.add(comment) await session.flush() 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( { "id": str(comment.id), diff --git a/backend/app/api/tickets.py b/backend/app/api/tickets.py index df6575e..86b0c9f 100644 --- a/backend/app/api/tickets.py +++ b/backend/app/api/tickets.py @@ -39,6 +39,7 @@ from ..models_task import Activity, Item from ..models_ticket import ItemLink, Notification, TicketExt from ..schemas import Pagination, encode_cursor, ok from ..serializers import item_out, tags_of +from ..services.notify import notify router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"]) notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"]) @@ -60,6 +61,16 @@ class ReassignIn(BaseModel): 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): approve: bool note: str = Field(default="", max_length=MAX_REJECT_NOTE) @@ -139,16 +150,18 @@ async def _notify( item_id: uuid.UUID | None, title: str, body: str = "", + actor_id: uuid.UUID | None = None, ) -> None: - session.add( - Notification( - workspace_id=ws_id, - user_id=user_id, - kind=kind, - item_id=item_id, - title=title[:120], - body=body[:300], - ) + """单人便捷入口,实际写入交给 services.notify(去重与跳过自己在那里做)。""" + await notify( + session, + ws_id=ws_id, + user_ids=(user_id,), + kind=kind, + item_id=item_id, + title=title, + body=body, + actor_id=actor_id, ) @@ -520,6 +533,67 @@ async def reassign( 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="工单列表:我指派的 / 我发起的") async def list_tickets( diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py new file mode 100644 index 0000000..2dbfe2c --- /dev/null +++ b/backend/app/services/notify.py @@ -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 diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..756f2c5 --- /dev/null +++ b/backend/tests/test_notifications.py @@ -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 diff --git a/web/e2e/notifications.mjs b/web/e2e/notifications.mjs new file mode 100644 index 0000000..3ca77ca --- /dev/null +++ b/web/e2e/notifications.mjs @@ -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); diff --git a/web/e2e/shots-notif.mjs b/web/e2e/shots-notif.mjs new file mode 100644 index 0000000..90d001c --- /dev/null +++ b/web/e2e/shots-notif.mjs @@ -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(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 3d0e936..8882d67 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { TasksPage } from "./pages/Tasks"; import { BoardPage } from "./pages/Board"; import { TicketsPage } from "./pages/Tickets"; import { KnowledgePage } from "./pages/Knowledge"; +import { NotificationsPage } from "./pages/Notifications"; import { JoinPage } from "./pages/Join"; interface AuthState { @@ -90,6 +91,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 4191a27..18a1261 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -6,6 +6,7 @@ import type { Workspace } from "../lib/types"; import { Book, ChevronDown, + Bell, Columns, Inbox, Logo, @@ -15,13 +16,25 @@ import { X, } 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 }[] = [ { key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: }, { key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: }, { key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: }, { key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: }, + { + key: "notifications", + label: "通知", + to: (id) => `/w/${id}/notifications`, + icon: , + }, { key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: }, ]; @@ -73,16 +86,22 @@ export function AppShell({ let alive = true; const load = async () => { try { - const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`); - if (alive) setUnread(r.count); + const r = await api.get<{ unread: number }>( + `/workspaces/${wsId}/notifications/unread-count`, + ); + if (alive) setUnread(r.unread ?? 0); } catch { /* 未读数失败不该影响主流程 */ } }; + // 通知页标了已读之后立刻刷新角标,不必等下一个轮询周期 + const onChange = () => void load(); void load(); + window.addEventListener("notifications:changed", onChange); const timer = window.setInterval(load, 30_000); return () => { alive = false; + window.removeEventListener("notifications:changed", onChange); window.clearInterval(timer); }; }, [wsId]); @@ -174,7 +193,7 @@ export function AppShell({ {n.icon} {n.label} - {n.key === "tickets" && unread > 0 ? ( + {n.key === "notifications" && unread > 0 ? ( ( ); +export const Bell = (p: IconProps) => ( + + + + +); + export const Sparkle = (p: IconProps) => ( diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6687bbf..3fc5eca 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -170,3 +170,15 @@ export interface SearchHit { vector_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; +} \ No newline at end of file diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx new file mode 100644 index 0000000..9a970e1 --- /dev/null +++ b/web/src/pages/Notifications.tsx @@ -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 ; + if (kind === "item_commented") return ; + return ; +} + +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([]); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + + + } + /> + +
+ {( + [ + ["unread", "未读"], + ["all", "全部"], + ] as const + ).map(([key, label]) => ( + + ))} + {tab === "unread" && unreadCount > 0 ? ( + 还有 {unreadCount} 条没看 + ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading && rows.length === 0 ? ( + + ) : rows.length === 0 ? ( +
+ } + title={tab === "unread" ? "没有未读通知" : "还没有通知"} + hint={ + tab === "unread" + ? "有人给你派活、或你关注的事情有进展时,会出现在这里。" + : "这里会留下所有历史通知,方便回头找。" + } + /> +
+ ) : ( +
    + {rows.map((n) => ( +
  • + +
  • + ))} +
+ )} + + {hasMore ? ( +
+ +
+ ) : rows.length > 0 && !hasMore ? ( +

+ + 没有更多了 +

+ ) : null} + + ); +} diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx index a3afb52..950148d 100644 --- a/web/src/pages/Tasks.tsx +++ b/web/src/pages/Tasks.tsx @@ -1,5 +1,5 @@ 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 { TaskDetail } from "../components/TaskDetail"; import { Check, Inbox, Plus, Search } from "../components/icons"; @@ -41,7 +41,9 @@ export function TasksPage() { const [draft, setDraft] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [openId, setOpenId] = useState(null); + // 从通知点进来时带 ?open=,落地即展开那一条 + const [searchParams, setSearchParams] = useSearchParams(); + const [openId, setOpenId] = useState(searchParams.get("open")); const [activeIdx, setActiveIdx] = useState(0); const reqId = useRef(0); @@ -333,7 +335,10 @@ export function TasksPage() { setOpenId(null)} + onClose={() => { + setOpenId(null); + if (searchParams.get("open")) setSearchParams({}, { replace: true }); + }} onChanged={(updated) => setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it))) } diff --git a/web/src/pages/Tickets.tsx b/web/src/pages/Tickets.tsx index 7f0cc5f..9b0dc70 100644 --- a/web/src/pages/Tickets.tsx +++ b/web/src/pages/Tickets.tsx @@ -1,12 +1,21 @@ 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 { TaskDetail } from "../components/TaskDetail"; -import { ChevronRight, TicketIcon } from "../components/icons"; -import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui"; +import { ChevronRight, Plus, TicketIcon } from "../components/icons"; +import { + Button, + EmptyState, + InlineAlert, + PageHeader, + Sheet, + SkeletonRows, + StatusPill, + useToast, +} from "../components/ui"; import { ApiError, api } from "../lib/client"; import { STATUS_LABEL } from "../lib/parse"; -import type { Item, Ticket } from "../lib/types"; +import type { Item, Member, Ticket } from "../lib/types"; const ROLES = [ { key: "assigned", label: "派给我的", desc: "别人等你的" }, @@ -25,14 +34,43 @@ const STATUS_FILTERS = [ export function TicketsPage() { const { wsId = "" } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned"); const [status, setStatus] = useState(""); const [tickets, setTickets] = useState([]); const [cursor, setCursor] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [openId, setOpenId] = useState(null); + // 从通知点进来时带 ?open=,落地即展开那一条 + const [openId, setOpenId] = useState(searchParams.get("open")); const reqId = useRef(0); + const { push: toast } = useToast(); + + // 手工开单:这条路径不经过任何任务,所以人得自己选 + const [members, setMembers] = useState([]); + 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( async (nextCursor: string | null) => { @@ -75,7 +113,8 @@ export function TicketsPage() { void load(null); } catch (err) { if (err instanceof ApiError && err.code === 4009) { - setError("这条刚被别人改过,已为你刷新列表"); void load(null); + setError("这条刚被别人改过,已为你刷新列表"); + void load(null); return; } setError(err instanceof ApiError ? err.message : "操作失败"); @@ -84,6 +123,41 @@ export function TicketsPage() { 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(`/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 ( 需要别人搭把手的事,都在这里} actions={ - + <> + + + } /> @@ -218,11 +298,117 @@ export function TicketsPage() { )} + {/* 手工开单:不经过任务,直接把一件要别人帮忙的事开成工单 */} + setCreating(false)} + title="新建工单" + subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。" + footer={ + <> + + + + } + > +
+
+ + setFormTitle(e.target.value)} + placeholder="一句能直接看懂的话,例如:核对 9 月发票与报销单" + maxLength={200} + autoFocus + /> +
+ +
+ +