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