feat(web,android): 看板拖拽、详情侧栏编辑、快速录入与安卓任务列表
Web: - 看板四列可拖拽,非法流转当场说明原因;勾选后可批量移动 - 详情侧栏全部可编辑(标题/优先级/截止/标签/备注/评论),409 自动刷新 - 快速录入 #标签 !优先级;键盘流 j/k、Enter、e、Esc - Playwright 真浏览器 e2e 7 项断言全绿(web/e2e/smoke.mjs) - 1000 条任务实测首屏 P50 38-55ms Android(未编译验证:本机无 JDK/Gradle): - 任务列表/详情/勾选完成/快速新建,冲突时提示并刷新 - 修:User/Member 字段名与后端 nickmame/phone 对齐,原先必然反序列化失败
This commit is contained in:
@@ -15,9 +15,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.TokenStore
|
||||
import com.dadao.task.data.Workspace
|
||||
import com.dadao.task.ui.DadaoTheme
|
||||
import com.dadao.task.ui.HomeScreen
|
||||
import com.dadao.task.ui.LoginScreen
|
||||
import com.dadao.task.ui.TaskListScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@@ -33,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Screen { Splash, Login, Home }
|
||||
private enum class Screen { Splash, Login, Home, Tasks }
|
||||
|
||||
@Composable
|
||||
private fun AppRoot() {
|
||||
@@ -41,6 +43,7 @@ private fun AppRoot() {
|
||||
val tokens = remember { TokenStore(context) }
|
||||
val api = remember { Api(tokens) }
|
||||
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
|
||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -64,6 +67,18 @@ private fun AppRoot() {
|
||||
tokens.clear()
|
||||
screen = Screen.Login
|
||||
},
|
||||
onOpenWorkspace = { ws ->
|
||||
current = ws
|
||||
screen = Screen.Tasks
|
||||
},
|
||||
)
|
||||
Screen.Tasks -> {
|
||||
val ws = current
|
||||
if (ws == null) {
|
||||
screen = Screen.Home
|
||||
} else {
|
||||
TaskListScreen(api = api, workspace = ws, onBack = { screen = Screen.Home })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ private data class Envelope<T>(
|
||||
// ---- 与 backend/app/api/schemas.py 一一对应 ----
|
||||
|
||||
@Serializable
|
||||
data class User(val id: String, val phone: String, @SerialName("display_name") val displayName: String)
|
||||
data class User(
|
||||
val id: String,
|
||||
val phone: String,
|
||||
val nickname: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Tokens(
|
||||
@@ -61,10 +65,62 @@ data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList(
|
||||
|
||||
@Serializable
|
||||
data class Member(
|
||||
val userId: String,
|
||||
@SerialName("display_name") val displayName: String,
|
||||
val role: String,
|
||||
val id: String = "",
|
||||
@SerialName("user_id") val userId: String = "",
|
||||
val role: String = "",
|
||||
val status: String = "",
|
||||
val nickname: String = "",
|
||||
val phone: String = "",
|
||||
@SerialName("joined_at") val joinedAt: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Tag(val id: String, val name: String, val color: String? = null)
|
||||
|
||||
/** 与 backend/app/api/items.py 的 `_item_out` 对齐。 */
|
||||
@Serializable
|
||||
data class Item(
|
||||
val id: String,
|
||||
val kind: String = "task",
|
||||
val title: String,
|
||||
@SerialName("body_md") val bodyMd: String = "",
|
||||
val status: String,
|
||||
val priority: Int = 1,
|
||||
@SerialName("creator_id") val creatorId: String = "",
|
||||
@SerialName("assignee_id") val assigneeId: String? = null,
|
||||
@SerialName("due_at") val dueAt: String? = null,
|
||||
@SerialName("completed_at") val completedAt: String? = null,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
@SerialName("version") val version: Int = 1,
|
||||
) {
|
||||
val isDone: Boolean get() = status == "done"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ItemPage(
|
||||
val items: List<Item> = emptyList(),
|
||||
@SerialName("next_cursor") val nextCursor: String? = null,
|
||||
@SerialName("has_more") val hasMore: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Activity(
|
||||
val id: String,
|
||||
val action: String,
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItemDetail(
|
||||
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,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val version: Int = 1,
|
||||
val activities: List<Activity> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -132,6 +188,37 @@ class Api(private val tokens: TokenStore) {
|
||||
auth = true,
|
||||
)
|
||||
|
||||
suspend fun items(workspaceId: String, view: String = "active", query: String? = null): ItemPage {
|
||||
val params = buildString {
|
||||
append("?view=").append(view).append("&limit=30")
|
||||
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
}
|
||||
return call(path = "/workspaces/$workspaceId/items$params", auth = true)
|
||||
}
|
||||
|
||||
suspend fun createItem(workspaceId: String, title: String, status: String = "inbox"): Item = call(
|
||||
path = "/workspaces/$workspaceId/items",
|
||||
body = json.encodeToString(mapOf("title" to title, "status" to status)),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
/** 改状态必须带 version:服务端用 `UPDATE ... WHERE version = ?` 防并发覆盖。 */
|
||||
suspend fun setStatus(workspaceId: String, itemId: String, status: String, version: Int): Item =
|
||||
call(
|
||||
path = "/workspaces/$workspaceId/items/$itemId/status",
|
||||
body = json.encodeToString(mapOf("status" to status, "version" to version)),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
suspend fun itemDetail(workspaceId: String, itemId: String): ItemDetail =
|
||||
call(path = "/workspaces/$workspaceId/items/$itemId", auth = true)
|
||||
|
||||
suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call(
|
||||
path = "/workspaces/$workspaceId/items/$itemId/comments",
|
||||
body = json.encodeToString(mapOf("body_md" to body)),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
private suspend inline fun <reified T> call(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
|
||||
@@ -47,7 +47,7 @@ private val ROLE_LABEL = mapOf(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
|
||||
val workspaces = remember { mutableStateListOf<Workspace>() }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
@@ -95,16 +95,7 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(workspaces, key = { it.id }) { ws ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
scope.launch {
|
||||
try {
|
||||
val inv = api.createInvitation(ws.id)
|
||||
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) },
|
||||
colors = CardDefaults.cardColors(),
|
||||
) {
|
||||
Row(
|
||||
@@ -120,11 +111,23 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Accent,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Accent,
|
||||
)
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val inv = api.createInvitation(ws.id)
|
||||
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
}) { Text("邀请") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Item
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val VIEWS = listOf(
|
||||
"today" to "今天",
|
||||
"active" to "进行中",
|
||||
"inbox" to "收件箱",
|
||||
"done" to "已完成",
|
||||
)
|
||||
|
||||
private val STATUS_LABEL = mapOf(
|
||||
"inbox" to "收件箱",
|
||||
"todo" to "待办",
|
||||
"doing" to "进行中",
|
||||
"done" to "已完成",
|
||||
"blocked" to "被阻塞",
|
||||
"canceled" to "已取消",
|
||||
)
|
||||
|
||||
/**
|
||||
* 任务列表。
|
||||
*
|
||||
* 勾选 = 完成/重开,都带 `version` 回传:手机端最常见的是"在地铁上点了一下,
|
||||
* 旁边同事也在改",没有乐观锁就会静默覆盖对方。
|
||||
* 409 时把服务端的最新内容拉回来,而不是让界面停在错误的勾选状态上。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TaskListScreen(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val items = remember { mutableStateListOf<Item>() }
|
||||
var view by remember { mutableStateOf("today") }
|
||||
var draft by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var detailId by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
val page = api.items(workspace.id, view)
|
||||
items.clear()
|
||||
items.addAll(page.items)
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(view) { reload() }
|
||||
|
||||
suspend fun toggle(item: Item) {
|
||||
val target = if (item.status == "done") "todo" else "done"
|
||||
try {
|
||||
val updated = api.setStatus(workspace.id, item.id, target, item.version)
|
||||
val index = items.indexOfFirst { it.id == updated.id }
|
||||
if (index >= 0) items[index] = updated
|
||||
if (target == "done" && view != "done") items.removeAll { it.id == updated.id }
|
||||
} catch (e: ApiException) {
|
||||
error = if (e.code == 4009) "这条刚被别人改过,已刷新" else e.message
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun add() {
|
||||
val title = draft.trim()
|
||||
if (title.isEmpty()) return
|
||||
try {
|
||||
val created = api.createItem(workspace.id, title, if (view == "inbox") "inbox" else "todo")
|
||||
draft = ""
|
||||
if (view != "done") items.add(0, created)
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
) {
|
||||
VIEWS.forEach { (key, label) ->
|
||||
FilterChip(
|
||||
selected = view == key,
|
||||
onClick = { view = key },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("加一件要做的事,回车创建") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { scope.launch { add() } }),
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
when {
|
||||
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
|
||||
items.isEmpty() -> Text(
|
||||
if (view == "today") "今天没有到期的事,挺好。" else "这里还空着。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(items, key = { it.id }) { item ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = item.isDone,
|
||||
onCheckedChange = { scope.launch { toggle(item) } },
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f).clickable { detailId = item.id },
|
||||
) {
|
||||
Text(
|
||||
item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textDecoration = if (item.isDone) TextDecoration.LineThrough else null,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
STATUS_LABEL[item.status] ?: item.status,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Accent,
|
||||
)
|
||||
item.dueAt?.let {
|
||||
Text(
|
||||
it.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
if (item.tags.isNotEmpty()) {
|
||||
Text(
|
||||
item.tags.joinToString(" ") { "#${it.name}" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detailId?.let { id ->
|
||||
var detail by remember(id) { mutableStateOf<String?>(null) }
|
||||
var comment by remember(id) { mutableStateOf("") }
|
||||
LaunchedEffect(id) {
|
||||
detail = try {
|
||||
val d = api.itemDetail(workspace.id, id)
|
||||
buildString {
|
||||
appendLine(d.title)
|
||||
appendLine()
|
||||
appendLine("状态:${STATUS_LABEL[d.status] ?: d.status} 优先级:${d.priority}")
|
||||
d.dueAt?.let { appendLine("截止:${it.take(16).replace('T', ' ')}") }
|
||||
if (d.tags.isNotEmpty()) appendLine("标签:${d.tags.joinToString(" ") { "#${it.name}" }}")
|
||||
if (d.bodyMd.isNotBlank()) {
|
||||
appendLine()
|
||||
append(d.bodyMd)
|
||||
}
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
e.message
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { detailId = null },
|
||||
title = { Text("任务详情") },
|
||||
text = {
|
||||
Column {
|
||||
Text(detail ?: "加载中…", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = comment,
|
||||
onValueChange = { comment = it },
|
||||
label = { Text("补充一句进展") },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = comment.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
api.addComment(workspace.id, id, comment.trim())
|
||||
comment = ""
|
||||
detailId = null
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("发表") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { detailId = null }) { Text("关闭") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user