feat(android): 工单列表与动作,任务页可进工单
- Api 增 tickets/ticketAction/verifyTicket/promote,状态机交给服务端,客户端只发动作 - TicketsScreen:派给我的 / 我发起的、按状态给接收/开始/提交/通过/驳回、6004 与 6003 给出人话提示 - 仍未编译验证(本机无 JDK/Gradle),见 android/README.md
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user