feat(web,android): 看板拖拽、详情侧栏编辑、快速录入与安卓任务列表
Web: - 看板四列可拖拽,非法流转当场说明原因;勾选后可批量移动 - 详情侧栏全部可编辑(标题/优先级/截止/标签/备注/评论),409 自动刷新 - 快速录入 #标签 !优先级;键盘流 j/k、Enter、e、Esc - Playwright 真浏览器 e2e 7 项断言全绿(web/e2e/smoke.mjs) - 1000 条任务实测首屏 P50 38-55ms Android(未编译验证:本机无 JDK/Gradle): - 任务列表/详情/勾选完成/快速新建,冲突时提示并刷新 - 修:User/Member 字段名与后端 nickmame/phone 对齐,原先必然反序列化失败
This commit is contained in:
@@ -15,9 +15,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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.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.LoginScreen
|
import com.dadao.task.ui.LoginScreen
|
||||||
|
import com.dadao.task.ui.TaskListScreen
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class Screen { Splash, Login, Home }
|
private enum class Screen { Splash, Login, Home, Tasks }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppRoot() {
|
private fun AppRoot() {
|
||||||
@@ -41,6 +43,7 @@ private fun AppRoot() {
|
|||||||
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 current by remember { mutableStateOf<Workspace?>(null) }
|
||||||
|
|
||||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
@@ -64,6 +67,18 @@ private fun AppRoot() {
|
|||||||
tokens.clear()
|
tokens.clear()
|
||||||
screen = Screen.Login
|
screen = Screen.Login
|
||||||
},
|
},
|
||||||
|
onOpenWorkspace = { ws ->
|
||||||
|
current = ws
|
||||||
|
screen = Screen.Tasks
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
Screen.Tasks -> {
|
||||||
|
val ws = current
|
||||||
|
if (ws == null) {
|
||||||
|
screen = Screen.Home
|
||||||
|
} else {
|
||||||
|
TaskListScreen(api = api, workspace = ws, onBack = { screen = Screen.Home })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ private data class Envelope<T>(
|
|||||||
// ---- 与 backend/app/api/schemas.py 一一对应 ----
|
// ---- 与 backend/app/api/schemas.py 一一对应 ----
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class User(val id: String, val phone: String, @SerialName("display_name") val displayName: String)
|
data class User(
|
||||||
|
val id: String,
|
||||||
|
val phone: String,
|
||||||
|
val nickname: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Tokens(
|
data class Tokens(
|
||||||
@@ -61,10 +65,62 @@ data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Member(
|
data class Member(
|
||||||
val userId: String,
|
val id: String = "",
|
||||||
@SerialName("display_name") val displayName: String,
|
@SerialName("user_id") val userId: String = "",
|
||||||
val role: String,
|
val role: String = "",
|
||||||
|
val status: String = "",
|
||||||
|
val nickname: String = "",
|
||||||
|
val phone: String = "",
|
||||||
|
@SerialName("joined_at") val joinedAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Tag(val id: String, val name: String, val color: String? = null)
|
||||||
|
|
||||||
|
/** 与 backend/app/api/items.py 的 `_item_out` 对齐。 */
|
||||||
|
@Serializable
|
||||||
|
data class Item(
|
||||||
|
val id: String,
|
||||||
|
val kind: String = "task",
|
||||||
|
val title: String,
|
||||||
|
@SerialName("body_md") val bodyMd: String = "",
|
||||||
val status: String,
|
val status: String,
|
||||||
|
val priority: Int = 1,
|
||||||
|
@SerialName("creator_id") val creatorId: String = "",
|
||||||
|
@SerialName("assignee_id") val assigneeId: String? = null,
|
||||||
|
@SerialName("due_at") val dueAt: String? = null,
|
||||||
|
@SerialName("completed_at") val completedAt: String? = null,
|
||||||
|
val tags: List<Tag> = emptyList(),
|
||||||
|
@SerialName("version") val version: Int = 1,
|
||||||
|
) {
|
||||||
|
val isDone: Boolean get() = status == "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemPage(
|
||||||
|
val items: List<Item> = emptyList(),
|
||||||
|
@SerialName("next_cursor") val nextCursor: String? = null,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Activity(
|
||||||
|
val id: String,
|
||||||
|
val action: String,
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemDetail(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
@SerialName("body_md") val bodyMd: String = "",
|
||||||
|
val status: String,
|
||||||
|
val priority: Int = 1,
|
||||||
|
@SerialName("due_at") val dueAt: String? = null,
|
||||||
|
val tags: List<Tag> = emptyList(),
|
||||||
|
val version: Int = 1,
|
||||||
|
val activities: List<Activity> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -132,6 +188,37 @@ class Api(private val tokens: TokenStore) {
|
|||||||
auth = true,
|
auth = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun items(workspaceId: String, view: String = "active", query: String? = null): ItemPage {
|
||||||
|
val params = buildString {
|
||||||
|
append("?view=").append(view).append("&limit=30")
|
||||||
|
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||||
|
}
|
||||||
|
return call(path = "/workspaces/$workspaceId/items$params", auth = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createItem(workspaceId: String, title: String, status: String = "inbox"): Item = call(
|
||||||
|
path = "/workspaces/$workspaceId/items",
|
||||||
|
body = json.encodeToString(mapOf("title" to title, "status" to status)),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 改状态必须带 version:服务端用 `UPDATE ... WHERE version = ?` 防并发覆盖。 */
|
||||||
|
suspend fun setStatus(workspaceId: String, itemId: String, status: String, version: Int): Item =
|
||||||
|
call(
|
||||||
|
path = "/workspaces/$workspaceId/items/$itemId/status",
|
||||||
|
body = json.encodeToString(mapOf("status" to status, "version" to version)),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun itemDetail(workspaceId: String, itemId: String): ItemDetail =
|
||||||
|
call(path = "/workspaces/$workspaceId/items/$itemId", auth = true)
|
||||||
|
|
||||||
|
suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call(
|
||||||
|
path = "/workspaces/$workspaceId/items/$itemId/comments",
|
||||||
|
body = json.encodeToString(mapOf("body_md" to body)),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend inline fun <reified T> call(
|
private suspend inline fun <reified T> call(
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ private val ROLE_LABEL = mapOf(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
|
||||||
val workspaces = remember { mutableStateListOf<Workspace>() }
|
val workspaces = remember { mutableStateListOf<Workspace>() }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -95,16 +95,7 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
|||||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
items(workspaces, key = { it.id }) { ws ->
|
items(workspaces, key = { it.id }) { ws ->
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable {
|
modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) },
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = CardDefaults.cardColors(),
|
colors = CardDefaults.cardColors(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -120,11 +111,23 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
Text(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
||||||
color = Accent,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
)
|
color = Accent,
|
||||||
|
)
|
||||||
|
TextButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val inv = api.createInvitation(ws.id)
|
||||||
|
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text("邀请") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.dadao.task.data.Api
|
||||||
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.Item
|
||||||
|
import com.dadao.task.data.Workspace
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val VIEWS = listOf(
|
||||||
|
"today" to "今天",
|
||||||
|
"active" to "进行中",
|
||||||
|
"inbox" to "收件箱",
|
||||||
|
"done" to "已完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val STATUS_LABEL = mapOf(
|
||||||
|
"inbox" to "收件箱",
|
||||||
|
"todo" to "待办",
|
||||||
|
"doing" to "进行中",
|
||||||
|
"done" to "已完成",
|
||||||
|
"blocked" to "被阻塞",
|
||||||
|
"canceled" to "已取消",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表。
|
||||||
|
*
|
||||||
|
* 勾选 = 完成/重开,都带 `version` 回传:手机端最常见的是"在地铁上点了一下,
|
||||||
|
* 旁边同事也在改",没有乐观锁就会静默覆盖对方。
|
||||||
|
* 409 时把服务端的最新内容拉回来,而不是让界面停在错误的勾选状态上。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TaskListScreen(
|
||||||
|
api: Api,
|
||||||
|
workspace: Workspace,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val items = remember { mutableStateListOf<Item>() }
|
||||||
|
var view by remember { mutableStateOf("today") }
|
||||||
|
var draft by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var detailId by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
suspend fun reload() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
val page = api.items(workspace.id, view)
|
||||||
|
items.clear()
|
||||||
|
items.addAll(page.items)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(view) { reload() }
|
||||||
|
|
||||||
|
suspend fun toggle(item: Item) {
|
||||||
|
val target = if (item.status == "done") "todo" else "done"
|
||||||
|
try {
|
||||||
|
val updated = api.setStatus(workspace.id, item.id, target, item.version)
|
||||||
|
val index = items.indexOfFirst { it.id == updated.id }
|
||||||
|
if (index >= 0) items[index] = updated
|
||||||
|
if (target == "done" && view != "done") items.removeAll { it.id == updated.id }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = if (e.code == 4009) "这条刚被别人改过,已刷新" else e.message
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun add() {
|
||||||
|
val title = draft.trim()
|
||||||
|
if (title.isEmpty()) return
|
||||||
|
try {
|
||||||
|
val created = api.createItem(workspace.id, title, if (view == "inbox") "inbox" else "todo")
|
||||||
|
draft = ""
|
||||||
|
if (view != "done") items.add(0, created)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(workspace.name) },
|
||||||
|
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
VIEWS.forEach { (key, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = view == key,
|
||||||
|
onClick = { view = key },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("加一件要做的事,回车创建") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { scope.launch { add() } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
items.isEmpty() -> Text(
|
||||||
|
if (view == "today") "今天没有到期的事,挺好。" else "这里还空着。",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(items, key = { it.id }) { item ->
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = item.isDone,
|
||||||
|
onCheckedChange = { scope.launch { toggle(item) } },
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).clickable { detailId = item.id },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textDecoration = if (item.isDone) TextDecoration.LineThrough else null,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
STATUS_LABEL[item.status] ?: item.status,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Accent,
|
||||||
|
)
|
||||||
|
item.dueAt?.let {
|
||||||
|
Text(
|
||||||
|
it.take(10),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.tags.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
item.tags.joinToString(" ") { "#${it.name}" },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detailId?.let { id ->
|
||||||
|
var detail by remember(id) { mutableStateOf<String?>(null) }
|
||||||
|
var comment by remember(id) { mutableStateOf("") }
|
||||||
|
LaunchedEffect(id) {
|
||||||
|
detail = try {
|
||||||
|
val d = api.itemDetail(workspace.id, id)
|
||||||
|
buildString {
|
||||||
|
appendLine(d.title)
|
||||||
|
appendLine()
|
||||||
|
appendLine("状态:${STATUS_LABEL[d.status] ?: d.status} 优先级:${d.priority}")
|
||||||
|
d.dueAt?.let { appendLine("截止:${it.take(16).replace('T', ' ')}") }
|
||||||
|
if (d.tags.isNotEmpty()) appendLine("标签:${d.tags.joinToString(" ") { "#${it.name}" }}")
|
||||||
|
if (d.bodyMd.isNotBlank()) {
|
||||||
|
appendLine()
|
||||||
|
append(d.bodyMd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { detailId = null },
|
||||||
|
title = { Text("任务详情") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(detail ?: "加载中…", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = comment,
|
||||||
|
onValueChange = { comment = it },
|
||||||
|
label = { Text("补充一句进展") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = comment.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
api.addComment(workspace.id, id, comment.trim())
|
||||||
|
comment = ""
|
||||||
|
detailId = null
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text("发表") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { detailId = null }) { Text("关闭") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 知识库向量库:独立实例(不碰 fnsvr-01)
|
||||||
|
#
|
||||||
|
# 为什么单独跑一个:
|
||||||
|
# 1. fnsvr-01 上的 PG 17 没有 pgvector,装扩展要动生产机,风险不值得;
|
||||||
|
# 2. 向量检索是重 IO、重内存的活,和业务库挤在一起会互相拖累;
|
||||||
|
# 3. 隔离后可以单独备份/重建/换 embedding 维度,不牵连任务数据。
|
||||||
|
#
|
||||||
|
# 启动:docker-compose -f docker-compose.vector.yml up -d
|
||||||
|
# 端口:5434(本机 5433 已经被开发库占用)
|
||||||
|
|
||||||
|
services:
|
||||||
|
vector-db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: dadao-vector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: dadao_owner
|
||||||
|
POSTGRES_PASSWORD: dadao_dev_pw
|
||||||
|
POSTGRES_DB: dadao_knowledge
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5434:5432"
|
||||||
|
volumes:
|
||||||
|
- vector_pgdata:/var/lib/postgresql/data
|
||||||
|
- ./postgres/vector-init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U dadao_owner -d dadao_knowledge"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- shared_preload_libraries=vector
|
||||||
|
- -c
|
||||||
|
- maintenance_work_mem=512MB
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vector_pgdata:
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 向量库初始化(只在首次创建数据卷时执行一次)
|
||||||
|
-- 与业务库完全隔离:这里没有任何任务/成员/工单数据,只有切片与向量。
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- 应用角色:非 superuser、无 BYPASSRLS。向量表同样开 RLS,
|
||||||
|
-- 因为"知识库串租户"比"任务串租户"更致命——那是把别人公司的文档喂给模型。
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dadao_app') THEN
|
||||||
|
CREATE ROLE dadao_app LOGIN PASSWORD 'dadao_dev_pw'
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT CONNECT ON DATABASE dadao_knowledge TO dadao_app;
|
||||||
|
GRANT USAGE ON SCHEMA public TO dadao_app;
|
||||||
|
|
||||||
|
-- 表结构由后端 alembic 建(迁移脚本里带 RLS 策略),这里只准备环境。
|
||||||
|
-- 但默认权限要提前给:否则 alembic 建完表,dadao_app 还是读不了。
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO dadao_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO dadao_app;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 三条真浏览器用例:登录 → 建任务(含 #标签/!优先级)→ 看板拖拽 → 详情抽屉保存。
|
||||||
|
*
|
||||||
|
* 用 playwright-core + 本机缓存的 Chromium,不下载浏览器(这台机器外网被掐)。
|
||||||
|
* 跑之前先起后端(8100)与前端(5180):
|
||||||
|
* node e2e/smoke.mjs
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const APP = process.env.APP_URL ?? "http://127.0.0.1:5180";
|
||||||
|
const CHROME =
|
||||||
|
process.env.CHROME_PATH ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||||
|
const SHOT_DIR = new URL("./artifacts/", import.meta.url).pathname.replace(/^\//, "");
|
||||||
|
|
||||||
|
if (!existsSync(CHROME)) {
|
||||||
|
console.error(`找不到 Chromium:${CHROME}\n用 CHROME_PATH 指定可执行文件`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
mkdirSync(SHOT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const phone = `139${String(Date.now()).slice(-8)}`;
|
||||||
|
let step = 0;
|
||||||
|
function pass(message) {
|
||||||
|
console.log(` ✓ ${++step}. ${message}`);
|
||||||
|
}
|
||||||
|
function fail(message) {
|
||||||
|
console.error(` ✗ ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||||
|
page.setDefaultTimeout(10_000);
|
||||||
|
|
||||||
|
const consoleErrors = [];
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||||
|
});
|
||||||
|
page.on("pageerror", (err) => consoleErrors.push(String(err)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 登录
|
||||||
|
await page.goto(`${APP}/login`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#phone").fill(phone);
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
const devCodeText = await page.locator("text=开发环境验证码").first().innerText();
|
||||||
|
const code = devCodeText.match(/(\d{6})/)?.[1];
|
||||||
|
if (!code) fail(`没读到开发验证码:${devCodeText}`);
|
||||||
|
await page.locator("#code").fill(code);
|
||||||
|
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await page.waitForURL(`${APP}/`);
|
||||||
|
pass("手机号验证码登录成功");
|
||||||
|
|
||||||
|
// 2. 建工作区(创建后直接跳进任务页)
|
||||||
|
await page.getByPlaceholder("例如:达道科技 / 我的事项").fill("E2E 测试组");
|
||||||
|
await page.getByRole("button", { name: "创建" }).click();
|
||||||
|
await page.waitForURL(/\/w\/[0-9a-f-]{36}$/);
|
||||||
|
await page.getByRole("heading", { name: "任务" }).waitFor();
|
||||||
|
pass("建工作区并进入任务页");
|
||||||
|
|
||||||
|
// 3. 快速录入:#标签 + !优先级
|
||||||
|
const composer = page.getByPlaceholder(/加一件要做的事/);
|
||||||
|
await composer.fill("写周报初稿 #周报 !高");
|
||||||
|
await composer.press("Enter");
|
||||||
|
const row = page.locator("div.card > div").filter({ hasText: "写周报初稿" }).first();
|
||||||
|
await row.waitFor();
|
||||||
|
const rowText = await row.innerText();
|
||||||
|
if (!rowText.includes("#周报")) fail(`标签没解析进标题外:${rowText}`);
|
||||||
|
if (rowText.includes("#周报") && rowText.includes("写周报初稿 #周报")) fail("标题里残留了语法");
|
||||||
|
if (!rowText.includes("优先级 高")) fail(`优先级没生效:${rowText}`);
|
||||||
|
pass("快速录入解析 #标签 与 !优先级(标题里已剥离语法)");
|
||||||
|
|
||||||
|
// 4. 详情抽屉可编辑:改优先级 + 加评论
|
||||||
|
await page.getByRole("button", { name: "写周报初稿" }).click();
|
||||||
|
const drawer = page.locator("aside");
|
||||||
|
await drawer.waitFor();
|
||||||
|
await drawer.locator("select").selectOption("2");
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const commentBox = drawer.getByPlaceholder("补充一句进展…");
|
||||||
|
await commentBox.fill("E2E:初稿已出,等评审");
|
||||||
|
await commentBox.press("Enter");
|
||||||
|
await drawer.locator("text=E2E:初稿已出,等评审").waitFor();
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/detail.png` });
|
||||||
|
await page.getByRole("button", { name: "关闭" }).click();
|
||||||
|
const rowAfter = await page.locator("div.card > div").filter({ hasText: "写周报初稿" }).first().innerText();
|
||||||
|
if (!rowAfter.includes("优先级 中")) fail(`详情里改优先级没同步回列表:${rowAfter}`);
|
||||||
|
pass("详情抽屉保存(优先级回写列表)与评论落库");
|
||||||
|
|
||||||
|
// 5. 看板拖拽:待办 → 进行中
|
||||||
|
await page.getByRole("link", { name: "看板" }).click();
|
||||||
|
await page.getByRole("heading", { name: "看板" }).waitFor();
|
||||||
|
const columns = page.locator("section");
|
||||||
|
const todoCol = columns.nth(0);
|
||||||
|
const doingCol = columns.nth(1);
|
||||||
|
const card = todoCol.locator("article").filter({ hasText: "写周报初稿" }).first();
|
||||||
|
await card.waitFor();
|
||||||
|
await card.dragTo(doingCol);
|
||||||
|
await doingCol.locator("article").filter({ hasText: "写周报初稿" }).first().waitFor();
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/board.png` });
|
||||||
|
pass("看板拖拽改状态(待办 → 进行中)落库并刷新");
|
||||||
|
|
||||||
|
// 6. 状态机拒绝非法拖拽:进行中 → 待办 之后不能再直接拖到"已完成"以外的非法列(用 blocked→done 验证)
|
||||||
|
const blockedCol = columns.nth(2);
|
||||||
|
await doingCol.locator("article").filter({ hasText: "写周报初稿" }).first().dragTo(blockedCol);
|
||||||
|
await blockedCol.locator("article").filter({ hasText: "写周报初稿" }).first().waitFor();
|
||||||
|
const doneCol = columns.nth(3);
|
||||||
|
await blockedCol.locator("article").filter({ hasText: "写周报初稿" }).first().dragTo(doneCol);
|
||||||
|
const warning = await page.locator("text=不能直接变成").first().innerText();
|
||||||
|
if (!warning.includes("不能直接变成")) fail("非法拖拽没有被拦下");
|
||||||
|
pass(`非法状态流转被拦下并给出原因:${warning}`);
|
||||||
|
|
||||||
|
if (consoleErrors.length > 0) {
|
||||||
|
fail(`浏览器控制台有报错:\n${consoleErrors.join("\n")}`);
|
||||||
|
}
|
||||||
|
pass("浏览器控制台无报错");
|
||||||
|
|
||||||
|
console.log(`\n全部通过,截图在 ${SHOT_DIR}`);
|
||||||
|
await browser.close();
|
||||||
|
} catch (err) {
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/failure.png` }).catch(() => {});
|
||||||
|
console.error(`\n失败:${err?.message ?? err}`);
|
||||||
|
await browser.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>达道Task</title>
|
<title>达道Task</title>
|
||||||
|
<meta name="description" content="达道Task:任务、工单、知识库,一条道走到底" />
|
||||||
|
<!-- 内联 SVG 图标:不额外发请求,也就不会有 favicon 404 -->
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233F5A68'/%3E%3Cpath d='M18 34l8 8 20-20' stroke='%23C2703D' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LoginPage } from "./pages/Login";
|
|||||||
import { HomePage } from "./pages/Home";
|
import { HomePage } from "./pages/Home";
|
||||||
import { WorkspacePage } from "./pages/Workspace";
|
import { WorkspacePage } from "./pages/Workspace";
|
||||||
import { TasksPage } from "./pages/Tasks";
|
import { TasksPage } from "./pages/Tasks";
|
||||||
|
import { BoardPage } from "./pages/Board";
|
||||||
import { JoinPage } from "./pages/Join";
|
import { JoinPage } from "./pages/Join";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -82,6 +83,7 @@ export function App() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/w/:wsId" element={<TasksPage />} />
|
<Route path="/w/:wsId" element={<TasksPage />} />
|
||||||
|
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ApiError, api } from "../lib/client";
|
||||||
|
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
|
||||||
|
import type { Activity, Item, ItemDetail } from "../lib/types";
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
body_md: string;
|
||||||
|
author_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wsId: string;
|
||||||
|
itemId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: (item: Item) => void;
|
||||||
|
onDeleted: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalInput(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Props) {
|
||||||
|
const [item, setItem] = useState<ItemDetail | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [due, setDue] = useState("");
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const base = `/workspaces/${wsId}/items/${itemId}`;
|
||||||
|
|
||||||
|
const hydrate = useCallback((next: ItemDetail) => {
|
||||||
|
setItem(next);
|
||||||
|
setTitle(next.title);
|
||||||
|
setBody(next.body_md);
|
||||||
|
setTags(next.tags.map((t) => t.name).join(" "));
|
||||||
|
setDue(toLocalInput(next.due_at));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const detail = await api.get<ItemDetail>(base);
|
||||||
|
if (alive) hydrate(detail);
|
||||||
|
const page = await api.get<{ items: Comment[] }>(`${base}/comments`);
|
||||||
|
if (alive) setComments(page.items);
|
||||||
|
} catch (err) {
|
||||||
|
if (alive) setError(err instanceof ApiError ? err.message : "加载失败");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [base, hydrate]);
|
||||||
|
|
||||||
|
async function patch(
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
label: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!item) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<Item>(base, { ...values, version: item.version });
|
||||||
|
onChanged(updated);
|
||||||
|
hydrate({ ...(item as ItemDetail), ...updated, activities: item.activities });
|
||||||
|
void refreshActivities(updated);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
|
setError(`${label}没保存:这条刚被别人改过,已刷新为最新内容`);
|
||||||
|
const fresh = await api.get<ItemDetail>(base);
|
||||||
|
hydrate(fresh);
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : `${label}没保存`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshActivities(updated: Item) {
|
||||||
|
try {
|
||||||
|
const fresh = await api.get<ItemDetail>(base);
|
||||||
|
setItem({ ...fresh, ...updated, activities: fresh.activities });
|
||||||
|
setTitle(updated.title);
|
||||||
|
setBody(updated.body_md);
|
||||||
|
setTags(updated.tags.map((t) => t.name).join(" "));
|
||||||
|
setDue(toLocalInput(updated.due_at));
|
||||||
|
} catch {
|
||||||
|
/* 刷新事件流失败不影响已保存的结果 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTitle() {
|
||||||
|
if (!item || !title.trim() || title.trim() === item.title) return;
|
||||||
|
await patch({ title: title.trim() }, "标题");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBody() {
|
||||||
|
if (!item || body === item.body_md) return;
|
||||||
|
await patch({ body_md: body }, "备注");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTags() {
|
||||||
|
if (!item) return;
|
||||||
|
const next = tags.split(/[\s,,]+/).filter(Boolean);
|
||||||
|
const current = item.tags.map((t) => t.name);
|
||||||
|
if (next.join() === current.join()) return;
|
||||||
|
await patch({ tag_names: next }, "标签");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDue(value: string) {
|
||||||
|
if (!item) return;
|
||||||
|
if (!value) {
|
||||||
|
await patch({ clear_due_at: true }, "截止时间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await patch({ due_at: new Date(value).toISOString() }, "截止时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComment() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
const created = await api.post<Comment>(`${base}/comments`, { body_md: text });
|
||||||
|
setComments((prev) => [...prev, created]);
|
||||||
|
setDraft("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "评论失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!item) return;
|
||||||
|
if (!window.confirm("删除这条任务?可以在「已删除」视图里找回内容。")) return;
|
||||||
|
try {
|
||||||
|
await api.del(`${base}?version=${item.version}`);
|
||||||
|
onDeleted(item.id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
|
||||||
|
<aside
|
||||||
|
className="flex h-full w-full max-w-xl flex-col overflow-y-auto bg-white p-6 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{!item ? (
|
||||||
|
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
|
||||||
|
<span>
|
||||||
|
{STATUS_LABEL[item.status]} · 版本 {item.version}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-ghost px-2 py-1 text-xs text-red-600" onClick={() => void remove()}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="input text-base font-medium"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onBlur={() => void saveTitle()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">优先级</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={item.priority}
|
||||||
|
onChange={(e) => void patch({ priority: Number(e.target.value) }, "优先级")}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{PRIORITY_LABEL[p]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">截止时间</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="input"
|
||||||
|
value={due}
|
||||||
|
onChange={(e) => setDue(e.target.value)}
|
||||||
|
onBlur={(e) => void saveDue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm">
|
||||||
|
<span className="label">标签(空格分隔)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
onBlur={() => void saveTags()}
|
||||||
|
placeholder="周报 客户"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm">
|
||||||
|
<span className="label">备注</span>
|
||||||
|
<textarea
|
||||||
|
className="input min-h-32"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onBlur={() => void saveBody()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
{busy && <p className="mt-3 text-xs text-ink-500">保存中…</p>}
|
||||||
|
|
||||||
|
<h3 className="mt-6 text-sm font-medium">评论({comments.length})</h3>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{comments.map((c) => (
|
||||||
|
<li key={c.id} className="rounded-lg bg-ink-50 p-2 text-sm">
|
||||||
|
<p className="whitespace-pre-wrap">{c.body_md}</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-500">
|
||||||
|
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="补充一句进展…"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void addComment();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addComment()}>
|
||||||
|
发表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mt-6 text-sm font-medium">动态</h3>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-ink-500">
|
||||||
|
{item.activities.slice(0, 30).map((a: Activity) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
|
||||||
|
{a.action === "status_changed" && a.payload
|
||||||
|
? ` ${String(a.payload.from ?? "")} → ${String(a.payload.to ?? "")}`
|
||||||
|
: ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ItemStatus } from "./types";
|
||||||
|
|
||||||
|
const PRIORITY_ALIASES: Record<string, number> = {
|
||||||
|
"!0": 0,
|
||||||
|
"!1": 1,
|
||||||
|
"!2": 2,
|
||||||
|
"!3": 3,
|
||||||
|
"!低": 1,
|
||||||
|
"!中": 2,
|
||||||
|
"!高": 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface QuickAdd {
|
||||||
|
title: string;
|
||||||
|
tagNames: string[];
|
||||||
|
priority: number;
|
||||||
|
status: ItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析快速录入:`#标签` 与 `!优先级` 会被吃掉,剩下的做标题。
|
||||||
|
*
|
||||||
|
* 只认行内 `#` 开头且后面不是空格的片段——中文正文里 `#` 极少自然出现,
|
||||||
|
* 真要以 # 开头就加个空格。`@人` 暂时不解析:重名的人比标签多,
|
||||||
|
* 悄悄指错负责人比不支持更糟(等 M2 有成员选择器再做)。
|
||||||
|
*/
|
||||||
|
export function parseQuickAdd(raw: string, defaultStatus: ItemStatus): QuickAdd {
|
||||||
|
const tagNames: string[] = [];
|
||||||
|
let priority = 1;
|
||||||
|
const words: string[] = [];
|
||||||
|
|
||||||
|
for (const token of raw.split(/\s+/)) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (token.length > 1 && token.startsWith("#")) {
|
||||||
|
tagNames.push(token.slice(1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (PRIORITY_ALIASES[token] !== undefined) {
|
||||||
|
priority = PRIORITY_ALIASES[token];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
words.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = words.join(" ").trim() || raw.trim();
|
||||||
|
return { title, tagNames, priority, status: defaultStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLE_LABEL: Record<string, string> = {
|
||||||
|
owner: "负责人",
|
||||||
|
admin: "管理员",
|
||||||
|
member: "成员",
|
||||||
|
guest: "访客",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||||
|
inbox: "收件箱",
|
||||||
|
todo: "待办",
|
||||||
|
doing: "进行中",
|
||||||
|
done: "已完成",
|
||||||
|
blocked: "被阻塞",
|
||||||
|
canceled: "已取消",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 状态机前端影子:只用来置灰拖拽目标,服务端仍会再校验一次。 */
|
||||||
|
export const ALLOWED: Record<ItemStatus, ItemStatus[]> = {
|
||||||
|
inbox: ["todo", "canceled"],
|
||||||
|
todo: ["inbox", "doing", "done", "canceled"],
|
||||||
|
doing: ["todo", "done", "blocked", "canceled"],
|
||||||
|
blocked: ["doing", "canceled"],
|
||||||
|
done: ["todo"],
|
||||||
|
canceled: ["todo"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canTransition(from: ItemStatus, to: ItemStatus): boolean {
|
||||||
|
return from === to || (ALLOWED[from] ?? []).includes(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRIORITY_LABEL: Record<number, string> = {
|
||||||
|
0: "无",
|
||||||
|
1: "低",
|
||||||
|
2: "中",
|
||||||
|
3: "高",
|
||||||
|
};
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
|
import { ApiError, api } from "../lib/client";
|
||||||
|
import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse";
|
||||||
|
import type { Item, ItemStatus } from "../lib/types";
|
||||||
|
|
||||||
|
const COLUMNS: { key: ItemStatus; label: string }[] = [
|
||||||
|
{ key: "todo", label: "待办" },
|
||||||
|
{ key: "doing", label: "进行中" },
|
||||||
|
{ key: "blocked", label: "被阻塞" },
|
||||||
|
{ key: "done", label: "已完成" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BoardPayload {
|
||||||
|
columns: Record<string, Item[]>;
|
||||||
|
order: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchResult {
|
||||||
|
updated: { id: string; version: number; unchanged: boolean }[];
|
||||||
|
failed: { id: string; code: number; message: string; allowed?: string[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardPage() {
|
||||||
|
const { wsId } = useParams<{ wsId: string }>();
|
||||||
|
const [columns, setColumns] = useState<Record<string, Item[]>>({});
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [hoverCol, setHoverCol] = useState<string | null>(null);
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!wsId) return;
|
||||||
|
try {
|
||||||
|
const data = await api.get<BoardPayload>(`/workspaces/${wsId}/items/board`);
|
||||||
|
setColumns(data.columns);
|
||||||
|
setSelected((prev) => {
|
||||||
|
const alive = new Set(Object.values(data.columns).flat().map((i) => i.id));
|
||||||
|
return new Set([...prev].filter((id) => alive.has(id)));
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "看板加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [wsId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const allItems = useMemo(() => Object.values(columns).flat(), [columns]);
|
||||||
|
const selectedItems = allItems.filter((i) => selected.has(i.id));
|
||||||
|
|
||||||
|
function moveLocally(item: Item, to: ItemStatus) {
|
||||||
|
setColumns((prev) => {
|
||||||
|
const next: Record<string, Item[]> = {};
|
||||||
|
for (const [key, list] of Object.entries(prev)) {
|
||||||
|
next[key] = list.filter((i) => i.id !== item.id);
|
||||||
|
}
|
||||||
|
next[to] = [{ ...item, status: to }, ...(next[to] ?? [])];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(item: Item, to: ItemStatus) {
|
||||||
|
if (!wsId || item.status === to) return;
|
||||||
|
if (!canTransition(item.status, to)) {
|
||||||
|
setError(`「${STATUS_LABEL[item.status]}」不能直接变成「${STATUS_LABEL[to]}」`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveLocally(item, to);
|
||||||
|
try {
|
||||||
|
const updated = await api.post<Item>(`/workspaces/${wsId}/items/${item.id}/status`, {
|
||||||
|
status: to,
|
||||||
|
version: item.version,
|
||||||
|
});
|
||||||
|
setColumns((prev) => {
|
||||||
|
const next: Record<string, Item[]> = {};
|
||||||
|
for (const [key, list] of Object.entries(prev)) {
|
||||||
|
next[key] = list.map((i) => (i.id === updated.id ? updated : i));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
void load();
|
||||||
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
|
setError("这条刚被别人改过,看板已刷新,请再拖一次");
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : "移动失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchMove(to: ItemStatus) {
|
||||||
|
if (!wsId || selectedItems.length === 0) return;
|
||||||
|
const movable = selectedItems.filter((i) => canTransition(i.status, to));
|
||||||
|
const blocked = selectedItems.length - movable.length;
|
||||||
|
if (movable.length === 0) {
|
||||||
|
setError(`选中的 ${selectedItems.length} 条都不能直接变成「${STATUS_LABEL[to]}」`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await api.post<BatchResult>(`/workspaces/${wsId}/items/batch-status`, {
|
||||||
|
status: to,
|
||||||
|
items: movable.map((i) => ({ id: i.id, version: i.version })),
|
||||||
|
});
|
||||||
|
const failedIds = new Set(result.failed.map((f) => f.id));
|
||||||
|
const note = [
|
||||||
|
`已移动 ${result.updated.length} 条`,
|
||||||
|
failedIds.size > 0 ? `${failedIds.size} 条因并发冲突未移动` : "",
|
||||||
|
blocked > 0 ? `${blocked} 条状态不允许` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(",");
|
||||||
|
setError(note);
|
||||||
|
setSelected(new Set());
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "批量移动失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(to: ItemStatus) {
|
||||||
|
setHoverCol(null);
|
||||||
|
const item = allItems.find((i) => i.id === dragId);
|
||||||
|
setDragId(null);
|
||||||
|
if (item) await move(item, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">看板</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">
|
||||||
|
拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会直接拒绝并说明原因。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Link to={`/w/${wsId}`} className="text-ink-500 hover:text-ink-900">
|
||||||
|
列表
|
||||||
|
</Link>
|
||||||
|
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
||||||
|
成员
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg bg-ink-50 px-3 py-2 text-sm">
|
||||||
|
<span className="text-ink-700">已选 {selectedItems.length} 条</span>
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<button
|
||||||
|
key={col.key}
|
||||||
|
className="btn-ghost px-2 py-1 text-xs"
|
||||||
|
onClick={() => void batchMove(col.key)}
|
||||||
|
>
|
||||||
|
移到{col.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="btn-ghost ml-auto px-2 py-1 text-xs" onClick={() => setSelected(new Set())}>
|
||||||
|
取消选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-amber-700">{error}</p>}
|
||||||
|
{loading && <p className="text-sm text-ink-500">加载中…</p>}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
{COLUMNS.map((col) => {
|
||||||
|
const list = columns[col.key] ?? [];
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={col.key}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setHoverCol(col.key);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setHoverCol((prev) => (prev === col.key ? null : prev))}
|
||||||
|
onDrop={() => void onDrop(col.key)}
|
||||||
|
className={`rounded-xl border p-2 transition ${
|
||||||
|
hoverCol === col.key ? "border-ink-900 bg-ink-50" : "border-ink-100 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<header className="mb-2 flex items-center justify-between px-1 text-xs text-ink-500">
|
||||||
|
<span className="font-medium text-ink-700">{col.label}</span>
|
||||||
|
<span>{list.length}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragId(item.id)}
|
||||||
|
onDragEnd={() => setDragId(null)}
|
||||||
|
className={`cursor-grab rounded-lg border border-ink-100 bg-white p-2 shadow-sm ${
|
||||||
|
dragId === item.id ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-3.5 w-3.5 accent-ink-900"
|
||||||
|
checked={selected.has(item.id)}
|
||||||
|
onChange={() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item.id)) next.delete(item.id);
|
||||||
|
else next.add(item.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="min-w-0 flex-1 text-left text-sm"
|
||||||
|
onClick={() => setOpenId(item.id)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1 pl-5 text-[11px] text-ink-500">
|
||||||
|
{item.priority > 0 && (
|
||||||
|
<span className="rounded bg-accent/15 px-1 text-accent">
|
||||||
|
{PRIORITY_LABEL[item.priority]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.due_at && <span>{new Date(item.due_at).toLocaleDateString("zh-CN")}</span>}
|
||||||
|
{item.tags.map((t) => (
|
||||||
|
<span key={t.id}>#{t.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{list.length === 0 && (
|
||||||
|
<p className="px-1 py-4 text-center text-xs text-ink-500">空</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openId && wsId && (
|
||||||
|
<TaskDetail
|
||||||
|
wsId={wsId}
|
||||||
|
itemId={openId}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
onChanged={() => void load()}
|
||||||
|
onDeleted={() => void load()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+86
-72
@@ -1,26 +1,19 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Layout } from "../components/Layout";
|
import { Layout } from "../components/Layout";
|
||||||
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
import { ApiError, api } from "../lib/client";
|
import { ApiError, api } from "../lib/client";
|
||||||
import type { Item, ItemDetail, ItemStatus, Page } from "../lib/types";
|
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
|
||||||
|
import type { Item, ItemStatus, Page } from "../lib/types";
|
||||||
|
|
||||||
const VIEWS: { key: string; label: string; hint: string }[] = [
|
const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [
|
||||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的" },
|
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" },
|
||||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" },
|
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" },
|
||||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事" },
|
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" },
|
||||||
{ key: "done", label: "已完成", hint: "最近完成的" },
|
{ key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" },
|
||||||
{ key: "all", label: "全部", hint: "含已取消" },
|
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
|
||||||
inbox: "收件箱",
|
|
||||||
todo: "待办",
|
|
||||||
doing: "进行中",
|
|
||||||
done: "已完成",
|
|
||||||
blocked: "被阻塞",
|
|
||||||
canceled: "已取消",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_STYLE: Record<ItemStatus, string> = {
|
const STATUS_STYLE: Record<ItemStatus, string> = {
|
||||||
inbox: "bg-ink-100 text-ink-700",
|
inbox: "bg-ink-100 text-ink-700",
|
||||||
todo: "bg-ink-100 text-ink-700",
|
todo: "bg-ink-100 text-ink-700",
|
||||||
@@ -30,12 +23,11 @@ const STATUS_STYLE: Record<ItemStatus, string> = {
|
|||||||
canceled: "bg-ink-100 text-ink-500 line-through",
|
canceled: "bg-ink-100 text-ink-500 line-through",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDue(iso: string | null): { text: string; overdue: boolean } | null {
|
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const due = new Date(iso);
|
const due = new Date(iso);
|
||||||
const now = new Date();
|
|
||||||
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||||||
return { text, overdue: due.getTime() < now.getTime() };
|
return { text, overdue: due.getTime() < Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TasksPage() {
|
export function TasksPage() {
|
||||||
@@ -49,8 +41,9 @@ export function TasksPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<ItemDetail | null>(null);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
const draftRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
const current = VIEWS.find((v) => v.key === view);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (nextCursor?: string | null) => {
|
async (nextCursor?: string | null) => {
|
||||||
@@ -78,28 +71,48 @@ export function TasksPage() {
|
|||||||
void load(null);
|
void load(null);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openId || !wsId) {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
setDetail(null);
|
const target = e.target as HTMLElement;
|
||||||
return;
|
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
|
||||||
}
|
if (e.key === "Escape") {
|
||||||
void api
|
setOpenId(null);
|
||||||
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
|
(target as HTMLInputElement).blur?.();
|
||||||
.then(setDetail)
|
return;
|
||||||
.catch(() => setDetail(null));
|
}
|
||||||
}, [openId, wsId]);
|
if (typing || items.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === "j" || e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIdx((i) => Math.min(i + 1, items.length - 1));
|
||||||
|
} else if (e.key === "k" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIdx((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "e") {
|
||||||
|
if (activeItem) setOpenId(activeItem.id);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
if (activeItem) void changeStatus(activeItem, activeItem.status === "done" ? "todo" : "done");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
});
|
||||||
|
|
||||||
async function addTask() {
|
async function addTask() {
|
||||||
const title = draft.trim();
|
const raw = draft.trim();
|
||||||
if (!title || !wsId) return;
|
if (!raw || !wsId || !current) return;
|
||||||
|
const parsed = parseQuickAdd(raw, current.createAs);
|
||||||
try {
|
try {
|
||||||
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
||||||
title,
|
title: parsed.title,
|
||||||
status: view === "inbox" ? "inbox" : "todo",
|
status: parsed.status,
|
||||||
|
priority: parsed.priority,
|
||||||
|
tag_names: parsed.tagNames,
|
||||||
});
|
});
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setItems((prev) => [created, ...prev]);
|
if (view !== "done") setItems((prev) => [created, ...prev]);
|
||||||
draftRef.current?.focus();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "创建失败");
|
setError(err instanceof ApiError ? err.message : "创建失败");
|
||||||
}
|
}
|
||||||
@@ -113,7 +126,6 @@ export function TasksPage() {
|
|||||||
version: item.version,
|
version: item.version,
|
||||||
});
|
});
|
||||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
|
||||||
if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 4009) {
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
setError("这条任务刚被别人改过,已为你刷新");
|
setError("这条任务刚被别人改过,已为你刷新");
|
||||||
@@ -124,18 +136,23 @@ export function TasksPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = VIEWS.find((v) => v.key === view);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold">任务</h1>
|
<h1 className="text-lg font-semibold">任务</h1>
|
||||||
<p className="mt-0.5 text-xs text-ink-500">{current?.hint}</p>
|
<p className="mt-0.5 text-xs text-ink-500">
|
||||||
|
{current?.hint}
|
||||||
|
<span className="ml-2 text-ink-100">|</span>
|
||||||
|
<span className="ml-2">j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Link to={`/w/${wsId}/board`} className="text-ink-500 hover:text-ink-900">
|
||||||
|
看板
|
||||||
|
</Link>
|
||||||
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
||||||
成员与邀请
|
成员
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/" className="text-ink-500 hover:text-ink-900">
|
<Link to="/" className="text-ink-500 hover:text-ink-900">
|
||||||
切换工作区
|
切换工作区
|
||||||
@@ -166,9 +183,8 @@ export function TasksPage() {
|
|||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
ref={draftRef}
|
|
||||||
className="input flex-1"
|
className="input flex-1"
|
||||||
placeholder="加一件要做的事,回车创建"
|
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -190,13 +206,19 @@ export function TasksPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.map((item) => {
|
{items.map((item, index) => {
|
||||||
const due = formatDue(item.due_at);
|
const due = dueLabel(item.due_at);
|
||||||
const done = item.status === "done";
|
const done = item.status === "done";
|
||||||
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
||||||
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex items-start gap-3 px-4 py-3">
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 ${
|
||||||
|
index === activeIdx ? "bg-ink-50/70" : ""
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setActiveIdx(index)}
|
||||||
|
>
|
||||||
{canComplete ? (
|
{canComplete ? (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -211,7 +233,7 @@ export function TasksPage() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
|
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
|
||||||
onClick={() => setOpenId(openId === item.id ? null : item.id)}
|
onClick={() => setOpenId(item.id)}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</button>
|
</button>
|
||||||
@@ -219,6 +241,9 @@ export function TasksPage() {
|
|||||||
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
|
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
|
||||||
{STATUS_LABEL[item.status]}
|
{STATUS_LABEL[item.status]}
|
||||||
</span>
|
</span>
|
||||||
|
{item.priority > 0 && (
|
||||||
|
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
|
||||||
|
)}
|
||||||
{due && (
|
{due && (
|
||||||
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
|
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
|
||||||
截止 {due.text}
|
截止 {due.text}
|
||||||
@@ -231,29 +256,6 @@ export function TasksPage() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{openId === item.id && (
|
|
||||||
<div className="mt-3 rounded-lg bg-ink-50 p-3 text-xs">
|
|
||||||
{detail && detail.id === item.id ? (
|
|
||||||
<>
|
|
||||||
{detail.body_md ? (
|
|
||||||
<p className="whitespace-pre-wrap text-ink-700">{detail.body_md}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-ink-500">没有备注。</p>
|
|
||||||
)}
|
|
||||||
<ul className="mt-3 space-y-1 text-ink-500">
|
|
||||||
{detail.activities.slice(0, 8).map((a) => (
|
|
||||||
<li key={a.id}>
|
|
||||||
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-ink-500">加载中…</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
@@ -286,6 +288,18 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
|
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
|
||||||
|
|
||||||
|
{openId && wsId && (
|
||||||
|
<TaskDetail
|
||||||
|
wsId={wsId}
|
||||||
|
itemId={openId}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
onChanged={(updated) =>
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||||
|
}
|
||||||
|
onDeleted={(id) => setItems((prev) => prev.filter((it) => it.id !== id))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user