From 52ec6474a97993c3f48df0e72a53030c8aedd81a Mon Sep 17 00:00:00 2001 From: dadao Date: Wed, 28 Oct 2026 18:37:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E8=AE=BE=E8=AE=A1=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=8E=E4=BA=94=E4=B8=AA=E9=A1=B5=E9=9D=A2=E9=87=8D?= =?UTF-8?q?=E5=81=9A=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E3=80=81=E9=82=80=E8=AF=B7=E3=80=81=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前安卓只到"能登录、能看列表",且从没编译过。这轮把它做到与 Web 同构: - Theme.kt 与 Tailwind 同一套色板与字号阶;新增 Widgets.kt(状态 pill、 卡片、按钮、头像、空态、时间与 action 的中文映射、错误码文案) - 新增 AppFrame.kt(顶栏工作区 + 未读角标 + 底部四 tab)与 TaskDetailSheet / BoardScreen / MembersScreen - 重写任务、工单、知识三页与登录、首页;任务详情里补上「升级成工单」 和「沉淀成知识」——这两个入口此前只有 Web 有,是真正的功能欠账 - 看板不做拖拽:一列已占 76% 屏宽,拖拽命中率太差,改为点卡片选目标列 - 修运行期 bug:workspaces/members 按 List 解析而服务端返回 {items:[...]}; 消息区被手势条遮挡;升级工单候选人里出现自己;priority=0 显示"无" - android/scripts/emulator.ps1 固化构建+装机+起模拟器;assembleDebug 出 APK 已在 API 35 模拟器上逐页跑通并截图(见 2.Design/screens/android-*.png)。 --- .../main/java/com/dadao/task/MainActivity.kt | 92 ++-- .../src/main/java/com/dadao/task/data/Api.kt | 50 +- .../main/java/com/dadao/task/ui/AppFrame.kt | 138 ++++++ .../java/com/dadao/task/ui/BoardScreen.kt | 180 +++++++ .../main/java/com/dadao/task/ui/HomeScreen.kt | 356 +++++++++----- .../java/com/dadao/task/ui/KnowledgeScreen.kt | 449 +++++++++-------- .../java/com/dadao/task/ui/LoginScreen.kt | 247 +++++++--- .../java/com/dadao/task/ui/MembersScreen.kt | 199 ++++++++ .../java/com/dadao/task/ui/TaskDetailSheet.kt | 378 +++++++++++++++ .../java/com/dadao/task/ui/TaskListScreen.kt | 454 +++++++++--------- .../src/main/java/com/dadao/task/ui/Theme.kt | 87 +++- .../java/com/dadao/task/ui/TicketsScreen.kt | 311 ++++++------ .../main/java/com/dadao/task/ui/Widgets.kt | 273 +++++++++++ 13 files changed, 2402 insertions(+), 812 deletions(-) create mode 100644 android/app/src/main/java/com/dadao/task/ui/AppFrame.kt create mode 100644 android/app/src/main/java/com/dadao/task/ui/BoardScreen.kt create mode 100644 android/app/src/main/java/com/dadao/task/ui/MembersScreen.kt create mode 100644 android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt create mode 100644 android/app/src/main/java/com/dadao/task/ui/Widgets.kt diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt index 3b7f3ba..a38659d 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -1,8 +1,10 @@ package com.dadao.task +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -13,15 +15,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import com.dadao.task.data.Api import com.dadao.task.data.TokenStore import com.dadao.task.data.Workspace +import com.dadao.task.ui.BoardScreen import com.dadao.task.ui.DadaoTheme import com.dadao.task.ui.HomeScreen import com.dadao.task.ui.KnowledgeScreen import com.dadao.task.ui.LoginScreen +import com.dadao.task.ui.MembersScreen import com.dadao.task.ui.TaskListScreen import com.dadao.task.ui.TicketsScreen +import com.dadao.task.ui.WorkspaceScaffold +import com.dadao.task.ui.WorkspaceTab class MainActivity : ComponentActivity() { @@ -30,22 +37,37 @@ class MainActivity : ComponentActivity() { setContent { DadaoTheme { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - AppRoot() + AppRoot(joinUri = intent?.dataString) } } } } } -private enum class Screen { Splash, Login, Home, Tasks, Tickets, Knowledge } +private enum class Screen { Splash, Login, Home, Workspace } + +/** 从 https://dadaotask.com/join// 里拆出工作区与邀请码。 */ +private fun parseJoin(uri: String?): Pair? { + if (uri.isNullOrBlank()) return null + val parts = uri.trimEnd('/').split("/") + val idx = parts.indexOf("join") + if (idx < 0 || parts.size < idx + 3) return null + val ws = parts[idx + 1] + val code = parts[idx + 2] + if (ws.length < 10 || code.length < 4) return null + return ws to code +} @Composable -private fun AppRoot() { - val context = androidx.compose.ui.platform.LocalContext.current +private fun AppRoot(joinUri: String?) { + val context = LocalContext.current val tokens = remember { TokenStore(context) } val api = remember { Api(tokens) } var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) } + var pendingJoin by remember { mutableStateOf(parseJoin(joinUri)) } var current by remember { mutableStateOf(null) } + var tab by remember { mutableStateOf(WorkspaceTab.Tasks) } + var unread by remember { mutableStateOf(0) } // 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。 LaunchedEffect(screen) { @@ -60,8 +82,28 @@ private fun AppRoot() { } } + // 邀请链接:登录之后自动加入,加入完直接进那个工作区。 + LaunchedEffect(screen, pendingJoin) { + val join = pendingJoin ?: return@LaunchedEffect + if (screen != Screen.Home) return@LaunchedEffect + runCatching { api.join(join.first, join.second) } + .onSuccess { + current = it + tab = WorkspaceTab.Tasks + pendingJoin = null + screen = Screen.Workspace + } + .onFailure { pendingJoin = null } + } + + // 工单未读数:进工作区就拉一次,切页面时再刷。 + LaunchedEffect(current?.id, tab) { + val ws = current ?: return@LaunchedEffect + unread = api.unreadCount(ws.id) + } + when (screen) { - Screen.Splash -> Unit // 校验中,空白过渡即可 + Screen.Splash -> Unit Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home }) Screen.Home -> HomeScreen( api = api, @@ -71,37 +113,29 @@ private fun AppRoot() { }, onOpenWorkspace = { ws -> current = ws - screen = Screen.Tasks + tab = WorkspaceTab.Tasks + screen = Screen.Workspace }, ) - Screen.Tasks -> { + Screen.Workspace -> { val ws = current if (ws == null) { screen = Screen.Home } else { - TaskListScreen( - api = api, + WorkspaceScaffold( workspace = ws, - onBack = { screen = Screen.Home }, - onOpenTickets = { screen = Screen.Tickets }, - onOpenKnowledge = { screen = Screen.Knowledge }, - ) - } - } - Screen.Tickets -> { - val ws = current - if (ws == null) { - screen = Screen.Home - } else { - TicketsScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks }) - } - } - Screen.Knowledge -> { - val ws = current - if (ws == null) { - screen = Screen.Home - } else { - KnowledgeScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks }) + tab = tab, + unread = unread, + onTab = { tab = it }, + onBackHome = { screen = Screen.Home }, + ) { + when (tab) { + WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws) + WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws) + WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws) + WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws) + } + } } } } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index 18dcf9f..9635075 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -111,9 +111,22 @@ data class Activity( @SerialName("created_at") val createdAt: String = "", ) +@Serializable +data class Comment( + val id: String, + @SerialName("author_id") val authorId: String = "", + @SerialName("body_md") val bodyMd: String = "", + @SerialName("created_at") val createdAt: String = "", +) + +@Serializable +data class CommentPage(val items: List = emptyList()) + +/** 详情比列表多一个 kind 与 activities:工单要按 kind 切界面,时间线要用详情里的。 */ @Serializable data class ItemDetail( val id: String, + val kind: String = "task", val title: String, @SerialName("body_md") val bodyMd: String = "", val status: String, @@ -220,6 +233,9 @@ data class KnowledgePage( // ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ---- +@Serializable +private data class UnreadCount(@SerialName("unread") val unread: Int = 0) + @Serializable private data class SmsRequest(val phone: String) @@ -235,6 +251,9 @@ private data class WorkspaceCreateRequest(val name: String) @Serializable private data class JoinRequest(val code: String) +@Serializable +private data class InviteRequest(val role: String) + @Serializable private data class CreateItemRequest(val title: String, val status: String) @@ -292,6 +311,11 @@ class Api(private val tokens: TokenStore) { // 与 backend/app/config.py 的 api_prefix 保持一致 private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1" + /** 退出登录只清本地令牌;服务端的 refresh 是无状态的,没有注销接口。 */ + fun logout() { + tokens.clear() + } + suspend fun sendSms(phone: String): JsonElement? = call( path = "/auth/sms/send", body = json.encodeToString(SmsRequest(phone)), @@ -325,8 +349,17 @@ class Api(private val tokens: TokenStore) { suspend fun members(workspaceId: String): List = call(path = "/workspaces/$workspaceId/members", auth = true).items - suspend fun createInvitation(workspaceId: String): Invitation = - call(path = "/workspaces/$workspaceId/invitations", body = "{}", auth = true) + /** 邀请角色:member(成员)或 admin(管理员,仅负责人可发)。 */ + suspend fun createInvitation(workspaceId: String, role: String = "member"): Invitation = call( + path = "/workspaces/$workspaceId/invitations", + body = json.encodeToString(InviteRequest(role)), + auth = true, + ) + + /** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */ + suspend fun unreadCount(workspaceId: String): Int = runCatching { + call(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread + }.getOrDefault(0) suspend fun join(workspaceId: String, code: String): Workspace = call( path = "/workspaces/$workspaceId/join", @@ -356,9 +389,22 @@ class Api(private val tokens: TokenStore) { auth = true, ) + /** + * 看板数据:手机屏幕装不下四列,这里取「进行中的全部」+「已完成」拼一份, + * 由界面按 status 分组,不为此新增解析结构。 + */ + suspend fun boardItems(workspaceId: String): List { + val active = items(workspaceId, view = "active").items + val done = items(workspaceId, view = "done").items + return active + done + } + suspend fun itemDetail(workspaceId: String, itemId: String): ItemDetail = call(path = "/workspaces/$workspaceId/items/$itemId", auth = true) + suspend fun comments(workspaceId: String, itemId: String): List = + call(path = "/workspaces/$workspaceId/items/$itemId/comments", auth = true).items + suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call( path = "/workspaces/$workspaceId/items/$itemId/comments", body = json.encodeToString(CommentRequest(body)), diff --git a/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt b/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt new file mode 100644 index 0000000..0e1e639 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt @@ -0,0 +1,138 @@ +package com.dadao.task.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.dadao.task.data.Workspace + +/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */ +enum class WorkspaceTab(val label: String) { + Tasks("任务"), + Tickets("工单"), + Knowledge("知识"), + Members("成员"), +} + +/** + * 工作区内所有页面共用的外壳:顶部(工作区名 / 返回 / 未读)+ 底部 tab。 + * 页面只管内容,别再各自画状态栏。 + */ +@Composable +fun WorkspaceScaffold( + workspace: Workspace, + tab: WorkspaceTab, + unread: Int, + onTab: (WorkspaceTab) -> Unit, + onBackHome: () -> Unit, + content: @Composable () -> Unit, +) { + Column(Modifier.fillMaxSize().background(Paper).statusBarsPadding()) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + color = PrimarySoft, + contentColor = Primary, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.clickable { onBackHome() }, + ) { + Text( + workspace.name.take(1), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), + ) + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + workspace.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "切换工作区", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + modifier = Modifier.clickable { onBackHome() }, + ) + } + if (unread > 0) { + Box( + Modifier + .background(Clay, CircleShape) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + Text( + if (unread > 99) "99+" else unread.toString(), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + + Box(Modifier.weight(1f).fillMaxWidth()) { content() } + + Row( + Modifier + .fillMaxWidth() + .background(Color.White) + .navigationBarsPadding() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + WorkspaceTab.entries.forEach { item -> + val active = item == tab + Column( + Modifier + .weight(1f) + .clickable { if (!active) onTab(item) } + .padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .size(width = 22.dp, height = 3.dp) + .background(if (active) Primary else Color.Transparent, CircleShape), + ) + Spacer(Modifier.height(6.dp)) + Text( + item.label, + style = MaterialTheme.typography.labelMedium, + color = if (active) Primary else InkMuted, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/dadao/task/ui/BoardScreen.kt b/android/app/src/main/java/com/dadao/task/ui/BoardScreen.kt new file mode 100644 index 0000000..4a479d2 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/BoardScreen.kt @@ -0,0 +1,180 @@ +package com.dadao.task.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Box +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +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 boardColumns = listOf( + "todo" to "待办", + "doing" to "进行中", + "blocked" to "被阻塞", + "done" to "已完成", +) + +/** + * 看板。手机屏幕放不下四列并排拖动,所以做成「横向滑动的列 + 点卡片改状态」, + * 功能等价、手势更简单;真正的拖拽留在 Web 端。 + */ +@Composable +fun BoardScreen(api: Api, workspace: Workspace, onOpen: (String) -> Unit) { + val scope = rememberCoroutineScope() + var items by remember { mutableStateOf>(emptyList()) } + var moving by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(null) } + + suspend fun reload() { + runCatching { api.boardItems(workspace.id) } + .onSuccess { items = it } + .onFailure { message = it.message ?: "看板加载失败" } + } + + LaunchedEffect(workspace.id) { reload() } + + Column(Modifier.fillMaxSize().padding(start = 14.dp)) { + val notice = message + if (notice != null) { + SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth().padding(bottom = 8.dp)) + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(boardColumns) { (status, label) -> + val list = items.filter { it.status == status } + Column( + Modifier + .fillMaxHeight() + .width(300.dp) + .background(PaperSunken.copy(alpha = 0.6f), RoundedCornerShape(14.dp)) + .padding(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(6.dp).height(6.dp).background(columnDot(status), CircleShape)) + Spacer(Modifier.width(6.dp)) + Text(label, style = MaterialTheme.typography.labelLarge, color = Ink) + Spacer(Modifier.weight(1f)) + Text("${list.size}", style = MaterialTheme.typography.labelSmall, color = InkMuted) + } + Spacer(Modifier.height(8.dp)) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 12.dp), + ) { + items(list, key = { it.id }) { item -> + Surface( + color = Color.White, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { moving = item }, + ) { + Column(Modifier.padding(10.dp)) { + Text( + item.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 2, + ) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PriorityTag(item.priority) + item.tags.take(1).forEach { SoftPill("#" + it.name, Primary, PrimarySoft) } + } + } + } + } + if (list.isEmpty()) { + item { + Text( + "拖到这里", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + modifier = Modifier.padding(12.dp), + ) + } + } + } + } + } + } + } + + val target = moving + if (target != null) { + AlertDialog( + onDismissRequest = { moving = null }, + title = { Text("把这条放到哪一列?", style = MaterialTheme.typography.titleMedium) }, + text = { + Column { + Text(target.title, style = MaterialTheme.typography.bodySmall, color = InkMuted) + Spacer(Modifier.height(10.dp)) + boardColumns.forEach { (status, label) -> + TextButton(onClick = { + scope.launch { + moving = null + runCatching { api.setStatus(workspace.id, target.id, status, target.version) } + .onSuccess { + message = null + reload() + } + .onFailure { + val code = (it as? ApiException)?.code ?: 0 + message = verifyStatusCode(code).ifBlank { it.message ?: "改不过去" } + } + } + }) { Text(if (target.status == status) "$label(当前)" else "移到$label") } + } + Spacer(Modifier.height(4.dp)) + TextButton(onClick = { + moving = null + onOpen(target.id) + }) { Text("打开详情") } + } + }, + confirmButton = {}, + containerColor = Paper, + ) + } +} + +private fun columnDot(status: String): Color = when (status) { + "doing" -> Primary + "blocked" -> Wheat + "done" -> Sage + else -> Sky +} diff --git a/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt index 0f85e7b..b4ea4f7 100644 --- a/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt @@ -1,187 +1,289 @@ package com.dadao.task.ui +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.statusBarsPadding 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton 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.material3.TextFieldDefaults 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.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.dadao.task.data.Api -import com.dadao.task.data.ApiException +import com.dadao.task.data.User import com.dadao.task.data.Workspace import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Locale -private val ROLE_LABEL = mapOf( - "owner" to "所有者", - "admin" to "管理员", - "member" to "成员", - "guest" to "访客", -) +private fun greeting(): String = when (LocalTime.now().hour) { + in 5..10 -> "早上好" + in 11..13 -> "中午好" + in 14..17 -> "下午好" + in 18..22 -> "晚上好" + else -> "夜深了" +} + +private fun today(): String = + LocalDate.now().format(DateTimeFormatter.ofPattern("M月d日 EEEE", Locale.SIMPLIFIED_CHINESE)) + +private fun roleLabel(role: String?): String = when (role) { + "owner" -> "负责人" + "admin" -> "管理员" + "member" -> "成员" + else -> "访客" +} -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) { - val workspaces = remember { mutableStateListOf() } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } - var showCreate by remember { mutableStateOf(false) } - var invitation by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + var user by remember { mutableStateOf(null) } + var workspaces by remember { mutableStateOf>(emptyList()) } + var creating by remember { mutableStateOf(false) } + var name by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } suspend fun reload() { - loading = true - error = null - try { - workspaces.clear() - workspaces.addAll(api.workspaces()) - } catch (e: ApiException) { - error = e.message - } finally { - loading = false - } + runCatching { api.me() } + .onSuccess { + user = it.user + workspaces = it.workspaces + error = null + } + .onFailure { error = it.message ?: "加载失败" } } LaunchedEffect(Unit) { reload() } - Scaffold( - topBar = { - TopAppBar( - title = { Text("我的工作区") }, - actions = { TextButton(onClick = onLogout) { Text("退出") } }, - ) - }, - floatingActionButton = { - FloatingActionButton(onClick = { showCreate = true }) { Text("+") } - }, - ) { padding -> - Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { - error?.let { - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(8.dp)) - } - when { - loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium) - workspaces.isEmpty() -> Text( - "还没有工作区。点右下角 + 新建一个,任务、工单、知识都放在里面。", - style = MaterialTheme.typography.bodyMedium, - ) - else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { - items(workspaces, key = { it.id }) { ws -> - Card( - modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) }, - colors = CardDefaults.cardColors(), - ) { - Row( - Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text(ws.name, style = MaterialTheme.typography.titleMedium) - Text( - ws.slug, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - ) - } - 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("邀请") } + LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Column(Modifier.padding(top = 18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .background(Primary, RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + "达道Task", + style = MaterialTheme.typography.labelLarge, + color = Color.White, + ) + } + Spacer(Modifier.weight(1f)) + Text( + "退出", + style = MaterialTheme.typography.labelMedium, + color = InkMuted, + modifier = Modifier + .clickable { + scope.launch { + api.logout() + onLogout() } } - } + .padding(6.dp), + ) + } + Spacer(Modifier.height(22.dp)) + Text( + today(), + style = MaterialTheme.typography.labelMedium, + color = Clay, + ) + Spacer(Modifier.height(6.dp)) + Text( + "${greeting()},${user?.nickname?.ifBlank { "回来就好" } ?: "回来就好"}", + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(6.dp)) + Text( + "选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + } + } + + item { + Row( + Modifier.padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("我的工作区", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.weight(1f)) + Text( + "${workspaces.size} 个", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + } + } + + if (workspaces.isEmpty()) { + item { + SoftCard { + Text("还没有工作区", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(6.dp)) + Text( + "工作区对应一支团队,或一个生活领域。建一个就能开始记事了。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + } + } + } + + items(workspaces, key = { it.id }) { ws -> + SoftCard(onClick = { onOpenWorkspace(ws) }) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .width(36.dp) + .height(36.dp) + .background(PrimarySoft, RoundedCornerShape(11.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + ws.name.take(1), + style = MaterialTheme.typography.titleSmall, + color = Primary, + ) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + ws.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "${roleLabel(ws.role)} · 员工免费版", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + } + Box( + Modifier + .width(6.dp) + .height(6.dp) + .background(Clay, CircleShape), + ) + } + } + } + + item { + SoftCard(onClick = { + creating = true + name = "" + }) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("+", style = MaterialTheme.typography.titleMedium, color = Clay) + Spacer(Modifier.width(10.dp)) + Column { + Text( + "新建工作区", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "一个人用也行", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) } } } - Spacer(Modifier.height(8.dp)) - Text( - "点工作区可生成邀请码(M0 临时入口,M1 换成工作区详情页)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), - ) } + + val notice = error + if (notice != null) { + item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) } + } + + item { Spacer(Modifier.height(40.dp)) } } - if (showCreate) { - var name by remember { mutableStateOf("") } - var busy by remember { mutableStateOf(false) } + if (creating) { AlertDialog( - onDismissRequest = { showCreate = false }, - title = { Text("新建工作区") }, + onDismissRequest = { creating = false }, + title = { Text("新建工作区", style = MaterialTheme.typography.titleMedium) }, text = { - OutlinedTextField( - value = name, - onValueChange = { name = it.take(40) }, - label = { Text("名称") }, - singleLine = true, - ) + Column { + Text( + "一个工作区对应一支团队或一个生活领域,成员之间共享任务、工单与知识。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it.take(40) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("例如:达道科技 / 我的事项", color = InkMuted) }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + } }, confirmButton = { - TextButton(enabled = !busy && name.isNotBlank(), onClick = { - scope.launch { - busy = true - try { - api.createWorkspace(name.trim()) - showCreate = false - reload() - } catch (e: ApiException) { - error = e.message - } finally { + TextButton( + enabled = name.isNotBlank() && !busy, + onClick = { + scope.launch { + busy = true + runCatching { api.createWorkspace(name.trim()) } + .onSuccess { ws -> + creating = false + onOpenWorkspace(ws) + } + .onFailure { error = it.message ?: "建不出来" } busy = false } - } - }) { Text("创建") } + }, + ) { Text("创建") } }, - dismissButton = { TextButton(onClick = { showCreate = false }) { Text("取消") } }, - ) - } - - invitation?.let { text -> - AlertDialog( - onDismissRequest = { invitation = null }, - title = { Text("邀请成员") }, - text = { Text(text) }, - confirmButton = { TextButton(onClick = { invitation = null }) { Text("知道了") } }, + dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } }, + containerColor = Paper, ) } } diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt index 04dd2d7..c5f8913 100644 --- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -7,29 +7,31 @@ 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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -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.material3.TextFieldDefaults 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.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.dadao.task.data.Api import com.dadao.task.data.ApiException @@ -37,248 +39,275 @@ import com.dadao.task.data.KnowledgeDoc import com.dadao.task.data.Workspace import kotlinx.coroutines.launch -private val KNOWLEDGE_TABS = listOf( - "待核对" to listOf("draft", "in_review"), - "已发布" to listOf("published"), - "已归档" to listOf("archived"), - "全部" to listOf("draft", "in_review", "published", "archived"), -) - -private val DOC_TYPE_LABEL = mapOf( - "note" to "手记", - "howto" to "做法", - "faq" to "问答", - "postmortem" to "复盘", - "decision" to "决策", -) - -private val KNOWLEDGE_STATUS_LABEL = mapOf( - "draft" to "草稿", - "in_review" to "待审议", +private val knowledgeTabs = listOf( + "draft" to "待核对", "published" to "已发布", "archived" to "已归档", + "all" to "全部", ) -/** - * 知识列表。 - * - * 手机端做知识,只保留两件最有价值的事:**记一条**(通勤路上想到就写) - * 与**核对后发布**(草稿躺在待核对里,手机上顺手就能审)。 - * 版本历史、标签管理这类重活留给桌面端,别把手机塞成缩小版 Web。 - */ -@OptIn(ExperimentalMaterial3Api::class) +private fun docTypeLabel(type: String): String = when (type) { + "howto" -> "怎么做" + "faq" -> "问答" + "postmortem" -> "复盘" + "decision" -> "决策" + else -> "笔记" +} + @Composable -fun KnowledgeScreen( - api: Api, - workspace: Workspace, - onBack: () -> Unit, -) { - val docs = remember { mutableStateListOf() } - var tab by remember { mutableStateOf(KNOWLEDGE_TABS.first()) } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } - var open by remember { mutableStateOf(null) } - var composing by remember { mutableStateOf(false) } - var draftTitle by remember { mutableStateOf("") } - var draftBody by remember { mutableStateOf("") } +fun KnowledgeScreen(api: Api, workspace: Workspace) { val scope = rememberCoroutineScope() + var tab by remember { mutableStateOf("draft") } + var docs by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var message by remember { mutableStateOf(null) } + var open by remember { mutableStateOf(null) } + var adding by remember { mutableStateOf(false) } + var newTitle by remember { mutableStateOf("") } + var newBody by remember { mutableStateOf("") } suspend fun reload() { loading = true - error = null - try { - val page = api.knowledge(workspace.id, tab.second) - docs.clear() - docs.addAll(page.items) - } catch (e: ApiException) { - error = e.message - } finally { - loading = false + val statuses = when (tab) { + "published" -> listOf("published") + "archived" -> listOf("archived") + "all" -> listOf("draft", "in_review", "published", "archived") + else -> listOf("draft", "in_review") } + runCatching { api.knowledge(workspace.id, statuses) } + .onSuccess { + docs = it.items + message = null + } + .onFailure { message = it.message ?: "知识加载失败" } + loading = false } LaunchedEffect(tab) { reload() } - suspend fun act(doc: KnowledgeDoc, action: String) { - try { - val updated = api.knowledgeAction(workspace.id, doc.id, action) - open = updated - reload() - } catch (e: ApiException) { - // 7004=权限不够(发布要 owner/admin),7003=状态已变 - error = when (e.code) { - 7004 -> "发布要管理员或 owner 来点" - 7003 -> "这个状态不允许该操作,可能刚被别人改过" - else -> e.message - } - reload() - } + fun fail(e: Throwable) { + val code = (e as? ApiException)?.code ?: 0 + message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" } } - suspend fun save() { - val title = draftTitle.trim() - if (title.isEmpty()) return - try { - api.createKnowledge(workspace.id, title, draftBody.trim()) - draftTitle = "" - draftBody = "" - composing = false - tab = KNOWLEDGE_TABS.first() - reload() - } catch (e: ApiException) { - error = e.message - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("${workspace.name} · 知识") }, - navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, - actions = { - TextButton(onClick = { composing = !composing }) { - Text(if (composing) "收起" else "记一条") + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Column(Modifier.padding(top = 4.dp)) { + Text("知识", style = MaterialTheme.typography.headlineSmall) + Text( + "工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 4.dp), + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + knowledgeTabs.forEach { (key, label) -> + SurfaceChip(text = label, selected = tab == key, onClick = { tab = key }) } - }, - ) - }, - ) { padding -> - Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { - Row( - Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - KNOWLEDGE_TABS.forEach { entry -> - FilterChip( - selected = tab == entry, - onClick = { tab = entry }, - label = { Text(entry.first) }, + } + Spacer(Modifier.height(10.dp)) + GhostButton("记一条", onClick = { + adding = true + newTitle = "" + newBody = "" + }) + } + } + + val notice = message + if (notice != null) { + item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) } + } + + if (!loading && docs.isEmpty()) { + item { + EmptyState( + "还没有待核对的知识", + "把工单验收通过,或在任务详情里点「沉淀成知识」", + ) + } + } + + items(docs, key = { it.id }) { doc -> + SoftCard(padding = 12.dp, onClick = { open = doc }) { + Row(verticalAlignment = Alignment.CenterVertically) { + SoftPill(docTypeLabel(doc.knowledge.docType)) + Spacer(Modifier.width(6.dp)) + StatusPill(doc.status) + if (doc.knowledge.generatedBy == "rule") { + Spacer(Modifier.width(6.dp)) + SoftPill("自动整理", Clay, ClaySoft) + } + } + Spacer(Modifier.height(8.dp)) + Text( + doc.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + Text( + sourceLine(doc), + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + } + } + + item { Spacer(Modifier.height(20.dp)) } + } + + val target = open + if (target != null) { + KnowledgeDetailDialog( + api = api, + workspace = workspace, + doc = target, + onClose = { open = null }, + onChanged = { + scope.launch { reload() } + }, + onFail = { fail(it) }, + ) + } + + if (adding) { + AlertDialog( + onDismissRequest = { adding = false }, + title = { Text("记一条", style = MaterialTheme.typography.titleMedium) }, + text = { + Column { + Text( + "手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = newTitle, + onValueChange = { newTitle = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = newBody, + onValueChange = { newBody = it }, + modifier = Modifier.fillMaxWidth().heightIn(min = 90.dp), + placeholder = { Text("正文(可空,之后再补)", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), ) } - } - - if (composing) { - OutlinedTextField( - value = draftTitle, - onValueChange = { draftTitle = it }, - label = { Text("标题:这件事叫什么") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = draftBody, - onValueChange = { draftBody = it }, - label = { Text("正文:以后还会用到的那部分") }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - Button(onClick = { scope.launch { save() } }, enabled = draftTitle.isNotBlank()) { - Text("存成草稿") - } - Spacer(Modifier.height(8.dp)) - } - - error?.let { - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(8.dp)) - } - - when { - loading -> Text("加载中…") - docs.isEmpty() -> Text( - if (tab.first == "待核对") { - "没有待核对的知识。工单验收通过会自动生成一份草稿,等一句话就能发。" - } else { - "这里还没有内容。" - }, - style = MaterialTheme.typography.bodyMedium, - ) - else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(docs, key = { it.id }) { doc -> - Card( - onClick = { open = doc }, - modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp), - ) { - Column(Modifier.fillMaxWidth().padding(12.dp)) { - Text(doc.title, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(4.dp)) - Text( - buildString { - append(DOC_TYPE_LABEL[doc.knowledge.docType] ?: doc.knowledge.docType) - append(" · ") - append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status) - val source = doc.source - if (source != null && source.title.isNotBlank()) { - append(" · 来自") - append(if (source.kind == "ticket") "工单" else "任务") - append("「").append(source.title).append("」") - } else { - append(" · 手记") - } - }, - style = MaterialTheme.typography.bodySmall, - ) - } - } + }, + confirmButton = { + TextButton(onClick = { + scope.launch { + adding = false + runCatching { api.createKnowledge(workspace.id, newTitle.trim(), newBody) } + .onSuccess { reload() } + .onFailure { fail(it) } } - } - } - } - } - - val selected = open - if (selected != null) { - KnowledgeDialog( - doc = selected, - onClose = { open = null }, - onAction = { action -> scope.launch { act(selected, action) } }, + }) { Text("保存草稿") } + }, + dismissButton = { TextButton(onClick = { adding = false }) { Text("取消") } }, + containerColor = Paper, ) } } +private fun sourceLine(doc: KnowledgeDoc): String { + val src = doc.source + return if (src != null && src.title.isNotBlank()) { + "来自${if (src.kind == "ticket") "工单" else "任务"}「${src.title}」" + } else { + "手记" + } +} + @Composable -private fun KnowledgeDialog( +private fun KnowledgeDetailDialog( + api: Api, + workspace: Workspace, doc: KnowledgeDoc, onClose: () -> Unit, - onAction: (String) -> Unit, + onChanged: () -> Unit, + onFail: (Throwable) -> Unit, ) { + val scope = rememberCoroutineScope() + var action by remember { mutableStateOf(doc.status) } + val actions = when (action) { + "draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档") + "in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档") + "published" -> listOf("archive" to "归档") + "archived" -> listOf("restore" to "恢复成草稿") + else -> emptyList() + } + AlertDialog( onDismissRequest = onClose, - title = { Text(doc.title) }, - text = { + title = { Column { - Text( - buildString { - append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status) - doc.knowledge.publishedAt?.let { append(" · 发布于 ").append(it.take(16).replace("T", " ")) } - if (doc.knowledge.generatedBy == "rule") append(" · 由系统整理,请核对后再发布") - }, - style = MaterialTheme.typography.bodySmall, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + SoftPill(docTypeLabel(doc.knowledge.docType)) + Spacer(Modifier.width(6.dp)) + StatusPill(action) + } Spacer(Modifier.height(8.dp)) + Text(doc.title, style = MaterialTheme.typography.titleMedium) + } + }, + text = { + Column( + Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), + ) { + Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted) + Spacer(Modifier.height(10.dp)) Text( - doc.bodyMd.ifBlank { "(还没有正文)" }, - style = MaterialTheme.typography.bodyMedium, + doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" }, + style = MaterialTheme.typography.bodySmall, + color = InkSoft, ) } }, confirmButton = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - when (doc.status) { - "draft" -> { - TextButton(onClick = { onAction("submit_review") }) { Text("提交审议") } - TextButton(onClick = { onAction("publish") }) { Text("发布") } - } - "in_review" -> { - TextButton(onClick = { onAction("withdraw") }) { Text("撤回") } - TextButton(onClick = { onAction("publish") }) { Text("发布") } - } - "published" -> TextButton(onClick = { onAction("archive") }) { Text("归档") } - "archived" -> TextButton(onClick = { onAction("restore") }) { Text("恢复草稿") } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + actions.forEach { (key, label) -> + TextButton(onClick = { + scope.launch { + runCatching { api.knowledgeAction(workspace.id, doc.id, key) } + .onSuccess { + action = when (key) { + "submit_review" -> "in_review" + "withdraw", "restore" -> "draft" + "publish" -> "published" + else -> "archived" + } + onChanged() + } + .onFailure { onFail(it) } + } + }) { Text(label) } } - TextButton(onClick = onClose) { Text("关闭") } } }, + dismissButton = { TextButton(onClick = onClose) { Text("关闭") } }, + containerColor = Paper, ) } diff --git a/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt index 93709b4..0e693b6 100644 --- a/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt @@ -1,20 +1,27 @@ package com.dadao.task.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.statusBarsPadding 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.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -22,115 +29,203 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.dadao.task.data.Api import com.dadao.task.data.ApiException +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive @Composable fun LoginScreen(api: Api, onLoggedIn: () -> Unit) { + val scope = rememberCoroutineScope() var phone by remember { mutableStateOf("") } var code by remember { mutableStateOf("") } + var seconds by remember { mutableStateOf(0) } var devCode by remember { mutableStateOf(null) } - var error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } - var cooldown by remember { mutableStateOf(0) } - val scope = rememberCoroutineScope() + var error by remember { mutableStateOf(null) } - fun sendCode() { - if (phone.length < 6 || cooldown > 0) return + LaunchedEffect(seconds) { + if (seconds > 0) { + delay(1000) + seconds -= 1 + } + } + + fun send() { scope.launch { busy = true - error = null - try { - val data = api.sendSms(phone) - // 开发环境后端会回显验证码,联调时省事;生产不会返回。 - devCode = data?.toString()?.let { Regex("\"dev_code\"\\s*:\\s*\"(\\d+)\"").find(it)?.groupValues?.get(1) } - cooldown = 60 - } catch (e: ApiException) { - error = e.message - } finally { - busy = false - } + runCatching { api.sendSms(phone.trim()) } + .onSuccess { data -> + seconds = 60 + devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content + error = null + } + .onFailure { + val c = (it as? ApiException)?.code ?: 0 + error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" } + } + busy = false } } fun submit() { - if (phone.isBlank() || code.isBlank()) return scope.launch { busy = true - error = null - try { - api.login(phone, code) - onLoggedIn() - } catch (e: ApiException) { - error = e.message - } finally { - busy = false - } + runCatching { api.login(phone.trim(), code.trim()) } + .onSuccess { + error = null + onLoggedIn() + } + .onFailure { + val c = (it as? ApiException)?.code ?: 0 + error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" } + } + busy = false } } Column( - modifier = Modifier.fillMaxSize().padding(28.dp), - verticalArrangement = Arrangement.Center, + Modifier + .fillMaxSize() + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), ) { - Text("达道Task", style = MaterialTheme.typography.headlineMedium) - Text( - "从任务到知识,一条道走到底", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - ) - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(64.dp)) + Box( + Modifier + .background(Primary, RoundedCornerShape(18.dp)) + .padding(horizontal = 18.dp, vertical = 16.dp), + ) { + Column { + Text( + "达道Task", + style = MaterialTheme.typography.titleLarge, + color = Color.White, + ) + Spacer(Modifier.height(4.dp)) + Text( + "任务 · 工单 · 知识", + style = MaterialTheme.typography.labelMedium, + color = PrimaryTint, + ) + } + } + Spacer(Modifier.height(26.dp)) + Text("登录", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(6.dp)) + Text( + "手机号 + 验证码,不用记密码。第一次登录会自动建好账号。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + + Spacer(Modifier.height(22.dp)) OutlinedTextField( value = phone, - onValueChange = { phone = it.filter(Char::isDigit).take(11) }, + onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) }, + modifier = Modifier.fillMaxWidth(), label = { Text("手机号") }, + placeholder = { Text("11 位手机号", color = InkMuted) }, singleLine = true, + shape = RoundedCornerShape(12.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), - modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), ) + Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = code, - onValueChange = { code = it.filter(Char::isDigit).take(6) }, - label = { Text("验证码") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = code, + onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) }, + modifier = Modifier.weight(1f), + label = { Text("验证码") }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Spacer(Modifier.width(10.dp)) + GhostButton( + text = if (seconds > 0) "${seconds}s" else "获取验证码", + enabled = phone.length == 11 && seconds == 0 && !busy, + onClick = { send() }, + ) + } + + val hint = devCode + if (hint != null) { + Spacer(Modifier.height(10.dp)) + SoftPill( + "开发环境验证码:$hint", + Primary, + PrimarySoft, + Modifier.fillMaxWidth(), + ) + } + + val notice = error + if (notice != null) { + Spacer(Modifier.height(10.dp)) + SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) + } + + Spacer(Modifier.height(22.dp)) + PrimaryButton( + text = if (busy) "请稍候…" else "登录", + enabled = phone.length == 11 && code.length >= 4 && !busy, modifier = Modifier.fillMaxWidth(), - trailingIcon = { - TextButton(enabled = !busy && cooldown == 0, onClick = { sendCode() }) { - Text(if (cooldown > 0) "${cooldown}s" else "获取验证码") - } - }, - ) - - devCode?.let { - Spacer(Modifier.height(8.dp)) - Text("开发环境验证码:$it", style = MaterialTheme.typography.bodySmall, color = Accent) - } - error?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - - Spacer(Modifier.height(20.dp)) - Button( onClick = { submit() }, - enabled = !busy && phone.length == 11 && code.isNotBlank(), - modifier = Modifier.fillMaxWidth().height(48.dp), - ) { - if (busy) CircularProgressIndicator(Modifier.height(20.dp), strokeWidth = 2.dp) - else Text("登录 / 注册") - } - Spacer(Modifier.height(12.dp)) - Text( - "未注册的手机号将自动创建账号", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.align(Alignment.CenterHorizontally), ) + + Spacer(Modifier.height(28.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + "一张收件箱收住所有零碎的事" to "在哪儿想到就记,之后再分类", + "卡住了就升级成工单" to "交给能办的人,办完自动回来", + "办完的事变成团队的知识" to "验收通过自动出草稿,核对后发布", + ).forEach { (title, desc) -> + Row(verticalAlignment = Alignment.Top) { + Box( + Modifier + .padding(top = 6.dp) + .width(5.dp) + .height(5.dp) + .background(Clay, RoundedCornerShape(3.dp)), + ) + Spacer(Modifier.width(8.dp)) + Column { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + desc, + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + } + } + } + } + Spacer(Modifier.height(40.dp)) } } diff --git a/android/app/src/main/java/com/dadao/task/ui/MembersScreen.kt b/android/app/src/main/java/com/dadao/task/ui/MembersScreen.kt new file mode 100644 index 0000000..de7ab8f --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/MembersScreen.kt @@ -0,0 +1,199 @@ +package com.dadao.task.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.dadao.task.data.Api +import com.dadao.task.data.ApiException +import com.dadao.task.data.Invitation +import com.dadao.task.data.Member +import com.dadao.task.data.Workspace +import kotlinx.coroutines.launch + +/** 邀请落地页的域名。客户端只需要拼链接,真正校验在工作区加入接口里。 */ +private const val JOIN_BASE = "https://dadaotask.com/join" + +private fun roleLabel(role: String): String = when (role) { + "owner" -> "负责人" + "admin" -> "管理员" + "member" -> "成员" + else -> "访客" +} + +@Composable +fun MembersScreen(api: Api, workspace: Workspace) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var members by remember { mutableStateOf>(emptyList()) } + var invite by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + suspend fun reload() { + runCatching { api.members(workspace.id) } + .onSuccess { members = it } + .onFailure { message = it.message ?: "成员加载失败" } + } + + LaunchedEffect(workspace.id) { reload() } + + val canInvite = workspace.role == "owner" || workspace.role == "admin" + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Column(Modifier.padding(top = 4.dp)) { + Text("成员", style = MaterialTheme.typography.headlineSmall) + Text( + "谁在这个工作区里,各自能做什么。席位按成员计费,访客不占。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + + val notice = message + if (notice != null) { + item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) } + } + + if (canInvite) { + item { + SoftCard { + SectionLabel("邀请") + Text( + "给同事一个链接:他打开后自动注册并加入这个工作区。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + Spacer(Modifier.height(10.dp)) + val current = invite + if (current == null) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton("邀请成员", enabled = !busy, onClick = { + scope.launch { + busy = true + runCatching { api.createInvitation(workspace.id, "member") } + .onSuccess { + invite = it + message = null + } + .onFailure { message = it.message ?: "生成邀请失败" } + busy = false + } + }) + if (workspace.role == "owner") { + GhostButton("邀请管理员", enabled = !busy, onClick = { + scope.launch { + busy = true + runCatching { api.createInvitation(workspace.id, "admin") } + .onSuccess { + invite = it + message = null + } + .onFailure { message = it.message ?: "生成邀请失败" } + busy = false + } + }) + } + } + } else { + val link = "$JOIN_BASE/${workspace.id}/${current.code}" + Surface(color = PaperSunken, shape = RoundedCornerShape(10.dp)) { + Text( + link, + style = MaterialTheme.typography.bodySmall, + color = InkSoft, + modifier = Modifier.padding(10.dp), + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton("分享链接", onClick = { share(context, link) }) + GhostButton("复制", onClick = { + copy(context, link) + message = "链接已复制,发给要邀请的人" + }) + } + Spacer(Modifier.height(6.dp)) + Text( + "链接一次有效,7 天内用完。", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + } + } + } + } + + item { SectionLabel("成员 ${members.size}") } + + items(members, key = { it.userId }) { m -> + SoftCard(padding = 12.dp) { + Row(verticalAlignment = Alignment.CenterVertically) { + Avatar(m.nickname.ifBlank { maskPhone(m.phone) }, 36.dp) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + m.nickname.ifBlank { "同事" }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + maskPhone(m.phone), + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + } + SoftPill(roleLabel(m.role)) + } + } + } + + item { Spacer(Modifier.height(20.dp)) } + } +} + +private fun copy(context: Context, text: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("邀请链接", text)) +} + +private fun share(context: Context, text: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "邀请你加入达道Task 的工作区:$text") + } + context.startActivity(Intent.createChooser(intent, "分享邀请链接")) +} diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt b/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt new file mode 100644 index 0000000..1d7788f --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt @@ -0,0 +1,378 @@ +package com.dadao.task.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.dadao.task.data.Activity +import com.dadao.task.data.Api +import com.dadao.task.data.ApiException +import com.dadao.task.data.Comment +import com.dadao.task.data.ItemDetail +import com.dadao.task.data.Member +import kotlinx.coroutines.launch + +/** 任务可以手动走的目标状态(服务端状态机会拒绝非法组合)。 */ +private val taskActions = listOf( + "todo" to "待办", + "doing" to "开工", + "done" to "完成", + "blocked" to "阻塞", +) + +/** + * 事项详情。任务与工单共用一套界面,靠 kind 切换下半部分: + * 任务给「改状态 / 升级成工单 / 沉淀成知识」,工单给「去工单页处理」。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskDetailSheet( + api: Api, + workspaceId: String, + itemId: String, + onClose: () -> Unit, + onChanged: () -> Unit, +) { + val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + var detail by remember { mutableStateOf(null) } + var members by remember { mutableStateOf>(emptyList()) } + var meId by remember { mutableStateOf("") } + var comments by remember { mutableStateOf>(emptyList()) } + var title by remember { mutableStateOf("") } + var comment by remember { mutableStateOf("") } + var promoteTo by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + suspend fun reload() { + runCatching { api.itemDetail(workspaceId, itemId) } + .onSuccess { + detail = it + title = it.title + } + .onFailure { message = it.message ?: "打不开这条事项" } + runCatching { api.comments(workspaceId, itemId) }.onSuccess { comments = it } + runCatching { api.members(workspaceId) }.onSuccess { members = it } + runCatching { api.me() }.onSuccess { meId = it.user.id } + } + + LaunchedEffect(itemId) { reload() } + + fun fail(e: Throwable) { + val code = (e as? ApiException)?.code ?: 0 + message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" } + } + + ModalBottomSheet(onDismissRequest = onClose, sheetState = sheet, containerColor = Paper) { + val d = detail + if (d == null) { + EmptyState("正在打开…", "读完这条事项的完整内容就显示出来") + return@ModalBottomSheet + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 720.dp) + .padding(horizontal = 18.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusPill(d.status) + Spacer(Modifier.width(6.dp)) + Text( + "版本 ${d.version}", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + Spacer(Modifier.width(6.dp)) + if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.titleLarge, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + } + } + + val notice = message + if (notice != null) { + item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) } + } + + if (d.kind != "ticket") { + item { + SoftCard { + SectionLabel("改状态") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + taskActions.forEach { (status, label) -> + if (d.status == status) { + Surface( + color = PrimarySoft, + contentColor = Primary, + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, PrimaryTint), + ) { + Text( + "$label(当前)", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + ) + } + } else { + GhostButton(label, enabled = !busy, onClick = { + scope.launch { + busy = true + runCatching { api.setStatus(workspaceId, d.id, status, d.version) } + .onSuccess { + message = null + onChanged() + } + .onFailure { fail(it) } + busy = false + reload() + } + }) + } + } + } + } + } + + item { + SoftCard { + SectionLabel("这件事卡在别人手里?升级成工单") + Text( + "升级后这条任务会变成「被阻塞」,工单办好并验收通过后自动回到待办,不用你自己盯。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + members + .filter { it.role != "guest" && it.userId != meId } + .take(4) + .forEach { m -> + SurfaceChip( + text = m.nickname.ifBlank { maskPhone(m.phone) }, + selected = promoteTo == m.userId, + onClick = { promoteTo = m.userId }, + ) + } + } + Spacer(Modifier.height(10.dp)) + PrimaryButton( + text = "升级成工单", + enabled = promoteTo.isNotBlank() && !busy, + onClick = { + scope.launch { + busy = true + runCatching { api.promote(workspaceId, d.id, promoteTo, d.version) } + .onSuccess { + message = "已把工单交给对方,这条任务现在处于被阻塞" + onChanged() + } + .onFailure { fail(it) } + busy = false + reload() + } + }, + ) + } + } + + item { + SoftCard { + SectionLabel("这件事值得留下来?沉淀成知识") + Text( + "会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,核对过才进团队知识。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + Spacer(Modifier.height(10.dp)) + GhostButton("沉淀成知识", enabled = !busy, onClick = { + scope.launch { + busy = true + runCatching { api.sediment(workspaceId, d.id) } + .onSuccess { + message = "已生成知识草稿,去「知识」核对后发布" + onChanged() + } + .onFailure { fail(it) } + busy = false + } + }) + } + } + } else { + item { + SoftCard { + SectionLabel("工单") + Text( + "工单的受理、办理、验收在「工单」页里操作,那边能看到对方提交的说明和验收结论。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + } + } + } + + item { + SoftCard { + SectionLabel("评论 ${comments.size}") + if (comments.isEmpty()) { + Text( + "还没有评论。写一句,将来的你会感谢现在记下来的你。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + } + comments.takeLast(8).forEach { c -> + Row( + Modifier.padding(vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Avatar(nameOf(members, c.authorId), 28.dp) + Column { + Text( + nameOf(members, c.authorId), + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + Text(c.bodyMd, style = MaterialTheme.typography.bodyMedium) + } + } + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("补充一句进展…", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Spacer(Modifier.height(8.dp)) + PrimaryButton("发表评论", enabled = comment.isNotBlank() && !busy, onClick = { + scope.launch { + busy = true + runCatching { api.addComment(workspaceId, d.id, comment) } + .onSuccess { + comment = "" + onChanged() + } + .onFailure { fail(it) } + busy = false + reload() + } + }) + } + } + + item { + SoftCard { + SectionLabel("时间线") + d.activities.takeLast(12).forEach { a -> TimelineRow(a) } + } + } + + item { Spacer(Modifier.height(24.dp)) } + } + } +} + +@Composable +private fun TimelineRow(a: Activity) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(6.dp).background(LineStrong, CircleShape)) + Text( + actionLabel(a.action), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Text(formatTime(a.createdAt), style = MaterialTheme.typography.labelSmall, color = InkMuted) + } +} + +@Composable +fun SurfaceChip(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.clickable { onClick() }, + color = if (selected) PrimarySoft else Color.White, + contentColor = if (selected) Primary else InkSoft, + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, if (selected) PrimaryTint else Line), + ) { + Text( + text, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } +} + +private fun nameOf(members: List, userId: String): String = + members.firstOrNull { it.userId == userId }?.nickname?.ifBlank { null } ?: "同事" + +fun maskPhone(phone: String): String = + if (phone.length == 11) phone.substring(0, 3) + "****" + phone.substring(7) else phone diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt index aac0c22..1a938ff 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt @@ -1,40 +1,39 @@ package com.dadao.task.ui +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues 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.layout.width 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip +import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TextFieldDefaults 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.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import com.dadao.task.data.Api @@ -43,238 +42,257 @@ import com.dadao.task.data.Item import com.dadao.task.data.Workspace import kotlinx.coroutines.launch -private val VIEWS = listOf( +private val views = listOf( "today" to "今天", "active" to "进行中", "inbox" to "收件箱", "done" to "已完成", + "all" 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, - onOpenTickets: () -> Unit = {}, - onOpenKnowledge: () -> Unit = {}, -) { - val items = remember { mutableStateListOf() } - var view by remember { mutableStateOf("today") } - var draft by remember { mutableStateOf("") } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } - var detailId by remember { mutableStateOf(null) } +fun TaskListScreen(api: Api, workspace: Workspace) { val scope = rememberCoroutineScope() + var view by remember { mutableStateOf("active") } + var mode by remember { mutableStateOf("list") } + var items by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var draft by remember { mutableStateOf("") } + var query by remember { mutableStateOf("") } + var openId by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } 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("返回") } }, - actions = { - TextButton(onClick = onOpenTickets) { Text("工单") } - TextButton(onClick = onOpenKnowledge) { 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) }, - ) - } + runCatching { api.items(workspace.id, view = view, query = query.ifBlank { null }) } + .onSuccess { + items = it.items + error = null } + .onFailure { error = it.message ?: "刷新失败" } + loading = false + } - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("加一件要做的事,回车创建") }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { scope.launch { add() } }), - ) + LaunchedEffect(view, query) { reload() } - error?.let { - Spacer(Modifier.height(8.dp)) - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + fun fail(e: Throwable) { + val code = (e as? ApiException)?.code ?: 0 + error = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" } + } + + Column(Modifier.fillMaxSize()) { + Column(Modifier.padding(horizontal = 16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("任务", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.weight(1f)) + ModeSwitch(mode) { mode = it } } + Text( + "收下、开工、完成,一条路走到底。工单办完会自己回到这里。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 4.dp), + ) + val notice = error + if (notice != null) { + SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth().padding(top = 8.dp)) + } + } - Spacer(Modifier.height(8.dp)) + if (mode == "board") { + Spacer(Modifier.height(10.dp)) + BoardScreen(api = api, workspace = workspace, onOpen = { openId = it }) + } else { + Box(Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(top = 10.dp, bottom = 96.dp), + ) { + item { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + views.forEach { (key, label) -> + SurfaceChip(text = label, selected = view == key, onClick = { view = key }) + } + } + } - 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) } }, + item { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + "搜标题或正文…", + color = InkMuted, + style = MaterialTheme.typography.bodySmall, ) - 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), - ) - } - } - } - } + }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + } + + if (!loading && items.isEmpty()) { + item { + EmptyState( + "这里还空着", + "加一件要做的事,或者换个视图看看——工单办完会自己回到待办", + ) } } + + items(items, key = { it.id }) { item -> + TaskRow( + item = item, + onOpen = { openId = item.id }, + onToggle = { + scope.launch { + val target = if (item.status == "done") "todo" else "done" + runCatching { api.setStatus(workspace.id, item.id, target, item.version) } + .onSuccess { reload() } + .onFailure { fail(it) } + } + }, + ) + } + } + + QuickAddBar( + value = draft, + onValue = { draft = it }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { text -> + scope.launch { + runCatching { api.createItem(workspace.id, text, "inbox") } + .onSuccess { + draft = "" + view = "inbox" + reload() + } + .onFailure { fail(it) } + } } } } } - detailId?.let { id -> - var detail by remember(id) { mutableStateOf(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("关闭") } }, + openId?.let { id -> + TaskDetailSheet( + api = api, + workspaceId = workspace.id, + itemId = id, + onClose = { openId = null }, + onChanged = { scope.launch { reload() } }, ) } } + +@Composable +private fun ModeSwitch(mode: String, onMode: (String) -> Unit) { + Row( + Modifier + .background(PaperSunken, RoundedCornerShape(9.dp)) + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + listOf("list" to "列表", "board" to "看板").forEach { (key, label) -> + val active = mode == key + Surface( + color = if (active) Color.White else Color.Transparent, + contentColor = if (active) Ink else InkMuted, + shape = RoundedCornerShape(7.dp), + modifier = Modifier.clickable { onMode(key) }, + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + ) + } + } + } +} + +@Composable +private fun QuickAddBar( + value: String, + onValue: (String) -> Unit, + modifier: Modifier = Modifier, + onSubmit: (String) -> Unit, +) { + SoftCard(modifier = modifier, padding = 10.dp) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = value, + onValueChange = onValue, + modifier = Modifier.weight(1f), + placeholder = { + Text( + "加一件要做的事,支持 #标签 !高", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + }, + singleLine = true, + shape = RoundedCornerShape(9.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = PaperSunken, + unfocusedContainerColor = PaperSunken, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + Spacer(Modifier.width(8.dp)) + PrimaryButton("添加", enabled = value.isNotBlank(), onClick = { onSubmit(value.trim()) }) + } + } +} + +@Composable +private fun TaskRow(item: Item, onOpen: () -> Unit, onToggle: () -> Unit) { + SoftCard(padding = 12.dp) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = item.isDone, + onCheckedChange = { onToggle() }, + colors = CheckboxDefaults.colors( + checkedColor = Sage, + uncheckedColor = LineStrong, + checkmarkColor = Color.White, + ), + ) + Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f).clickable { onOpen() }) { + Text( + item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = if (item.isDone) InkMuted else Ink, + textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None, + ) + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + StatusPill(item.status) + PriorityTag(item.priority) + if (!item.dueAt.isNullOrBlank()) { + SoftPill("截止 " + formatTime(item.dueAt).take(5)) + } + item.tags.take(2).forEach { SoftPill("#" + it.name, Primary, PrimarySoft) } + } + } + } + } +} diff --git a/android/app/src/main/java/com/dadao/task/ui/Theme.kt b/android/app/src/main/java/com/dadao/task/ui/Theme.kt index f03ddbd..febaa3b 100644 --- a/android/app/src/main/java/com/dadao/task/ui/Theme.kt +++ b/android/app/src/main/java/com/dadao/task/ui/Theme.kt @@ -1,30 +1,95 @@ package com.dadao.task.ui +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.Typography import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp -// 与 Web 端 tailwind.config.js 的色板保持一致:墨青主色 + 陶土色点缀。 -val Ink = Color(0xFF3F5A68) -val InkDark = Color(0xFF2C4049) -val Accent = Color(0xFFC2703D) -val Paper = Color(0xFFF7F8F8) +/** + * 与 Web 端 tailwind.config.js 同一套色板:暖白纸面 + 墨松绿主色 + 陶土橙强调。 + * 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。 + */ +val Paper = Color(0xFFFBF9F6) +val PaperSunken = Color(0xFFF3F0EA) +val Line = Color(0xFFE4E0D8) +val LineStrong = Color(0xFFD3CEC2) + +val Ink = Color(0xFF1F2A28) +val InkSoft = Color(0xFF4A5854) +val InkMuted = Color(0xFF7C8985) + +val Primary = Color(0xFF2A5A50) +val PrimaryDeep = Color(0xFF1E443C) +val PrimarySoft = Color(0xFFE3EDE8) +val PrimaryTint = Color(0xFFD2E3DB) + +val Clay = Color(0xFFC2703D) +val ClaySoft = Color(0xFFFBEADF) + +val Sage = Color(0xFF2F7D5E) +val SageSoft = Color(0xFFE2F0E9) +val Wheat = Color(0xFFA97C25) +val WheatSoft = Color(0xFFF8EED7) +val Rust = Color(0xFFB04A32) +val RustSoft = Color(0xFFFBE5DF) +val Sky = Color(0xFF3E6F91) +val SkySoft = Color(0xFFE4EEF5) private val colors = lightColorScheme( - primary = Ink, + primary = Primary, onPrimary = Color.White, - primaryContainer = Color(0xFFDCE6EA), - onPrimaryContainer = InkDark, - secondary = Accent, + primaryContainer = PrimarySoft, + onPrimaryContainer = PrimaryDeep, + secondary = Clay, onSecondary = Color.White, + secondaryContainer = ClaySoft, + onSecondaryContainer = Color(0xFF7A3F1B), + tertiary = Sage, + onTertiary = Color.White, background = Paper, + onBackground = Ink, surface = Color.White, - onSurface = Color(0xFF1B2528), + onSurface = Ink, + surfaceVariant = PaperSunken, + onSurfaceVariant = InkSoft, + outline = LineStrong, + outlineVariant = Line, + error = Rust, + onError = Color.White, + errorContainer = RustSoft, + onErrorContainer = Color(0xFF7A2C1B), +) + +private val type = Typography( + displaySmall = TextStyle(fontSize = 26.sp, lineHeight = 34.sp, fontWeight = FontWeight.Bold), + headlineSmall = TextStyle(fontSize = 22.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold), + titleLarge = TextStyle(fontSize = 18.sp, lineHeight = 26.sp, fontWeight = FontWeight.SemiBold), + titleMedium = TextStyle(fontSize = 15.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold), + titleSmall = TextStyle(fontSize = 13.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium), + bodyLarge = TextStyle(fontSize = 15.sp, lineHeight = 23.sp), + bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 22.sp), + bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 19.sp), + labelLarge = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium), + labelMedium = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + labelSmall = TextStyle(fontSize = 11.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium), +) + +private val shapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(10.dp), + medium = RoundedCornerShape(14.dp), + large = RoundedCornerShape(18.dp), + extraLarge = RoundedCornerShape(24.dp), ) @Composable fun DadaoTheme(content: @Composable () -> Unit) { - MaterialTheme(colorScheme = colors, typography = Typography(), content = content) + MaterialTheme(colorScheme = colors, typography = type, shapes = shapes, content = content) } diff --git a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt index a9caaef..6106468 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt @@ -10,24 +10,24 @@ 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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TextFieldDefaults 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.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.dadao.task.data.Api import com.dadao.task.data.ApiException @@ -35,165 +35,198 @@ import com.dadao.task.data.Ticket import com.dadao.task.data.Workspace import kotlinx.coroutines.launch -private val ROLES = listOf( +private val ticketRoles = listOf( "assigned" to "派给我的", "requested" to "我发起的", + "all" 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() } - var role by remember { mutableStateOf("assigned") } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } +fun TicketsScreen(api: Api, workspace: Workspace) { val scope = rememberCoroutineScope() + var role by remember { mutableStateOf("assigned") } + var tickets by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var message by remember { mutableStateOf(null) } + var verifyTarget by remember { mutableStateOf(null) } + var note by remember { mutableStateOf("") } + var resolution by remember { mutableStateOf("") } 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 - } + runCatching { api.tickets(workspace.id, role) } + .onSuccess { + tickets = it.tickets + message = null + } + .onFailure { message = it.message ?: "工单加载失败" } + 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() + fun fail(e: Throwable) { + val code = (e as? ApiException)?.code ?: 0 + message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" } + } + + fun act(ticket: Ticket, action: String) { + scope.launch { + runCatching { api.ticketAction(workspace.id, ticket.id, action) } + .onSuccess { + message = null + reload() + } + .onFailure { fail(it) } } } - 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) }, - ) + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Column(Modifier.padding(top = 4.dp)) { + Text("工单", style = MaterialTheme.typography.headlineSmall) + Text( + "把卡住的事交给能办的人。办好并验收通过,原任务会自动回到待办。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 4.dp), + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ticketRoles.forEach { (key, label) -> + SurfaceChip(text = label, selected = role == key, onClick = { role = key }) + } } } + } - error?.let { - Spacer(Modifier.height(8.dp)) - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - } + val notice = message + if (notice != null) { + item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) } + } - when { - loading -> Text("加载中…") - tickets.isEmpty() -> Text( - if (role == "assigned") "没有需要你出手的事。" else "你还没找人帮过忙。", - style = MaterialTheme.typography.bodyMedium, + if (!loading && tickets.isEmpty()) { + item { + EmptyState( + "这里还没有工单", + "在任务详情里点「升级成工单」,把要别人配合的事交出去", ) - 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("取消") - } - } - } + } + } + + items(tickets, key = { it.id }) { ticket -> + SoftCard(padding = 12.dp) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + ticket.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + StatusPill(ticket.status) + PriorityTag(ticket.priority) + if (ticket.ticket.rejectCount > 0) { + SoftPill("被退回 ${ticket.ticket.rejectCount} 次", Clay, ClaySoft) } } } } + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + when (ticket.status) { + "open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") }) + "accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") }) + "in_progress" -> PrimaryButton("提交验收", onClick = { + scope.launch { + runCatching { api.ticketAction(workspace.id, ticket.id, "submit") } + .onSuccess { + message = null + reload() + } + .onFailure { fail(it) } + } + }) + "pending_verify" -> { + PrimaryButton("通过验收", onClick = { + verifyTarget = ticket + note = "" + resolution = "" + }) + GhostButton("退回", onClick = { + scope.launch { + runCatching { + api.verifyTicket(workspace.id, ticket.id, approve = false, note = "") + } + .onSuccess { + message = null + reload() + } + .onFailure { fail(it) } + } + }) + } + } + } } } + + item { Spacer(Modifier.height(20.dp)) } + } + + val target = verifyTarget + if (target != null) { + AlertDialog( + onDismissRequest = { verifyTarget = null }, + title = { Text("验收这条工单", style = MaterialTheme.typography.titleMedium) }, + text = { + Column { + Text( + "验收通过后原任务回到待办,并自动生成一份知识草稿。", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + ) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = resolution, + onValueChange = { resolution = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("验收结论:这件事留下了什么结论", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + } + }, + confirmButton = { + TextButton(onClick = { + scope.launch { + val t = target + verifyTarget = null + runCatching { + api.verifyTicket(workspace.id, t.id, approve = true, note = note, resolution = resolution) + } + .onSuccess { + message = null + reload() + } + .onFailure { fail(it) } + } + }) { Text("通过验收") } + }, + dismissButton = { TextButton(onClick = { verifyTarget = null }) { Text("再想想") } }, + containerColor = Paper, + ) } } - -@Composable -private fun ActionButton(label: String, onClick: () -> Unit) { - TextButton(onClick = onClick) { Text(label) } -} diff --git a/android/app/src/main/java/com/dadao/task/ui/Widgets.kt b/android/app/src/main/java/com/dadao/task/ui/Widgets.kt new file mode 100644 index 0000000..5a8cced --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/Widgets.kt @@ -0,0 +1,273 @@ +package com.dadao.task.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** 状态 → 中文名与配色。任务的、工单的、知识的都在这张表里,避免各页面各写一套。 */ +data class StatusStyle(val label: String, val fg: Color, val bg: Color) + +fun statusStyle(status: String): StatusStyle = when (status) { + "inbox" -> StatusStyle("收件箱", InkSoft, PaperSunken) + "todo" -> StatusStyle("待办", Sky, SkySoft) + "doing", "active" -> StatusStyle("进行中", Primary, PrimarySoft) + "blocked" -> StatusStyle("被阻塞", Wheat, WheatSoft) + "done" -> StatusStyle("已完成", Sage, SageSoft) + "canceled" -> StatusStyle("已取消", InkMuted, PaperSunken) + "open" -> StatusStyle("待接收", Sky, SkySoft) + "accepted" -> StatusStyle("待办理", Primary, PrimarySoft) + "in_progress" -> StatusStyle("办理中", Primary, PrimarySoft) + "pending_verify" -> StatusStyle("待验收", Wheat, WheatSoft) + "draft" -> StatusStyle("草稿", InkSoft, PaperSunken) + "in_review" -> StatusStyle("审议中", Sky, SkySoft) + "published" -> StatusStyle("已发布", Sage, SageSoft) + "archived" -> StatusStyle("已归档", InkMuted, PaperSunken) + else -> StatusStyle(status, InkSoft, PaperSunken) +} + +fun priorityLabel(priority: Int): String = when (priority) { + 3 -> "高" + 2 -> "中" + 1 -> "低" + else -> "无" +} + +fun priorityColor(priority: Int): Color = when (priority) { + 3 -> Rust + 2 -> Clay + 1 -> Sky + else -> LineStrong +} + +@Composable +fun StatusPill(status: String, modifier: Modifier = Modifier) { + val style = statusStyle(status) + Surface( + modifier = modifier, + color = style.bg, + contentColor = style.fg, + shape = RoundedCornerShape(6.dp), + border = null, + ) { + Text( + style.label, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +@Composable +fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifier: Modifier = Modifier) { + Surface(modifier = modifier, color = bg, contentColor = fg, shape = RoundedCornerShape(6.dp)) { + Text(text, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) + } +} + +/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */ +@Composable +fun SoftCard( + modifier: Modifier = Modifier, + padding: Dp = 14.dp, + onClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val base = modifier + .fillMaxWidth() + .border(1.dp, Line, RoundedCornerShape(14.dp)) + .background(Color.White, RoundedCornerShape(14.dp)) + Surface( + modifier = if (onClick != null) base.clickable { onClick() } else base, + color = Color.White, + shape = RoundedCornerShape(14.dp), + ) { + Column(modifier = Modifier.padding(padding), content = content) + } +} + +@Composable +fun SectionLabel(text: String, modifier: Modifier = Modifier) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + modifier = modifier.padding(bottom = 6.dp), + ) +} + +@Composable +fun Avatar(name: String, size: Dp = 34.dp) { + val initial = name.trim().take(1).ifBlank { "我" } + Box( + modifier = Modifier + .size(size) + .background(PrimarySoft, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text(initial, style = MaterialTheme.typography.titleSmall, color = Primary) + } +} + +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = RoundedCornerShape(10.dp), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 11.dp), + colors = ButtonDefaults.buttonColors(containerColor = Primary, contentColor = Color.White), + ) { + Text(text, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +fun GhostButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = RoundedCornerShape(10.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), + border = BorderStroke(1.dp, LineStrong), + colors = ButtonDefaults.outlinedButtonColors(contentColor = InkSoft), + ) { + Text(text, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +fun EmptyState(title: String, desc: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 44.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(46.dp) + .background(PaperSunken, RoundedCornerShape(14.dp)), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(18.dp) + .border(1.5.dp, LineStrong, CircleShape), + ) + } + Spacer(Modifier.height(12.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, color = InkSoft) + Spacer(Modifier.height(4.dp)) + Text( + desc, + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun DividerLine(modifier: Modifier = Modifier) { + Box(modifier.fillMaxWidth().height(1.dp).background(Line)) +} + +@Composable +fun PriorityTag(priority: Int, modifier: Modifier = Modifier) { + if (priority <= 0) return + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(Modifier.size(6.dp).background(priorityColor(priority), CircleShape)) + Text(priorityLabel(priority), style = MaterialTheme.typography.labelSmall, color = InkMuted) + } +} + +private val dateFmt = DateTimeFormatter.ofPattern("MM/dd HH:mm") + +/** 后端时间形如 2026-10-28T06:14:00.123456+00:00,统一按本地时区显示。 */ +fun formatTime(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return runCatching { + Instant.parse(iso).atZone(ZoneId.systemDefault()).format(dateFmt) + }.getOrElse { + runCatching { + java.time.OffsetDateTime.parse(iso).atZoneSameInstant(ZoneId.systemDefault()).format(dateFmt) + }.getOrDefault(iso.take(16).replace("T", " ")) + } +} + +/** 动作名 → 人话,时间线里不出现数据库里的英文 key。 */ +fun actionLabel(action: String): String = when (action) { + "created" -> "创建" + "status_changed" -> "状态变更" + "updated" -> "更新" + "commented" -> "评论" + "promoted_to_ticket" -> "升级成工单" + "ticket_accepted" -> "受理工单" + "ticket_started" -> "开始办理" + "ticket_submitted" -> "提交验收" + "ticket_done" -> "验收通过" + "ticket_rejected" -> "验收驳回" + "ticket_canceled" -> "取消工单" + "ticket_reassigned" -> "改派工单" + "task_unblocked" -> "原任务解阻塞" + "knowledge_status_changed" -> "知识状态变更" + else -> action +} + +fun verifyStatusCode(code: Int): String = when (code) { + 4009 -> "这条刚被别人改过,已刷新为最新内容" + 7004 -> "只有负责人和管理员能发布知识" + 7002 -> "这件事已经有一份知识草稿了" + 6004 -> "这一步只有对方或管理员能做" + 6003, 5002 -> "当前状态不允许这么做" + 3004 -> "你的权限不够" + else -> "" +}