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.HomeScreen
|
||||||
import com.dadao.task.ui.LoginScreen
|
import com.dadao.task.ui.LoginScreen
|
||||||
import com.dadao.task.ui.TaskListScreen
|
import com.dadao.task.ui.TaskListScreen
|
||||||
|
import com.dadao.task.ui.TicketsScreen
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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
|
@Composable
|
||||||
private fun AppRoot() {
|
private fun AppRoot() {
|
||||||
@@ -77,7 +78,20 @@ private fun AppRoot() {
|
|||||||
if (ws == null) {
|
if (ws == null) {
|
||||||
screen = Screen.Home
|
screen = Screen.Home
|
||||||
} else {
|
} 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,
|
@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 SmsRequest(val phone: String)
|
||||||
private data class LoginRequest(val phone: String, val code: String)
|
private data class LoginRequest(val phone: String, val code: String)
|
||||||
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
||||||
@@ -219,6 +252,57 @@ class Api(private val tokens: TokenStore) {
|
|||||||
auth = true,
|
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(
|
private suspend inline fun <reified T> call(
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ fun TaskListScreen(
|
|||||||
api: Api,
|
api: Api,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenTickets: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val items = remember { mutableStateListOf<Item>() }
|
val items = remember { mutableStateListOf<Item>() }
|
||||||
var view by remember { mutableStateOf("today") }
|
var view by remember { mutableStateOf("today") }
|
||||||
@@ -127,6 +128,7 @@ fun TaskListScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(workspace.name) },
|
title = { Text(workspace.name) },
|
||||||
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
|
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
|
||||||
|
actions = { TextButton(onClick = onOpenTickets) { Text("工单") } },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { 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