feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
CI / backend (push) Canceled after 0s

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:
Steven
2026-10-30 02:19:42 +08:00
parent d079564d6c
commit 91b3dde670
23 changed files with 1674 additions and 60 deletions
+34 -2
View File
@@ -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 工单的错误码
+16 -4
View File
@@ -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(
+72
View File
@@ -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),
+81 -7
View File
@@ -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(
+73
View File
@@ -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
+271
View File
@@ -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
+139
View File
@@ -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);
+50
View File
@@ -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();
+2
View File
@@ -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 />} />
+23 -4
View File
@@ -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"
+7
View File
@@ -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" />
+12
View File
@@ -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;
}
+228
View File
@@ -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>
);
}
+8 -3
View File
@@ -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
View File
@@ -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)))
} }