feat(android): 设计系统与五个页面重做,并补齐升级工单、邀请、看板

此前安卓只到"能登录、能看列表",且从没编译过。这轮把它做到与 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)。
This commit is contained in:
dadao
2026-10-28 18:37:35 +08:00
parent 5be55fdccb
commit 52ec6474a9
13 changed files with 2402 additions and 812 deletions
@@ -1,8 +1,10 @@
package com.dadao.task package com.dadao.task
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -13,15 +15,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.dadao.task.data.Api import com.dadao.task.data.Api
import com.dadao.task.data.TokenStore import com.dadao.task.data.TokenStore
import com.dadao.task.data.Workspace import com.dadao.task.data.Workspace
import com.dadao.task.ui.BoardScreen
import com.dadao.task.ui.DadaoTheme import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.HomeScreen import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.MembersScreen
import com.dadao.task.ui.TaskListScreen import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen import com.dadao.task.ui.TicketsScreen
import com.dadao.task.ui.WorkspaceScaffold
import com.dadao.task.ui.WorkspaceTab
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -30,22 +37,37 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
DadaoTheme { DadaoTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { 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/<workspaceId>/<code> 里拆出工作区与邀请码。 */
private fun parseJoin(uri: String?): Pair<String, String>? {
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 @Composable
private fun AppRoot() { private fun AppRoot(joinUri: String?) {
val context = androidx.compose.ui.platform.LocalContext.current val context = LocalContext.current
val tokens = remember { TokenStore(context) } val tokens = remember { TokenStore(context) }
val api = remember { Api(tokens) } val api = remember { Api(tokens) }
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) } 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<Workspace?>(null) } var current by remember { mutableStateOf<Workspace?>(null) }
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
var unread by remember { mutableStateOf(0) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。 // 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) { 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) { when (screen) {
Screen.Splash -> Unit // 校验中,空白过渡即可 Screen.Splash -> Unit
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home }) Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
Screen.Home -> HomeScreen( Screen.Home -> HomeScreen(
api = api, api = api,
@@ -71,37 +113,29 @@ private fun AppRoot() {
}, },
onOpenWorkspace = { ws -> onOpenWorkspace = { ws ->
current = ws current = ws
screen = Screen.Tasks tab = WorkspaceTab.Tasks
screen = Screen.Workspace
}, },
) )
Screen.Tasks -> { Screen.Workspace -> {
val ws = current val ws = current
if (ws == null) { if (ws == null) {
screen = Screen.Home screen = Screen.Home
} else { } else {
TaskListScreen( WorkspaceScaffold(
api = api,
workspace = ws, workspace = ws,
onBack = { screen = Screen.Home }, tab = tab,
onOpenTickets = { screen = Screen.Tickets }, unread = unread,
onOpenKnowledge = { screen = Screen.Knowledge }, onTab = { tab = it },
) onBackHome = { screen = Screen.Home },
} ) {
} when (tab) {
Screen.Tickets -> { WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
val ws = current WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
if (ws == null) { WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
screen = Screen.Home WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
} 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 })
} }
} }
} }
@@ -111,9 +111,22 @@ data class Activity(
@SerialName("created_at") val createdAt: String = "", @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<Comment> = emptyList())
/** 详情比列表多一个 kind 与 activities:工单要按 kind 切界面,时间线要用详情里的。 */
@Serializable @Serializable
data class ItemDetail( data class ItemDetail(
val id: String, val id: String,
val kind: String = "task",
val title: String, val title: String,
@SerialName("body_md") val bodyMd: String = "", @SerialName("body_md") val bodyMd: String = "",
val status: String, val status: String,
@@ -220,6 +233,9 @@ data class KnowledgePage(
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ---- // ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
@Serializable
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
@Serializable @Serializable
private data class SmsRequest(val phone: String) private data class SmsRequest(val phone: String)
@@ -235,6 +251,9 @@ private data class WorkspaceCreateRequest(val name: String)
@Serializable @Serializable
private data class JoinRequest(val code: String) private data class JoinRequest(val code: String)
@Serializable
private data class InviteRequest(val role: String)
@Serializable @Serializable
private data class CreateItemRequest(val title: String, val status: String) 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 保持一致 // 与 backend/app/config.py 的 api_prefix 保持一致
private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1" private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1"
/** 退出登录只清本地令牌;服务端的 refresh 是无状态的,没有注销接口。 */
fun logout() {
tokens.clear()
}
suspend fun sendSms(phone: String): JsonElement? = call( suspend fun sendSms(phone: String): JsonElement? = call(
path = "/auth/sms/send", path = "/auth/sms/send",
body = json.encodeToString(SmsRequest(phone)), body = json.encodeToString(SmsRequest(phone)),
@@ -325,8 +349,17 @@ class Api(private val tokens: TokenStore) {
suspend fun members(workspaceId: String): List<Member> = suspend fun members(workspaceId: String): List<Member> =
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
suspend fun createInvitation(workspaceId: String): Invitation = /** 邀请角色:member(成员)或 admin(管理员,仅负责人可发)。 */
call(path = "/workspaces/$workspaceId/invitations", body = "{}", auth = true) 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<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
}.getOrDefault(0)
suspend fun join(workspaceId: String, code: String): Workspace = call( suspend fun join(workspaceId: String, code: String): Workspace = call(
path = "/workspaces/$workspaceId/join", path = "/workspaces/$workspaceId/join",
@@ -356,9 +389,22 @@ class Api(private val tokens: TokenStore) {
auth = true, auth = true,
) )
/**
* 看板数据:手机屏幕装不下四列,这里取「进行中的全部」+「已完成」拼一份,
* 由界面按 status 分组,不为此新增解析结构。
*/
suspend fun boardItems(workspaceId: String): List<Item> {
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 = suspend fun itemDetail(workspaceId: String, itemId: String): ItemDetail =
call(path = "/workspaces/$workspaceId/items/$itemId", auth = true) call(path = "/workspaces/$workspaceId/items/$itemId", auth = true)
suspend fun comments(workspaceId: String, itemId: String): List<Comment> =
call<CommentPage>(path = "/workspaces/$workspaceId/items/$itemId/comments", auth = true).items
suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call( suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call(
path = "/workspaces/$workspaceId/items/$itemId/comments", path = "/workspaces/$workspaceId/items/$itemId/comments",
body = json.encodeToString(CommentRequest(body)), body = json.encodeToString(CommentRequest(body)),
@@ -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,
)
}
}
}
}
}
@@ -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<List<Item>>(emptyList()) }
var moving by remember { mutableStateOf<Item?>(null) }
var message by remember { mutableStateOf<String?>(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
}
@@ -1,187 +1,289 @@
package com.dadao.task.ui package com.dadao.task.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.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.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.dadao.task.data.Api 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 com.dadao.task.data.Workspace
import kotlinx.coroutines.launch 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( private fun greeting(): String = when (LocalTime.now().hour) {
"owner" to "所有者", in 5..10 -> "早上好"
"admin" to "管理员", in 11..13 -> "中午好"
"member" to "成员", in 14..17 -> "下午好"
"guest" to "访客", 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 @Composable
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) { fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
val workspaces = remember { mutableStateListOf<Workspace>() }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var showCreate by remember { mutableStateOf(false) }
var invitation by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var user by remember { mutableStateOf<User?>(null) }
var workspaces by remember { mutableStateOf<List<Workspace>>(emptyList()) }
var creating by remember { mutableStateOf(false) }
var name by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
suspend fun reload() { suspend fun reload() {
loading = true runCatching { api.me() }
error = null .onSuccess {
try { user = it.user
workspaces.clear() workspaces = it.workspaces
workspaces.addAll(api.workspaces()) error = null
} catch (e: ApiException) { }
error = e.message .onFailure { error = it.message ?: "加载失败" }
} finally {
loading = false
}
} }
LaunchedEffect(Unit) { reload() } LaunchedEffect(Unit) { reload() }
Scaffold( LazyColumn(
topBar = { modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 20.dp),
TopAppBar( verticalArrangement = Arrangement.spacedBy(12.dp),
title = { Text("我的工作区") }, ) {
actions = { TextButton(onClick = onLogout) { Text("退出") } }, item {
) Column(Modifier.padding(top = 18.dp)) {
}, Row(verticalAlignment = Alignment.CenterVertically) {
floatingActionButton = { Box(
FloatingActionButton(onClick = { showCreate = true }) { Text("+") } Modifier
}, .background(Primary, RoundedCornerShape(10.dp))
) { padding -> .padding(horizontal = 10.dp, vertical = 6.dp),
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { ) {
error?.let { Text(
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) "达道Task",
Spacer(Modifier.height(8.dp)) style = MaterialTheme.typography.labelLarge,
} color = Color.White,
when { )
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium) }
workspaces.isEmpty() -> Text( Spacer(Modifier.weight(1f))
"还没有工作区。点右下角 + 新建一个,任务、工单、知识都放在里面。", Text(
style = MaterialTheme.typography.bodyMedium, "退出",
) style = MaterialTheme.typography.labelMedium,
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { color = InkMuted,
items(workspaces, key = { it.id }) { ws -> modifier = Modifier
Card( .clickable {
modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) }, scope.launch {
colors = CardDefaults.cardColors(), api.logout()
) { onLogout()
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("邀请") }
} }
} }
} .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) { if (creating) {
var name by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = { showCreate = false }, onDismissRequest = { creating = false },
title = { Text("新建工作区") }, title = { Text("新建工作区", style = MaterialTheme.typography.titleMedium) },
text = { text = {
OutlinedTextField( Column {
value = name, Text(
onValueChange = { name = it.take(40) }, "一个工作区对应一支团队或一个生活领域,成员之间共享任务、工单与知识。",
label = { Text("名称") }, style = MaterialTheme.typography.bodySmall,
singleLine = true, 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 = { confirmButton = {
TextButton(enabled = !busy && name.isNotBlank(), onClick = { TextButton(
scope.launch { enabled = name.isNotBlank() && !busy,
busy = true onClick = {
try { scope.launch {
api.createWorkspace(name.trim()) busy = true
showCreate = false runCatching { api.createWorkspace(name.trim()) }
reload() .onSuccess { ws ->
} catch (e: ApiException) { creating = false
error = e.message onOpenWorkspace(ws)
} finally { }
.onFailure { error = it.message ?: "建不出来" }
busy = false busy = false
} }
} },
}) { Text("创建") } ) { Text("创建") }
}, },
dismissButton = { TextButton(onClick = { showCreate = false }) { Text("取消") } }, dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } },
) containerColor = Paper,
}
invitation?.let { text ->
AlertDialog(
onDismissRequest = { invitation = null },
title = { Text("邀请成员") },
text = { Text(text) },
confirmButton = { TextButton(onClick = { invitation = null }) { Text("知道了") } },
) )
} }
} }
@@ -7,29 +7,31 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.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.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.dadao.task.data.Api import com.dadao.task.data.Api
import com.dadao.task.data.ApiException import com.dadao.task.data.ApiException
@@ -37,248 +39,275 @@ import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val KNOWLEDGE_TABS = listOf( private val knowledgeTabs = listOf(
"待核对" to listOf("draft", "in_review"), "draft" to "待核对",
"已发布" 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 "待审议",
"published" to "已发布", "published" to "已发布",
"archived" to "已归档", "archived" to "已归档",
"all" to "全部",
) )
/** private fun docTypeLabel(type: String): String = when (type) {
* 知识列表。 "howto" -> "怎么做"
* "faq" -> "问答"
* 手机端做知识,只保留两件最有价值的事:**记一条**(通勤路上想到就写) "postmortem" -> "复盘"
* 与**核对后发布**(草稿躺在待核对里,手机上顺手就能审)。 "decision" -> "决策"
* 版本历史、标签管理这类重活留给桌面端,别把手机塞成缩小版 Web。 else -> "笔记"
*/ }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun KnowledgeScreen( fun KnowledgeScreen(api: Api, workspace: Workspace) {
api: Api,
workspace: Workspace,
onBack: () -> Unit,
) {
val docs = remember { mutableStateListOf<KnowledgeDoc>() }
var tab by remember { mutableStateOf(KNOWLEDGE_TABS.first()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
var composing by remember { mutableStateOf(false) }
var draftTitle by remember { mutableStateOf("") }
var draftBody by remember { mutableStateOf("") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var tab by remember { mutableStateOf("draft") }
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var message by remember { mutableStateOf<String?>(null) }
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
var adding by remember { mutableStateOf(false) }
var newTitle by remember { mutableStateOf("") }
var newBody by remember { mutableStateOf("") }
suspend fun reload() { suspend fun reload() {
loading = true loading = true
error = null val statuses = when (tab) {
try { "published" -> listOf("published")
val page = api.knowledge(workspace.id, tab.second) "archived" -> listOf("archived")
docs.clear() "all" -> listOf("draft", "in_review", "published", "archived")
docs.addAll(page.items) else -> listOf("draft", "in_review")
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
} }
runCatching { api.knowledge(workspace.id, statuses) }
.onSuccess {
docs = it.items
message = null
}
.onFailure { message = it.message ?: "知识加载失败" }
loading = false
} }
LaunchedEffect(tab) { reload() } LaunchedEffect(tab) { reload() }
suspend fun act(doc: KnowledgeDoc, action: String) { fun fail(e: Throwable) {
try { val code = (e as? ApiException)?.code ?: 0
val updated = api.knowledgeAction(workspace.id, doc.id, action) message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
open = updated
reload()
} catch (e: ApiException) {
// 7004=权限不够(发布要 owner/admin),7003=状态已变
error = when (e.code) {
7004 -> "发布要管理员或 owner 来点"
7003 -> "这个状态不允许该操作,可能刚被别人改过"
else -> e.message
}
reload()
}
} }
suspend fun save() { LazyColumn(
val title = draftTitle.trim() modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
if (title.isEmpty()) return verticalArrangement = Arrangement.spacedBy(10.dp),
try { ) {
api.createKnowledge(workspace.id, title, draftBody.trim()) item {
draftTitle = "" Column(Modifier.padding(top = 4.dp)) {
draftBody = "" Text("知识", style = MaterialTheme.typography.headlineSmall)
composing = false Text(
tab = KNOWLEDGE_TABS.first() "工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索。",
reload() style = MaterialTheme.typography.bodySmall,
} catch (e: ApiException) { color = InkMuted,
error = e.message modifier = Modifier.padding(top = 4.dp),
} )
} Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Scaffold( knowledgeTabs.forEach { (key, label) ->
topBar = { SurfaceChip(text = label, selected = tab == key, onClick = { tab = key })
TopAppBar(
title = { Text("${workspace.name} · 知识") },
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
actions = {
TextButton(onClick = { composing = !composing }) {
Text(if (composing) "收起" else "记一条")
} }
}, }
) Spacer(Modifier.height(10.dp))
}, GhostButton("记一条", onClick = {
) { padding -> adding = true
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { newTitle = ""
Row( newBody = ""
Modifier.fillMaxWidth().padding(vertical = 8.dp), })
horizontalArrangement = Arrangement.spacedBy(8.dp), }
) { }
KNOWLEDGE_TABS.forEach { entry ->
FilterChip( val notice = message
selected = tab == entry, if (notice != null) {
onClick = { tab = entry }, item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
label = { Text(entry.first) }, }
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,
),
) )
} }
} },
confirmButton = {
if (composing) { TextButton(onClick = {
OutlinedTextField( scope.launch {
value = draftTitle, adding = false
onValueChange = { draftTitle = it }, runCatching { api.createKnowledge(workspace.id, newTitle.trim(), newBody) }
label = { Text("标题:这件事叫什么") }, .onSuccess { reload() }
singleLine = true, .onFailure { fail(it) }
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,
)
}
}
} }
} }) { Text("保存草稿") }
} },
} dismissButton = { TextButton(onClick = { adding = false }) { Text("取消") } },
} containerColor = Paper,
val selected = open
if (selected != null) {
KnowledgeDialog(
doc = selected,
onClose = { open = null },
onAction = { action -> scope.launch { act(selected, action) } },
) )
} }
} }
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 @Composable
private fun KnowledgeDialog( private fun KnowledgeDetailDialog(
api: Api,
workspace: Workspace,
doc: KnowledgeDoc, doc: KnowledgeDoc,
onClose: () -> Unit, 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( AlertDialog(
onDismissRequest = onClose, onDismissRequest = onClose,
title = { Text(doc.title) }, title = {
text = {
Column { Column {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
buildString { SoftPill(docTypeLabel(doc.knowledge.docType))
append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status) Spacer(Modifier.width(6.dp))
doc.knowledge.publishedAt?.let { append(" · 发布于 ").append(it.take(16).replace("T", " ")) } StatusPill(action)
if (doc.knowledge.generatedBy == "rule") append(" · 由系统整理,请核对后再发布") }
},
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(8.dp)) 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( Text(
doc.bodyMd.ifBlank { "(还没有正文)" }, doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
color = InkSoft,
) )
} }
}, },
confirmButton = { confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
when (doc.status) { actions.forEach { (key, label) ->
"draft" -> { TextButton(onClick = {
TextButton(onClick = { onAction("submit_review") }) { Text("提交审议") } scope.launch {
TextButton(onClick = { onAction("publish") }) { Text("发布") } runCatching { api.knowledgeAction(workspace.id, doc.id, key) }
} .onSuccess {
"in_review" -> { action = when (key) {
TextButton(onClick = { onAction("withdraw") }) { Text("撤回") } "submit_review" -> "in_review"
TextButton(onClick = { onAction("publish") }) { Text("发布") } "withdraw", "restore" -> "draft"
} "publish" -> "published"
"published" -> TextButton(onClick = { onAction("archive") }) { Text("归档") } else -> "archived"
"archived" -> TextButton(onClick = { onAction("restore") }) { Text("恢复草稿") } }
onChanged()
}
.onFailure { onFail(it) }
}
}) { Text(label) }
} }
TextButton(onClick = onClose) { Text("关闭") }
} }
}, },
dismissButton = { TextButton(onClick = onClose) { Text("关闭") } },
containerColor = Paper,
) )
} }
@@ -1,20 +1,27 @@
package com.dadao.task.ui package com.dadao.task.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -22,115 +29,203 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api import com.dadao.task.data.Api
import com.dadao.task.data.ApiException import com.dadao.task.data.ApiException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Composable @Composable
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) { fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
val scope = rememberCoroutineScope()
var phone by remember { mutableStateOf("") } var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") } var code by remember { mutableStateOf("") }
var seconds by remember { mutableStateOf(0) }
var devCode by remember { mutableStateOf<String?>(null) } var devCode by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var cooldown by remember { mutableStateOf(0) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
fun sendCode() { LaunchedEffect(seconds) {
if (phone.length < 6 || cooldown > 0) return if (seconds > 0) {
delay(1000)
seconds -= 1
}
}
fun send() {
scope.launch { scope.launch {
busy = true busy = true
error = null runCatching { api.sendSms(phone.trim()) }
try { .onSuccess { data ->
val data = api.sendSms(phone) seconds = 60
// 开发环境后端会回显验证码,联调时省事;生产不会返回。 devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
devCode = data?.toString()?.let { Regex("\"dev_code\"\\s*:\\s*\"(\\d+)\"").find(it)?.groupValues?.get(1) } error = null
cooldown = 60 }
} catch (e: ApiException) { .onFailure {
error = e.message val c = (it as? ApiException)?.code ?: 0
} finally { error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
busy = false }
} busy = false
} }
} }
fun submit() { fun submit() {
if (phone.isBlank() || code.isBlank()) return
scope.launch { scope.launch {
busy = true busy = true
error = null runCatching { api.login(phone.trim(), code.trim()) }
try { .onSuccess {
api.login(phone, code) error = null
onLoggedIn() onLoggedIn()
} catch (e: ApiException) { }
error = e.message .onFailure {
} finally { val c = (it as? ApiException)?.code ?: 0
busy = false error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
} }
busy = false
} }
} }
Column( Column(
modifier = Modifier.fillMaxSize().padding(28.dp), Modifier
verticalArrangement = Arrangement.Center, .fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) { ) {
Text("达道Task", style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(64.dp))
Text( Box(
"从任务到知识,一条道走到底", Modifier
style = MaterialTheme.typography.bodyMedium, .background(Primary, RoundedCornerShape(18.dp))
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), .padding(horizontal = 18.dp, vertical = 16.dp),
) ) {
Spacer(Modifier.height(32.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( OutlinedTextField(
value = phone, value = phone,
onValueChange = { phone = it.filter(Char::isDigit).take(11) }, onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号") }, label = { Text("手机号") },
placeholder = { Text("11 位手机号", color = InkMuted) },
singleLine = true, singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), 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)) Spacer(Modifier.height(12.dp))
OutlinedTextField( Row(verticalAlignment = Alignment.CenterVertically) {
value = code, OutlinedTextField(
onValueChange = { code = it.filter(Char::isDigit).take(6) }, value = code,
label = { Text("验证码") }, onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
singleLine = true, modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), 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(), 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() }, 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))
} }
} }
@@ -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<List<Member>>(emptyList()) }
var invite by remember { mutableStateOf<Invitation?>(null) }
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(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, "分享邀请链接"))
}
@@ -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<ItemDetail?>(null) }
var members by remember { mutableStateOf<List<Member>>(emptyList()) }
var meId by remember { mutableStateOf("") }
var comments by remember { mutableStateOf<List<Comment>>(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<String?>(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<Member>, 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
@@ -1,40 +1,39 @@
package com.dadao.task.ui package com.dadao.task.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api import com.dadao.task.data.Api
@@ -43,238 +42,257 @@ import com.dadao.task.data.Item
import com.dadao.task.data.Workspace import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val VIEWS = listOf( private val views = listOf(
"today" to "今天", "today" to "今天",
"active" to "进行中", "active" to "进行中",
"inbox" to "收件箱", "inbox" to "收件箱",
"done" 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 @Composable
fun TaskListScreen( fun TaskListScreen(api: Api, workspace: Workspace) {
api: Api,
workspace: Workspace,
onBack: () -> Unit,
onOpenTickets: () -> Unit = {},
onOpenKnowledge: () -> Unit = {},
) {
val items = remember { mutableStateListOf<Item>() }
var view by remember { mutableStateOf("today") }
var draft by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var detailId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var view by remember { mutableStateOf("active") }
var mode by remember { mutableStateOf("list") }
var items by remember { mutableStateOf<List<Item>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var draft by remember { mutableStateOf("") }
var query by remember { mutableStateOf("") }
var openId by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
suspend fun reload() { suspend fun reload() {
loading = true loading = true
error = null runCatching { api.items(workspace.id, view = view, query = query.ifBlank { null }) }
try { .onSuccess {
val page = api.items(workspace.id, view) items = it.items
items.clear() error = null
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) },
)
}
} }
.onFailure { error = it.message ?: "刷新失败" }
loading = false
}
OutlinedTextField( LaunchedEffect(view, query) { reload() }
value = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("加一件要做的事,回车创建") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { scope.launch { add() } }),
)
error?.let { fun fail(e: Throwable) {
Spacer(Modifier.height(8.dp)) val code = (e as? ApiException)?.code ?: 0
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) 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 { item {
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium) OutlinedTextField(
items.isEmpty() -> Text( value = query,
if (view == "today") "今天没有到期的事,挺好。" else "这里还空着。", onValueChange = { query = it },
style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth(),
) placeholder = {
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(
items(items, key = { it.id }) { item -> "搜标题或正文…",
Card(Modifier.fillMaxWidth()) { color = InkMuted,
Row( style = MaterialTheme.typography.bodySmall,
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = item.isDone,
onCheckedChange = { scope.launch { toggle(item) } },
) )
Column( },
Modifier.weight(1f).clickable { detailId = item.id }, singleLine = true,
) { shape = RoundedCornerShape(10.dp),
Text( colors = TextFieldDefaults.colors(
item.title, focusedContainerColor = Color.White,
style = MaterialTheme.typography.bodyLarge, unfocusedContainerColor = Color.White,
textDecoration = if (item.isDone) TextDecoration.LineThrough else null, focusedIndicatorColor = Primary,
) unfocusedIndicatorColor = Line,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ),
Text( )
STATUS_LABEL[item.status] ?: item.status, }
style = MaterialTheme.typography.labelSmall,
color = Accent, if (!loading && items.isEmpty()) {
) item {
item.dueAt?.let { EmptyState(
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),
)
}
}
}
}
} }
} }
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 -> openId?.let { id ->
var detail by remember(id) { mutableStateOf<String?>(null) } TaskDetailSheet(
var comment by remember(id) { mutableStateOf("") } api = api,
LaunchedEffect(id) { workspaceId = workspace.id,
detail = try { itemId = id,
val d = api.itemDetail(workspace.id, id) onClose = { openId = null },
buildString { onChanged = { scope.launch { reload() } },
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("关闭") } },
) )
} }
} }
@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) }
}
}
}
}
}
@@ -1,30 +1,95 @@
package com.dadao.task.ui package com.dadao.task.ui
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color 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) * 与 Web 端 tailwind.config.js 同一套色板:暖白纸面 + 墨松绿主色 + 陶土橙强调。
val InkDark = Color(0xFF2C4049) * 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。
val Accent = Color(0xFFC2703D) */
val Paper = Color(0xFFF7F8F8) 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( private val colors = lightColorScheme(
primary = Ink, primary = Primary,
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = Color(0xFFDCE6EA), primaryContainer = PrimarySoft,
onPrimaryContainer = InkDark, onPrimaryContainer = PrimaryDeep,
secondary = Accent, secondary = Clay,
onSecondary = Color.White, onSecondary = Color.White,
secondaryContainer = ClaySoft,
onSecondaryContainer = Color(0xFF7A3F1B),
tertiary = Sage,
onTertiary = Color.White,
background = Paper, background = Paper,
onBackground = Ink,
surface = Color.White, 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 @Composable
fun DadaoTheme(content: @Composable () -> Unit) { fun DadaoTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = colors, typography = Typography(), content = content) MaterialTheme(colorScheme = colors, typography = type, shapes = shapes, content = content)
} }
@@ -10,24 +10,24 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.dadao.task.data.Api import com.dadao.task.data.Api
import com.dadao.task.data.ApiException import com.dadao.task.data.ApiException
@@ -35,165 +35,198 @@ import com.dadao.task.data.Ticket
import com.dadao.task.data.Workspace import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val ROLES = listOf( private val ticketRoles = listOf(
"assigned" to "派给我的", "assigned" to "派给我的",
"requested" 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 @Composable
fun TicketsScreen( fun TicketsScreen(api: Api, workspace: Workspace) {
api: Api,
workspace: Workspace,
onBack: () -> Unit,
) {
val tickets = remember { mutableStateListOf<Ticket>() }
var role by remember { mutableStateOf("assigned") }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var role by remember { mutableStateOf("assigned") }
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var message by remember { mutableStateOf<String?>(null) }
var verifyTarget by remember { mutableStateOf<Ticket?>(null) }
var note by remember { mutableStateOf("") }
var resolution by remember { mutableStateOf("") }
suspend fun reload() { suspend fun reload() {
loading = true loading = true
error = null runCatching { api.tickets(workspace.id, role) }
try { .onSuccess {
val page = api.tickets(workspace.id, role) tickets = it.tickets
tickets.clear() message = null
tickets.addAll(page.tickets) }
} catch (e: ApiException) { .onFailure { message = it.message ?: "工单加载失败" }
error = e.message loading = false
} finally {
loading = false
}
} }
LaunchedEffect(role) { reload() } LaunchedEffect(role) { reload() }
suspend fun act(ticket: Ticket, action: String, approve: Boolean = false) { fun fail(e: Throwable) {
try { val code = (e as? ApiException)?.code ?: 0
val updated = if (action == "verify") { message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
api.verifyTicket(workspace.id, ticket.id, approve) }
} else {
api.ticketAction(workspace.id, ticket.id, action) fun act(ticket: Ticket, action: String) {
} scope.launch {
val index = tickets.indexOfFirst { it.id == updated.id } runCatching { api.ticketAction(workspace.id, ticket.id, action) }
if (index >= 0) tickets[index] = updated .onSuccess {
// 办完就不该再占着"待我处理"的位置 message = null
if (updated.status == "done" && role == "assigned") tickets.removeAll { it.id == updated.id } reload()
} catch (e: ApiException) { }
error = when (e.code) { .onFailure { fail(it) }
6004 -> "这个动作得由需求人来点(自己不能验收自己的工单)"
6003 -> "当前状态不允许这个操作,可能刚被别人推进了一步"
else -> e.message
}
reload()
} }
} }
Scaffold( LazyColumn(
topBar = { modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
TopAppBar( verticalArrangement = Arrangement.spacedBy(10.dp),
title = { Text("${workspace.name} · 工单") }, ) {
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, item {
) Column(Modifier.padding(top = 4.dp)) {
}, Text("工单", style = MaterialTheme.typography.headlineSmall)
) { padding -> Text(
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { "把卡住的事交给能办的人。办好并验收通过,原任务会自动回到待办。",
Row( style = MaterialTheme.typography.bodySmall,
Modifier.fillMaxWidth().padding(vertical = 8.dp), color = InkMuted,
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp),
) { )
ROLES.forEach { (key, label) -> Spacer(Modifier.height(10.dp))
FilterChip( Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
selected = role == key, ticketRoles.forEach { (key, label) ->
onClick = { role = key }, SurfaceChip(text = label, selected = role == key, onClick = { role = key })
label = { Text(label) }, }
)
} }
} }
}
error?.let { val notice = message
Spacer(Modifier.height(8.dp)) if (notice != null) {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
} }
when { if (!loading && tickets.isEmpty()) {
loading -> Text("加载中…") item {
tickets.isEmpty() -> Text( EmptyState(
if (role == "assigned") "没有需要你出手的事。" else "你还没找人帮过忙。", "这里还没有工单",
style = MaterialTheme.typography.bodyMedium, "在任务详情里点「升级成工单」,把要别人配合的事交出去",
) )
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { }
items(tickets, key = { it.id }) { ticket -> }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp)) { items(tickets, key = { it.id }) { ticket ->
Text(ticket.title, style = MaterialTheme.typography.titleMedium) SoftCard(padding = 12.dp) {
Spacer(Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Text( Column(Modifier.weight(1f)) {
buildString { Text(
append(TICKET_STATUS_LABEL[ticket.status] ?: ticket.status) ticket.title,
if (ticket.ticket.rejectCount > 0) { style = MaterialTheme.typography.bodyLarge,
append(" · 已退回 ").append(ticket.ticket.rejectCount).append(" 次") fontWeight = FontWeight.Medium,
} )
}, Spacer(Modifier.height(6.dp))
style = MaterialTheme.typography.bodySmall, Row(
) verticalAlignment = Alignment.CenterVertically,
Row( horizontalArrangement = Arrangement.spacedBy(6.dp),
Modifier.fillMaxWidth().padding(top = 4.dp), ) {
verticalAlignment = Alignment.CenterVertically, StatusPill(ticket.status)
) { PriorityTag(ticket.priority)
when (ticket.status) { if (ticket.ticket.rejectCount > 0) {
"open" -> ActionButton("接收") { SoftPill("被退回 ${ticket.ticket.rejectCount} 次", Clay, ClaySoft)
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("取消")
}
}
}
} }
} }
} }
} }
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) }
}
@@ -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 -> ""
}