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:
dadao
2026-10-28 04:05:41 +08:00
parent c4868f04cf
commit 6e33e1024d
13 changed files with 1316 additions and 93 deletions
@@ -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("关闭") } },
)
}
}