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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user