feat(android): 工单列表与动作,任务页可进工单

- Api 增 tickets/ticketAction/verifyTicket/promote,状态机交给服务端,客户端只发动作
- TicketsScreen:派给我的 / 我发起的、按状态给接收/开始/提交/通过/驳回、6004 与 6003 给出人话提示
- 仍未编译验证(本机无 JDK/Gradle),见 android/README.md
This commit is contained in:
dadao
2026-10-28 13:20:31 +08:00
parent b1dc3cffa6
commit fd18b2a403
4 changed files with 301 additions and 2 deletions
@@ -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 })
}
}
}
@@ -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<Ticket> = 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<String, Any> = 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 <reified T> call(
path: String,
body: String? = null,
@@ -72,6 +72,7 @@ fun TaskListScreen(
api: Api,
workspace: Workspace,
onBack: () -> Unit,
onOpenTickets: () -> Unit = {},
) {
val items = remember { mutableStateListOf<Item>() }
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 ->
@@ -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<Ticket>() }
var role by remember { mutableStateOf("assigned") }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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) }
}