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
@@ -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<Workspace?>(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<EditRequest?>(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 },
)
}
}
}
@@ -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<AppNotification> = 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<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 com.dadao.task.data.Workspace
/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
enum class WorkspaceTab(val label: String) {
Tasks("任务"),
Tickets("工单"),
Knowledge("知识"),
Notifications("通知"),
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
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<List<Ticket>>(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<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() {
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(