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
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -13,15 +15,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.dadao.task.data.Api
import com.dadao.task.data.TokenStore
import com.dadao.task.data.Workspace
import com.dadao.task.ui.BoardScreen
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.MembersScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
import com.dadao.task.ui.WorkspaceScaffold
import com.dadao.task.ui.WorkspaceTab
class MainActivity : ComponentActivity() {
@@ -30,22 +37,37 @@ class MainActivity : ComponentActivity() {
setContent {
DadaoTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
AppRoot()
AppRoot(joinUri = intent?.dataString)
}
}
}
}
}
private enum class Screen { Splash, Login, Home, Tasks, Tickets, Knowledge }
private enum class Screen { Splash, Login, Home, Workspace }
/** 从 https://dadaotask.com/join/<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
private fun AppRoot() {
val context = androidx.compose.ui.platform.LocalContext.current
private fun AppRoot(joinUri: String?) {
val context = LocalContext.current
val tokens = remember { TokenStore(context) }
val api = remember { Api(tokens) }
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
var pendingJoin by remember { mutableStateOf(parseJoin(joinUri)) }
var current by remember { mutableStateOf<Workspace?>(null) }
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
var unread by remember { mutableStateOf(0) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
@@ -60,8 +82,28 @@ private fun AppRoot() {
}
}
// 邀请链接:登录之后自动加入,加入完直接进那个工作区。
LaunchedEffect(screen, pendingJoin) {
val join = pendingJoin ?: return@LaunchedEffect
if (screen != Screen.Home) return@LaunchedEffect
runCatching { api.join(join.first, join.second) }
.onSuccess {
current = it
tab = WorkspaceTab.Tasks
pendingJoin = null
screen = Screen.Workspace
}
.onFailure { pendingJoin = null }
}
// 工单未读数:进工作区就拉一次,切页面时再刷。
LaunchedEffect(current?.id, tab) {
val ws = current ?: return@LaunchedEffect
unread = api.unreadCount(ws.id)
}
when (screen) {
Screen.Splash -> Unit // 校验中,空白过渡即可
Screen.Splash -> Unit
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
Screen.Home -> HomeScreen(
api = api,
@@ -71,37 +113,29 @@ private fun AppRoot() {
},
onOpenWorkspace = { ws ->
current = ws
screen = Screen.Tasks
tab = WorkspaceTab.Tasks
screen = Screen.Workspace
},
)
Screen.Tasks -> {
Screen.Workspace -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
TaskListScreen(
api = api,
WorkspaceScaffold(
workspace = ws,
onBack = { screen = Screen.Home },
onOpenTickets = { screen = Screen.Tickets },
onOpenKnowledge = { screen = Screen.Knowledge },
)
}
}
Screen.Tickets -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
TicketsScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks })
}
}
Screen.Knowledge -> {
val ws = current
if (ws == null) {
screen = Screen.Home
} else {
KnowledgeScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks })
tab = tab,
unread = unread,
onTab = { tab = it },
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
}
}
}
}
}
@@ -111,9 +111,22 @@ data class Activity(
@SerialName("created_at") val createdAt: String = "",
)
@Serializable
data class Comment(
val id: String,
@SerialName("author_id") val authorId: String = "",
@SerialName("body_md") val bodyMd: String = "",
@SerialName("created_at") val createdAt: String = "",
)
@Serializable
data class CommentPage(val items: List<Comment> = emptyList())
/** 详情比列表多一个 kind 与 activities:工单要按 kind 切界面,时间线要用详情里的。 */
@Serializable
data class ItemDetail(
val id: String,
val kind: String = "task",
val title: String,
@SerialName("body_md") val bodyMd: String = "",
val status: String,
@@ -220,6 +233,9 @@ data class KnowledgePage(
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
@Serializable
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
@Serializable
private data class SmsRequest(val phone: String)
@@ -235,6 +251,9 @@ private data class WorkspaceCreateRequest(val name: String)
@Serializable
private data class JoinRequest(val code: String)
@Serializable
private data class InviteRequest(val role: String)
@Serializable
private data class CreateItemRequest(val title: String, val status: String)
@@ -292,6 +311,11 @@ class Api(private val tokens: TokenStore) {
// 与 backend/app/config.py 的 api_prefix 保持一致
private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1"
/** 退出登录只清本地令牌;服务端的 refresh 是无状态的,没有注销接口。 */
fun logout() {
tokens.clear()
}
suspend fun sendSms(phone: String): JsonElement? = call(
path = "/auth/sms/send",
body = json.encodeToString(SmsRequest(phone)),
@@ -325,8 +349,17 @@ class Api(private val tokens: TokenStore) {
suspend fun members(workspaceId: String): List<Member> =
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
suspend fun createInvitation(workspaceId: String): Invitation =
call(path = "/workspaces/$workspaceId/invitations", body = "{}", auth = true)
/** 邀请角色:member(成员)或 admin(管理员,仅负责人可发)。 */
suspend fun createInvitation(workspaceId: String, role: String = "member"): Invitation = call(
path = "/workspaces/$workspaceId/invitations",
body = json.encodeToString(InviteRequest(role)),
auth = true,
)
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
suspend fun unreadCount(workspaceId: String): Int = runCatching {
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
}.getOrDefault(0)
suspend fun join(workspaceId: String, code: String): Workspace = call(
path = "/workspaces/$workspaceId/join",
@@ -356,9 +389,22 @@ class Api(private val tokens: TokenStore) {
auth = true,
)
/**
* 看板数据:手机屏幕装不下四列,这里取「进行中的全部」+「已完成」拼一份,
* 由界面按 status 分组,不为此新增解析结构。
*/
suspend fun boardItems(workspaceId: String): List<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 =
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(
path = "/workspaces/$workspaceId/items/$itemId/comments",
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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.User
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
private val ROLE_LABEL = mapOf(
"owner" to "所有者",
"admin" to "管理员",
"member" to "成员",
"guest" to "访客",
)
private fun greeting(): String = when (LocalTime.now().hour) {
in 5..10 -> "早上好"
in 11..13 -> "中午好"
in 14..17 -> "下午好"
in 18..22 -> "晚上好"
else -> "夜深了"
}
private fun today(): String =
LocalDate.now().format(DateTimeFormatter.ofPattern("M月d日 EEEE", Locale.SIMPLIFIED_CHINESE))
private fun roleLabel(role: String?): String = when (role) {
"owner" -> "负责人"
"admin" -> "管理员"
"member" -> "成员"
else -> "访客"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
val workspaces = remember { mutableStateListOf<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()
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() {
loading = true
runCatching { api.me() }
.onSuccess {
user = it.user
workspaces = it.workspaces
error = null
try {
workspaces.clear()
workspaces.addAll(api.workspaces())
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
}
.onFailure { error = it.message ?: "加载失败" }
}
LaunchedEffect(Unit) { reload() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的工作区") },
actions = { TextButton(onClick = onLogout) { Text("退出") } },
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreate = true }) { Text("+") }
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
}
when {
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
workspaces.isEmpty() -> Text(
"还没有工作区。点右下角 + 新建一个,任务、工单、知识都放在里面。",
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(workspaces, key = { it.id }) { ws ->
Card(
modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) },
colors = CardDefaults.cardColors(),
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
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),
)
}
item {
Column(Modifier.padding(top = 18.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.background(Primary, RoundedCornerShape(10.dp))
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
style = MaterialTheme.typography.labelMedium,
color = Accent,
"达道Task",
style = MaterialTheme.typography.labelLarge,
color = Color.White,
)
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("邀请") }
}
}
}
}
}
}
Spacer(Modifier.height(8.dp))
Spacer(Modifier.weight(1f))
Text(
"点工作区可生成邀请码(M0 临时入口,M1 换成工作区详情页)",
"退出",
style = MaterialTheme.typography.labelMedium,
color = InkMuted,
modifier = Modifier
.clickable {
scope.launch {
api.logout()
onLogout()
}
}
.padding(6.dp),
)
}
Spacer(Modifier.height(22.dp))
Text(
today(),
style = MaterialTheme.typography.labelMedium,
color = Clay,
)
Spacer(Modifier.height(6.dp))
Text(
"${greeting()},${user?.nickname?.ifBlank { "回来就好" } ?: "回来就好"}",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(6.dp))
Text(
"选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f),
color = InkMuted,
)
}
}
if (showCreate) {
var name by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
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,
)
}
}
}
}
val notice = error
if (notice != null) {
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
}
item { Spacer(Modifier.height(40.dp)) }
}
if (creating) {
AlertDialog(
onDismissRequest = { showCreate = false },
title = { Text("新建工作区") },
onDismissRequest = { creating = false },
title = { Text("新建工作区", style = MaterialTheme.typography.titleMedium) },
text = {
Column {
Text(
"一个工作区对应一支团队或一个生活领域,成员之间共享任务、工单与知识。",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it.take(40) },
label = { Text("名称") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("例如:达道科技 / 我的事项", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
}
},
confirmButton = {
TextButton(enabled = !busy && name.isNotBlank(), onClick = {
TextButton(
enabled = name.isNotBlank() && !busy,
onClick = {
scope.launch {
busy = true
try {
api.createWorkspace(name.trim())
showCreate = false
reload()
} catch (e: ApiException) {
error = e.message
} finally {
runCatching { api.createWorkspace(name.trim()) }
.onSuccess { ws ->
creating = false
onOpenWorkspace(ws)
}
.onFailure { error = it.message ?: "建不出来" }
busy = false
}
}
}) { Text("创建") }
},
dismissButton = { TextButton(onClick = { showCreate = false }) { Text("取消") } },
)
}
invitation?.let { text ->
AlertDialog(
onDismissRequest = { invitation = null },
title = { Text("邀请成员") },
text = { Text(text) },
confirmButton = { TextButton(onClick = { invitation = null }) { Text("知道了") } },
) { Text("创建") }
},
dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } },
containerColor = Paper,
)
}
}
@@ -7,29 +7,31 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
@@ -37,248 +39,275 @@ import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
private val KNOWLEDGE_TABS = listOf(
"待核对" to listOf("draft", "in_review"),
"已发布" to listOf("published"),
"已归档" to listOf("archived"),
"全部" to listOf("draft", "in_review", "published", "archived"),
)
private val DOC_TYPE_LABEL = mapOf(
"note" to "手记",
"howto" to "做法",
"faq" to "问答",
"postmortem" to "复盘",
"decision" to "决策",
)
private val KNOWLEDGE_STATUS_LABEL = mapOf(
"draft" to "草稿",
"in_review" to "待审议",
private val knowledgeTabs = listOf(
"draft" to "待核对",
"published" to "已发布",
"archived" to "已归档",
"all" to "全部",
)
/**
* 知识列表。
*
* 手机端做知识,只保留两件最有价值的事:**记一条**(通勤路上想到就写)
* 与**核对后发布**(草稿躺在待核对里,手机上顺手就能审)。
* 版本历史、标签管理这类重活留给桌面端,别把手机塞成缩小版 Web。
*/
@OptIn(ExperimentalMaterial3Api::class)
private fun docTypeLabel(type: String): String = when (type) {
"howto" -> "怎么做"
"faq" -> "问答"
"postmortem" -> "复盘"
"decision" -> "决策"
else -> "笔记"
}
@Composable
fun KnowledgeScreen(
api: Api,
workspace: Workspace,
onBack: () -> Unit,
) {
val docs = remember { mutableStateListOf<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("") }
fun KnowledgeScreen(api: Api, workspace: Workspace) {
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() {
loading = true
error = null
try {
val page = api.knowledge(workspace.id, tab.second)
docs.clear()
docs.addAll(page.items)
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
val statuses = when (tab) {
"published" -> listOf("published")
"archived" -> listOf("archived")
"all" -> listOf("draft", "in_review", "published", "archived")
else -> listOf("draft", "in_review")
}
runCatching { api.knowledge(workspace.id, statuses) }
.onSuccess {
docs = it.items
message = null
}
.onFailure { message = it.message ?: "知识加载失败" }
loading = false
}
LaunchedEffect(tab) { reload() }
suspend fun act(doc: KnowledgeDoc, action: String) {
try {
val updated = api.knowledgeAction(workspace.id, doc.id, action)
open = updated
reload()
} catch (e: ApiException) {
// 7004=权限不够(发布要 owner/admin),7003=状态已变
error = when (e.code) {
7004 -> "发布要管理员或 owner 来点"
7003 -> "这个状态不允许该操作,可能刚被别人改过"
else -> e.message
}
reload()
}
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
}
suspend fun save() {
val title = draftTitle.trim()
if (title.isEmpty()) return
try {
api.createKnowledge(workspace.id, title, draftBody.trim())
draftTitle = ""
draftBody = ""
composing = false
tab = KNOWLEDGE_TABS.first()
reload()
} catch (e: ApiException) {
error = e.message
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("${workspace.name} · 知识") },
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
actions = {
TextButton(onClick = { composing = !composing }) {
Text(if (composing) "收起" else "记一条")
}
},
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
KNOWLEDGE_TABS.forEach { entry ->
FilterChip(
selected = tab == entry,
onClick = { tab = entry },
label = { Text(entry.first) },
)
}
}
if (composing) {
OutlinedTextField(
value = draftTitle,
onValueChange = { draftTitle = it },
label = { Text("标题:这件事叫什么") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = draftBody,
onValueChange = { draftBody = it },
label = { Text("正文:以后还会用到的那部分") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Button(onClick = { scope.launch { save() } }, enabled = draftTitle.isNotBlank()) {
Text("存成草稿")
}
Spacer(Modifier.height(8.dp))
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
}
when {
loading -> Text("加载中…")
docs.isEmpty() -> Text(
if (tab.first == "待核对") {
"没有待核对的知识。工单验收通过会自动生成一份草稿,等一句话就能发。"
} else {
"这里还没有内容。"
},
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(docs, key = { it.id }) { doc ->
Card(
onClick = { open = doc },
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp),
) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text(doc.title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
item {
Column(Modifier.padding(top = 4.dp)) {
Text("知识", style = MaterialTheme.typography.headlineSmall)
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,
color = InkMuted,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
knowledgeTabs.forEach { (key, label) ->
SurfaceChip(text = label, selected = tab == key, onClick = { tab = key })
}
}
}
}
}
Spacer(Modifier.height(10.dp))
GhostButton("记一条", onClick = {
adding = true
newTitle = ""
newBody = ""
})
}
}
val selected = open
if (selected != null) {
KnowledgeDialog(
doc = selected,
val notice = message
if (notice != null) {
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
}
if (!loading && docs.isEmpty()) {
item {
EmptyState(
"还没有待核对的知识",
"把工单验收通过,或在任务详情里点「沉淀成知识」",
)
}
}
items(docs, key = { it.id }) { doc ->
SoftCard(padding = 12.dp, onClick = { open = doc }) {
Row(verticalAlignment = Alignment.CenterVertically) {
SoftPill(docTypeLabel(doc.knowledge.docType))
Spacer(Modifier.width(6.dp))
StatusPill(doc.status)
if (doc.knowledge.generatedBy == "rule") {
Spacer(Modifier.width(6.dp))
SoftPill("自动整理", Clay, ClaySoft)
}
}
Spacer(Modifier.height(8.dp))
Text(
doc.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(6.dp))
Text(
sourceLine(doc),
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
}
}
item { Spacer(Modifier.height(20.dp)) }
}
val target = open
if (target != null) {
KnowledgeDetailDialog(
api = api,
workspace = workspace,
doc = target,
onClose = { open = null },
onAction = { action -> scope.launch { act(selected, action) } },
onChanged = {
scope.launch { reload() }
},
onFail = { fail(it) },
)
}
}
@Composable
private fun KnowledgeDialog(
doc: KnowledgeDoc,
onClose: () -> Unit,
onAction: (String) -> Unit,
) {
if (adding) {
AlertDialog(
onDismissRequest = onClose,
title = { Text(doc.title) },
onDismissRequest = { adding = false },
title = { Text("记一条", style = MaterialTheme.typography.titleMedium) },
text = {
Column {
Text(
buildString {
append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status)
doc.knowledge.publishedAt?.let { append(" · 发布于 ").append(it.take(16).replace("T", " ")) }
if (doc.knowledge.generatedBy == "rule") append(" · 由系统整理,请核对后再发布")
},
"手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。",
style = MaterialTheme.typography.bodySmall,
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))
Text(
doc.bodyMd.ifBlank { "(还没有正文)" },
style = MaterialTheme.typography.bodyMedium,
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 = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
when (doc.status) {
"draft" -> {
TextButton(onClick = { onAction("submit_review") }) { Text("提交审议") }
TextButton(onClick = { onAction("publish") }) { Text("发布") }
}
"in_review" -> {
TextButton(onClick = { onAction("withdraw") }) { Text("撤回") }
TextButton(onClick = { onAction("publish") }) { Text("发布") }
}
"published" -> TextButton(onClick = { onAction("archive") }) { Text("归档") }
"archived" -> TextButton(onClick = { onAction("restore") }) { Text("恢复草稿") }
}
TextButton(onClick = onClose) { Text("关闭") }
TextButton(onClick = {
scope.launch {
adding = false
runCatching { api.createKnowledge(workspace.id, newTitle.trim(), newBody) }
.onSuccess { reload() }
.onFailure { fail(it) }
}
}) { Text("保存草稿") }
},
dismissButton = { TextButton(onClick = { adding = false }) { Text("取消") } },
containerColor = Paper,
)
}
}
private fun sourceLine(doc: KnowledgeDoc): String {
val src = doc.source
return if (src != null && src.title.isNotBlank()) {
"来自${if (src.kind == "ticket") "工单" else "任务"}「${src.title}」"
} else {
"手记"
}
}
@Composable
private fun KnowledgeDetailDialog(
api: Api,
workspace: Workspace,
doc: KnowledgeDoc,
onClose: () -> Unit,
onChanged: () -> Unit,
onFail: (Throwable) -> Unit,
) {
val scope = rememberCoroutineScope()
var action by remember { mutableStateOf(doc.status) }
val actions = when (action) {
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
"published" -> listOf("archive" to "归档")
"archived" -> listOf("restore" to "恢复成草稿")
else -> emptyList()
}
AlertDialog(
onDismissRequest = onClose,
title = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
SoftPill(docTypeLabel(doc.knowledge.docType))
Spacer(Modifier.width(6.dp))
StatusPill(action)
}
Spacer(Modifier.height(8.dp))
Text(doc.title, style = MaterialTheme.typography.titleMedium)
}
},
text = {
Column(
Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
) {
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
Spacer(Modifier.height(10.dp))
Text(
doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" },
style = MaterialTheme.typography.bodySmall,
color = InkSoft,
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
actions.forEach { (key, label) ->
TextButton(onClick = {
scope.launch {
runCatching { api.knowledgeAction(workspace.id, doc.id, key) }
.onSuccess {
action = when (key) {
"submit_review" -> "in_review"
"withdraw", "restore" -> "draft"
"publish" -> "published"
else -> "archived"
}
onChanged()
}
.onFailure { onFail(it) }
}
}) { Text(label) }
}
}
},
dismissButton = { TextButton(onClick = onClose) { Text("关闭") } },
containerColor = Paper,
)
}
@@ -1,20 +1,27 @@
package com.dadao.task.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -22,115 +29,203 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Composable
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
val scope = rememberCoroutineScope()
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var seconds by remember { mutableStateOf(0) }
var devCode by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var cooldown by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
var error by remember { mutableStateOf<String?>(null) }
fun sendCode() {
if (phone.length < 6 || cooldown > 0) return
LaunchedEffect(seconds) {
if (seconds > 0) {
delay(1000)
seconds -= 1
}
}
fun send() {
scope.launch {
busy = true
runCatching { api.sendSms(phone.trim()) }
.onSuccess { data ->
seconds = 60
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
error = null
try {
val data = api.sendSms(phone)
// 开发环境后端会回显验证码,联调时省事;生产不会返回。
devCode = data?.toString()?.let { Regex("\"dev_code\"\\s*:\\s*\"(\\d+)\"").find(it)?.groupValues?.get(1) }
cooldown = 60
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
.onFailure {
val c = (it as? ApiException)?.code ?: 0
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
}
busy = false
}
}
fun submit() {
if (phone.isBlank() || code.isBlank()) return
scope.launch {
busy = true
runCatching { api.login(phone.trim(), code.trim()) }
.onSuccess {
error = null
try {
api.login(phone, code)
onLoggedIn()
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
.onFailure {
val c = (it as? ApiException)?.code ?: 0
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
}
busy = false
}
}
Column(
modifier = Modifier.fillMaxSize().padding(28.dp),
verticalArrangement = Arrangement.Center,
Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
Text("达道Task", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(64.dp))
Box(
Modifier
.background(Primary, RoundedCornerShape(18.dp))
.padding(horizontal = 18.dp, vertical = 16.dp),
) {
Column {
Text(
"从任务到知识,一条道走到底",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
"达道Task",
style = MaterialTheme.typography.titleLarge,
color = Color.White,
)
Spacer(Modifier.height(32.dp))
Spacer(Modifier.height(4.dp))
Text(
"任务 · 工单 · 知识",
style = MaterialTheme.typography.labelMedium,
color = PrimaryTint,
)
}
}
Spacer(Modifier.height(26.dp))
Text("登录", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(6.dp))
Text(
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
Spacer(Modifier.height(22.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter(Char::isDigit).take(11) },
label = { Text("手机号") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号") },
placeholder = { Text("11 位手机号", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
modifier = Modifier.weight(1f),
label = { Text("验证码") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.width(10.dp))
GhostButton(
text = if (seconds > 0) "${seconds}s" else "获取验证码",
enabled = phone.length == 11 && seconds == 0 && !busy,
onClick = { send() },
)
}
val hint = devCode
if (hint != null) {
Spacer(Modifier.height(10.dp))
SoftPill(
"开发环境验证码:$hint",
Primary,
PrimarySoft,
Modifier.fillMaxWidth(),
)
}
val notice = error
if (notice != null) {
Spacer(Modifier.height(10.dp))
SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth())
}
Spacer(Modifier.height(22.dp))
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = phone.length == 11 && code.length >= 4 && !busy,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
TextButton(enabled = !busy && cooldown == 0, onClick = { sendCode() }) {
Text(if (cooldown > 0) "${cooldown}s" else "获取验证码")
}
},
)
devCode?.let {
Spacer(Modifier.height(8.dp))
Text("开发环境验证码:$it", style = MaterialTheme.typography.bodySmall, color = Accent)
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { submit() },
enabled = !busy && phone.length == 11 && code.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
if (busy) CircularProgressIndicator(Modifier.height(20.dp), strokeWidth = 2.dp)
else Text("登录 / 注册")
}
Spacer(Modifier.height(12.dp))
)
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(
"未注册的手机号将自动创建账号",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.CenterHorizontally),
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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
@@ -43,238 +42,257 @@ import com.dadao.task.data.Item
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
private val VIEWS = listOf(
private val views = listOf(
"today" to "今天",
"active" to "进行中",
"inbox" to "收件箱",
"done" to "已完成",
"all" to "全部",
)
private val STATUS_LABEL = mapOf(
"inbox" to "收件箱",
"todo" to "待办",
"doing" to "进行中",
"done" to "已完成",
"blocked" to "被阻塞",
"canceled" to "已取消",
)
/**
* 任务列表。
*
* 勾选 = 完成/重开,都带 `version` 回传:手机端最常见的是"在地铁上点了一下,
* 旁边同事也在改",没有乐观锁就会静默覆盖对方。
* 409 时把服务端的最新内容拉回来,而不是让界面停在错误的勾选状态上。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListScreen(
api: Api,
workspace: Workspace,
onBack: () -> Unit,
onOpenTickets: () -> Unit = {},
onOpenKnowledge: () -> Unit = {},
) {
val items = remember { mutableStateListOf<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) }
fun TaskListScreen(api: Api, workspace: Workspace) {
val scope = rememberCoroutineScope()
var view by remember { mutableStateOf("active") }
var mode by remember { mutableStateOf("list") }
var items by remember { mutableStateOf<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() {
loading = true
runCatching { api.items(workspace.id, view = view, query = query.ifBlank { null }) }
.onSuccess {
items = it.items
error = null
try {
val page = api.items(workspace.id, view)
items.clear()
items.addAll(page.items)
} catch (e: ApiException) {
error = e.message
} finally {
}
.onFailure { error = it.message ?: "刷新失败" }
loading = false
}
LaunchedEffect(view, query) { reload() }
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
error = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
}
LaunchedEffect(view) { reload() }
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))
}
}
suspend fun toggle(item: Item) {
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 })
}
}
}
item {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
"搜标题或正文…",
color = InkMuted,
style = MaterialTheme.typography.bodySmall,
)
},
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
}
if (!loading && items.isEmpty()) {
item {
EmptyState(
"这里还空着",
"加一件要做的事,或者换个视图看看——工单办完会自己回到待办",
)
}
}
items(items, key = { it.id }) { item ->
TaskRow(
item = item,
onOpen = { openId = item.id },
onToggle = {
scope.launch {
val target = if (item.status == "done") "todo" else "done"
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
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) }
}
}
}
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("知识") }
},
openId?.let { id ->
TaskDetailSheet(
api = api,
workspaceId = workspace.id,
itemId = id,
onClose = { openId = null },
onChanged = { scope.launch { reload() } },
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
}
}
@Composable
private fun ModeSwitch(mode: String, onMode: (String) -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
Modifier
.background(PaperSunken, RoundedCornerShape(9.dp))
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
VIEWS.forEach { (key, label) ->
FilterChip(
selected = view == key,
onClick = { view = key },
label = { Text(label) },
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 = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("加一件要做的事,回车创建") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { scope.launch { add() } }),
value = value,
onValueChange = onValue,
modifier = Modifier.weight(1f),
placeholder = {
Text(
"加一件要做的事,支持 #标签 !高",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
},
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()) })
}
}
}
Spacer(Modifier.height(8.dp))
when {
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
items.isEmpty() -> Text(
if (view == "today") "今天没有到期的事,挺好。" else "这里还空着。",
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items, key = { it.id }) { item ->
Card(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@Composable
private fun TaskRow(item: Item, onOpen: () -> Unit, onToggle: () -> Unit) {
SoftCard(padding = 12.dp) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.isDone,
onCheckedChange = { scope.launch { toggle(item) } },
onCheckedChange = { onToggle() },
colors = CheckboxDefaults.colors(
checkedColor = Sage,
uncheckedColor = LineStrong,
checkmarkColor = Color.White,
),
)
Column(
Modifier.weight(1f).clickable { detailId = item.id },
) {
Spacer(Modifier.width(6.dp))
Column(Modifier.weight(1f).clickable { onOpen() }) {
Text(
item.title,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (item.isDone) TextDecoration.LineThrough else null,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
STATUS_LABEL[item.status] ?: item.status,
style = MaterialTheme.typography.labelSmall,
color = Accent,
)
item.dueAt?.let {
Text(
it.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
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))
}
if (item.tags.isNotEmpty()) {
Text(
item.tags.joinToString(" ") { "#${it.name}" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
item.tags.take(2).forEach { SoftPill("#" + it.name, Primary, PrimarySoft) }
}
}
}
}
}
}
}
}
}
}
detailId?.let { id ->
var detail by remember(id) { mutableStateOf<String?>(null) }
var comment by remember(id) { mutableStateOf("") }
LaunchedEffect(id) {
detail = try {
val d = api.itemDetail(workspace.id, id)
buildString {
appendLine(d.title)
appendLine()
appendLine("状态:${STATUS_LABEL[d.status] ?: d.status} 优先级:${d.priority}")
d.dueAt?.let { appendLine("截止:${it.take(16).replace('T', ' ')}") }
if (d.tags.isNotEmpty()) appendLine("标签:${d.tags.joinToString(" ") { "#${it.name}" }}")
if (d.bodyMd.isNotBlank()) {
appendLine()
append(d.bodyMd)
}
}
} catch (e: ApiException) {
e.message
}
}
AlertDialog(
onDismissRequest = { detailId = null },
title = { Text("任务详情") },
text = {
Column {
Text(detail ?: "加载中…", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text("补充一句进展") },
singleLine = true,
)
}
},
confirmButton = {
TextButton(
enabled = comment.isNotBlank(),
onClick = {
scope.launch {
try {
api.addComment(workspace.id, id, comment.trim())
comment = ""
detailId = null
} catch (e: ApiException) {
error = e.message
}
}
},
) { Text("发表") }
},
dismissButton = { TextButton(onClick = { detailId = null }) { Text("关闭") } },
)
}
}
@@ -1,30 +1,95 @@
package com.dadao.task.ui
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// 与 Web 端 tailwind.config.js 的色板保持一致:墨青主色 + 陶土色点缀。
val Ink = Color(0xFF3F5A68)
val InkDark = Color(0xFF2C4049)
val Accent = Color(0xFFC2703D)
val Paper = Color(0xFFF7F8F8)
/**
* 与 Web 端 tailwind.config.js 同一套色板:暖白纸面 + 墨松绿主色 + 陶土橙强调。
* 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。
*/
val Paper = Color(0xFFFBF9F6)
val PaperSunken = Color(0xFFF3F0EA)
val Line = Color(0xFFE4E0D8)
val LineStrong = Color(0xFFD3CEC2)
val Ink = Color(0xFF1F2A28)
val InkSoft = Color(0xFF4A5854)
val InkMuted = Color(0xFF7C8985)
val Primary = Color(0xFF2A5A50)
val PrimaryDeep = Color(0xFF1E443C)
val PrimarySoft = Color(0xFFE3EDE8)
val PrimaryTint = Color(0xFFD2E3DB)
val Clay = Color(0xFFC2703D)
val ClaySoft = Color(0xFFFBEADF)
val Sage = Color(0xFF2F7D5E)
val SageSoft = Color(0xFFE2F0E9)
val Wheat = Color(0xFFA97C25)
val WheatSoft = Color(0xFFF8EED7)
val Rust = Color(0xFFB04A32)
val RustSoft = Color(0xFFFBE5DF)
val Sky = Color(0xFF3E6F91)
val SkySoft = Color(0xFFE4EEF5)
private val colors = lightColorScheme(
primary = Ink,
primary = Primary,
onPrimary = Color.White,
primaryContainer = Color(0xFFDCE6EA),
onPrimaryContainer = InkDark,
secondary = Accent,
primaryContainer = PrimarySoft,
onPrimaryContainer = PrimaryDeep,
secondary = Clay,
onSecondary = Color.White,
secondaryContainer = ClaySoft,
onSecondaryContainer = Color(0xFF7A3F1B),
tertiary = Sage,
onTertiary = Color.White,
background = Paper,
onBackground = Ink,
surface = Color.White,
onSurface = Color(0xFF1B2528),
onSurface = Ink,
surfaceVariant = PaperSunken,
onSurfaceVariant = InkSoft,
outline = LineStrong,
outlineVariant = Line,
error = Rust,
onError = Color.White,
errorContainer = RustSoft,
onErrorContainer = Color(0xFF7A2C1B),
)
private val type = Typography(
displaySmall = TextStyle(fontSize = 26.sp, lineHeight = 34.sp, fontWeight = FontWeight.Bold),
headlineSmall = TextStyle(fontSize = 22.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold),
titleLarge = TextStyle(fontSize = 18.sp, lineHeight = 26.sp, fontWeight = FontWeight.SemiBold),
titleMedium = TextStyle(fontSize = 15.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold),
titleSmall = TextStyle(fontSize = 13.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium),
bodyLarge = TextStyle(fontSize = 15.sp, lineHeight = 23.sp),
bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 22.sp),
bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 19.sp),
labelLarge = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium),
labelMedium = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium),
labelSmall = TextStyle(fontSize = 11.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium),
)
private val shapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(10.dp),
medium = RoundedCornerShape(14.dp),
large = RoundedCornerShape(18.dp),
extraLarge = RoundedCornerShape(24.dp),
)
@Composable
fun DadaoTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = colors, typography = Typography(), content = content)
MaterialTheme(colorScheme = colors, typography = type, shapes = shapes, content = content)
}
@@ -10,24 +10,24 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
@@ -35,165 +35,198 @@ import com.dadao.task.data.Ticket
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
private val ROLES = listOf(
private val ticketRoles = listOf(
"assigned" to "派给我的",
"requested" to "我发起的",
"all" to "全部",
)
private val TICKET_STATUS_LABEL = mapOf(
"open" to "待接收",
"accepted" to "待办理",
"in_progress" to "办理中",
"pending_verify" to "待验收",
"done" to "已完成",
"canceled" to "已取消",
)
/**
* 工单列表(移动端只做"该我办的"与"我发起的"两栏)。
*
* 为什么手机端也必须有:需要别人帮忙时,人往往不在电脑前。
* 只做查看不做动作的工单页等于把流程卡在桌面端,等于没有协同。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TicketsScreen(
api: Api,
workspace: Workspace,
onBack: () -> Unit,
) {
val tickets = remember { mutableStateListOf<Ticket>() }
var role by remember { mutableStateOf("assigned") }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
fun TicketsScreen(api: Api, workspace: Workspace) {
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() {
loading = true
error = null
try {
val page = api.tickets(workspace.id, role)
tickets.clear()
tickets.addAll(page.tickets)
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
runCatching { api.tickets(workspace.id, role) }
.onSuccess {
tickets = it.tickets
message = null
}
.onFailure { message = it.message ?: "工单加载失败" }
loading = false
}
LaunchedEffect(role) { reload() }
suspend fun act(ticket: Ticket, action: String, approve: Boolean = false) {
try {
val updated = if (action == "verify") {
api.verifyTicket(workspace.id, ticket.id, approve)
} else {
api.ticketAction(workspace.id, ticket.id, action)
}
val index = tickets.indexOfFirst { it.id == updated.id }
if (index >= 0) tickets[index] = updated
// 办完就不该再占着"待我处理"的位置
if (updated.status == "done" && role == "assigned") tickets.removeAll { it.id == updated.id }
} catch (e: ApiException) {
error = when (e.code) {
6004 -> "这个动作得由需求人来点(自己不能验收自己的工单)"
6003 -> "当前状态不允许这个操作,可能刚被别人推进了一步"
else -> e.message
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
}
fun act(ticket: Ticket, action: String) {
scope.launch {
runCatching { api.ticketAction(workspace.id, ticket.id, action) }
.onSuccess {
message = null
reload()
}
.onFailure { fail(it) }
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("${workspace.name} · 工单") },
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
ROLES.forEach { (key, label) ->
FilterChip(
selected = role == key,
onClick = { role = key },
label = { Text(label) },
)
}
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
when {
loading -> Text("加载中…")
tickets.isEmpty() -> Text(
if (role == "assigned") "没有需要你出手的事。" else "你还没找人帮过忙。",
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(tickets, key = { it.id }) { ticket ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text(ticket.title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
item {
Column(Modifier.padding(top = 4.dp)) {
Text("工单", style = MaterialTheme.typography.headlineSmall)
Text(
buildString {
append(TICKET_STATUS_LABEL[ticket.status] ?: ticket.status)
if (ticket.ticket.rejectCount > 0) {
append(" · 已退回 ").append(ticket.ticket.rejectCount).append(" 次")
}
},
"把卡住的事交给能办的人。办好并验收通过,原任务会自动回到待办。",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ticketRoles.forEach { (key, label) ->
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
}
}
}
}
val notice = message
if (notice != null) {
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
}
if (!loading && tickets.isEmpty()) {
item {
EmptyState(
"这里还没有工单",
"在任务详情里点「升级成工单」,把要别人配合的事交出去",
)
}
}
items(tickets, key = { it.id }) { ticket ->
SoftCard(padding = 12.dp) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
ticket.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(6.dp))
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
StatusPill(ticket.status)
PriorityTag(ticket.priority)
if (ticket.ticket.rejectCount > 0) {
SoftPill("被退回 ${ticket.ticket.rejectCount} 次", Clay, ClaySoft)
}
}
}
}
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
when (ticket.status) {
"open" -> ActionButton("接收") {
scope.launch { act(ticket, "accept") }
"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()
}
"accepted" -> ActionButton("开始") {
scope.launch { act(ticket, "start") }
}
"in_progress" -> ActionButton("提交验收") {
scope.launch { act(ticket, "submit") }
.onFailure { fail(it) }
}
})
"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("取消")
}
}
}
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) }
}
})
}
}
}
}
}
@Composable
private fun ActionButton(label: String, onClick: () -> Unit) {
TextButton(onClick = onClick) { Text(label) }
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,
)
}
}
@@ -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 -> ""
}