diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt index e16d6c8..ec842e7 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -20,6 +20,7 @@ import com.dadao.task.ui.DadaoTheme import com.dadao.task.ui.HomeScreen import com.dadao.task.ui.LoginScreen import com.dadao.task.ui.TaskListScreen +import com.dadao.task.ui.TicketsScreen class MainActivity : ComponentActivity() { @@ -35,7 +36,7 @@ class MainActivity : ComponentActivity() { } } -private enum class Screen { Splash, Login, Home, Tasks } +private enum class Screen { Splash, Login, Home, Tasks, Tickets } @Composable private fun AppRoot() { @@ -77,7 +78,20 @@ private fun AppRoot() { if (ws == null) { screen = Screen.Home } else { - TaskListScreen(api = api, workspace = ws, onBack = { screen = Screen.Home }) + TaskListScreen( + api = api, + workspace = ws, + onBack = { screen = Screen.Home }, + onOpenTickets = { screen = Screen.Tickets }, + ) + } + } + Screen.Tickets -> { + val ws = current + if (ws == null) { + screen = Screen.Home + } else { + TicketsScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks }) } } } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index 903d8fb..2b06f34 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -129,6 +129,39 @@ data class Invitation( @SerialName("expires_at") val expiresAt: String, ) +/** 与 backend/app/models_ticket.py 的 `ticket_ext` 对齐(只取手机端要显示的)。 */ +@Serializable +data class TicketInfo( + @SerialName("requester_id") val requesterId: String = "", + @SerialName("assignee_id") val assigneeId: String = "", + @SerialName("parent_item_id") val parentItemId: String? = null, + @SerialName("reject_count") val rejectCount: Int = 0, + @SerialName("resolution_md") val resolutionMd: String = "", +) + +/** + * 工单 = 一条 kind=ticket 的 item + 一份 ticket 扩展。 + * 结构上就是 [Item] 多一个 ticket 字段,所以不要另起一套字段名。 + */ +@Serializable +data class Ticket( + val id: String, + val title: String, + @SerialName("body_md") val bodyMd: String = "", + val status: String, + val priority: Int = 1, + @SerialName("due_at") val dueAt: String? = null, + @SerialName("version") val version: Int = 1, + val ticket: TicketInfo = TicketInfo(), +) + +@Serializable +data class TicketPage( + val tickets: List = emptyList(), + @SerialName("next_cursor") val nextCursor: String? = null, + @SerialName("has_more") val hasMore: Boolean = false, +) + private data class SmsRequest(val phone: String) private data class LoginRequest(val phone: String, val code: String) private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String) @@ -219,6 +252,57 @@ class Api(private val tokens: TokenStore) { auth = true, ) + // ---- M2 工单 ---- + + /** role: assigned(派给我的)/ requested(我发起的)/ all。 */ + suspend fun tickets(workspaceId: String, role: String = "assigned"): TicketPage = + call(path = "/workspaces/$workspaceId/tickets?role=$role", auth = true) + + /** + * 工单动作。状态机在服务端,这里只发"做什么",不自己算目标状态—— + * 客户端如果自己判断,规则一改就会和前端的按钮显隐对不上。 + */ + suspend fun ticketAction( + workspaceId: String, + itemId: String, + action: String, + body: Map = emptyMap(), + ): Ticket = call( + path = "/workspaces/$workspaceId/items/$itemId/ticket/$action", + body = json.encodeToString(body), + auth = true, + ) + + suspend fun verifyTicket( + workspaceId: String, + itemId: String, + approve: Boolean, + note: String = "", + resolution: String = "", + ): Ticket = ticketAction( + workspaceId, + itemId, + "verify", + mapOf("approve" to approve, "note" to note, "resolution_md" to resolution), + ) + + /** 把任务升级成工单:原任务留在原地变"被阻塞",不搬数据。 */ + suspend fun promote( + workspaceId: String, + itemId: String, + assigneeId: String, + version: Int? = null, + ): JsonElement = call( + path = "/workspaces/$workspaceId/items/$itemId/promote", + body = json.encodeToString( + buildMap { + put("assignee_id", assigneeId) + if (version != null) put("version", version) + }, + ), + auth = true, + ) + private suspend inline fun call( path: String, body: String? = null, diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt index fde612b..5f054c0 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt @@ -72,6 +72,7 @@ fun TaskListScreen( api: Api, workspace: Workspace, onBack: () -> Unit, + onOpenTickets: () -> Unit = {}, ) { val items = remember { mutableStateListOf() } var view by remember { mutableStateOf("today") } @@ -127,6 +128,7 @@ fun TaskListScreen( TopAppBar( title = { Text(workspace.name) }, navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, + actions = { TextButton(onClick = onOpenTickets) { Text("工单") } }, ) }, ) { padding -> diff --git a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt new file mode 100644 index 0000000..a9caaef --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt @@ -0,0 +1,199 @@ +package com.dadao.task.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.unit.dp +import com.dadao.task.data.Api +import com.dadao.task.data.ApiException +import com.dadao.task.data.Ticket +import com.dadao.task.data.Workspace +import kotlinx.coroutines.launch + +private val ROLES = listOf( + "assigned" to "派给我的", + "requested" to "我发起的", +) + +private val TICKET_STATUS_LABEL = mapOf( + "open" to "待接收", + "accepted" to "待办理", + "in_progress" to "办理中", + "pending_verify" to "待验收", + "done" to "已完成", + "canceled" to "已取消", +) + +/** + * 工单列表(移动端只做"该我办的"与"我发起的"两栏)。 + * + * 为什么手机端也必须有:需要别人帮忙时,人往往不在电脑前。 + * 只做查看不做动作的工单页等于把流程卡在桌面端,等于没有协同。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TicketsScreen( + api: Api, + workspace: Workspace, + onBack: () -> Unit, +) { + val tickets = remember { mutableStateListOf() } + var role by remember { mutableStateOf("assigned") } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + suspend fun reload() { + loading = true + error = null + try { + val page = api.tickets(workspace.id, role) + tickets.clear() + tickets.addAll(page.tickets) + } catch (e: ApiException) { + error = e.message + } finally { + loading = false + } + } + + LaunchedEffect(role) { reload() } + + suspend fun act(ticket: Ticket, action: String, approve: Boolean = false) { + try { + val updated = if (action == "verify") { + api.verifyTicket(workspace.id, ticket.id, approve) + } else { + api.ticketAction(workspace.id, ticket.id, action) + } + val index = tickets.indexOfFirst { it.id == updated.id } + if (index >= 0) tickets[index] = updated + // 办完就不该再占着"待我处理"的位置 + if (updated.status == "done" && role == "assigned") tickets.removeAll { it.id == updated.id } + } catch (e: ApiException) { + error = when (e.code) { + 6004 -> "这个动作得由需求人来点(自己不能验收自己的工单)" + 6003 -> "当前状态不允许这个操作,可能刚被别人推进了一步" + else -> e.message + } + reload() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("${workspace.name} · 工单") }, + navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ROLES.forEach { (key, label) -> + FilterChip( + selected = role == key, + onClick = { role = key }, + label = { Text(label) }, + ) + } + } + + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + when { + loading -> Text("加载中…") + tickets.isEmpty() -> Text( + if (role == "assigned") "没有需要你出手的事。" else "你还没找人帮过忙。", + style = MaterialTheme.typography.bodyMedium, + ) + else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(tickets, key = { it.id }) { ticket -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(ticket.title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + buildString { + append(TICKET_STATUS_LABEL[ticket.status] ?: ticket.status) + if (ticket.ticket.rejectCount > 0) { + append(" · 已退回 ").append(ticket.ticket.rejectCount).append(" 次") + } + }, + style = MaterialTheme.typography.bodySmall, + ) + Row( + Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (ticket.status) { + "open" -> ActionButton("接收") { + scope.launch { act(ticket, "accept") } + } + "accepted" -> ActionButton("开始") { + scope.launch { act(ticket, "start") } + } + "in_progress" -> ActionButton("提交验收") { + scope.launch { act(ticket, "submit") } + } + "pending_verify" -> { + ActionButton("通过") { + scope.launch { act(ticket, "verify", approve = true) } + } + ActionButton("驳回") { + scope.launch { act(ticket, "verify", approve = false) } + } + } + else -> Unit + } + Spacer(Modifier.weight(1f)) + if (ticket.status != "done" && ticket.status != "canceled") { + TextButton(onClick = { scope.launch { act(ticket, "cancel") } }) { + Text("取消") + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun ActionButton(label: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { Text(label) } +}