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 com.dadao.task.data.Api
|
||||
import com.dadao.task.data.TokenStore
|
||||
import com.dadao.task.data.Workspace
|
||||
import com.dadao.task.ui.DadaoTheme
|
||||
import com.dadao.task.ui.HomeScreen
|
||||
import com.dadao.task.ui.LoginScreen
|
||||
import com.dadao.task.ui.TaskListScreen
|
||||
|
||||
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
|
||||
private fun AppRoot() {
|
||||
@@ -41,6 +43,7 @@ private fun AppRoot() {
|
||||
val tokens = remember { TokenStore(context) }
|
||||
val api = remember { Api(tokens) }
|
||||
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
|
||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -64,6 +67,18 @@ private fun AppRoot() {
|
||||
tokens.clear()
|
||||
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 一一对应 ----
|
||||
|
||||
@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
|
||||
data class Tokens(
|
||||
@@ -61,10 +65,62 @@ data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList(
|
||||
|
||||
@Serializable
|
||||
data class Member(
|
||||
val userId: String,
|
||||
@SerialName("display_name") val displayName: String,
|
||||
val role: String,
|
||||
val id: String = "",
|
||||
@SerialName("user_id") val userId: 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 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
|
||||
@@ -132,6 +188,37 @@ class Api(private val tokens: TokenStore) {
|
||||
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(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
|
||||
@@ -47,7 +47,7 @@ private val ROLE_LABEL = mapOf(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(api: Api, onLogout: () -> 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) }
|
||||
@@ -95,16 +95,7 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(workspaces, key = { it.id }) { ws ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) },
|
||||
colors = CardDefaults.cardColors(),
|
||||
) {
|
||||
Row(
|
||||
@@ -120,11 +111,23 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
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("邀请") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LoginPage } from "./pages/Login";
|
||||
import { HomePage } from "./pages/Home";
|
||||
import { WorkspacePage } from "./pages/Workspace";
|
||||
import { TasksPage } from "./pages/Tasks";
|
||||
import { BoardPage } from "./pages/Board";
|
||||
import { JoinPage } from "./pages/Join";
|
||||
|
||||
interface AuthState {
|
||||
@@ -82,6 +83,7 @@ export function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/w/:wsId" element={<TasksPage />} />
|
||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+84
-70
@@ -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 { Layout } from "../components/Layout";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
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 }[] = [
|
||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的" },
|
||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" },
|
||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事" },
|
||||
{ key: "done", label: "已完成", hint: "最近完成的" },
|
||||
{ key: "all", label: "全部", hint: "含已取消" },
|
||||
const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [
|
||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" },
|
||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" },
|
||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" },
|
||||
{ key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" },
|
||||
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
inbox: "收件箱",
|
||||
todo: "待办",
|
||||
doing: "进行中",
|
||||
done: "已完成",
|
||||
blocked: "被阻塞",
|
||||
canceled: "已取消",
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ItemStatus, string> = {
|
||||
inbox: "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",
|
||||
};
|
||||
|
||||
function formatDue(iso: string | null): { text: string; overdue: boolean } | null {
|
||||
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
|
||||
if (!iso) return null;
|
||||
const due = new Date(iso);
|
||||
const now = new Date();
|
||||
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() {
|
||||
@@ -49,8 +41,9 @@ export function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ItemDetail | null>(null);
|
||||
const draftRef = useRef<HTMLInputElement>(null);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
const current = VIEWS.find((v) => v.key === view);
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor?: string | null) => {
|
||||
@@ -78,28 +71,48 @@ export function TasksPage() {
|
||||
void load(null);
|
||||
}, [load]);
|
||||
|
||||
const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openId || !wsId) {
|
||||
setDetail(null);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
|
||||
if (e.key === "Escape") {
|
||||
setOpenId(null);
|
||||
(target as HTMLInputElement).blur?.();
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
|
||||
.then(setDetail)
|
||||
.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() {
|
||||
const title = draft.trim();
|
||||
if (!title || !wsId) return;
|
||||
const raw = draft.trim();
|
||||
if (!raw || !wsId || !current) return;
|
||||
const parsed = parseQuickAdd(raw, current.createAs);
|
||||
try {
|
||||
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
||||
title,
|
||||
status: view === "inbox" ? "inbox" : "todo",
|
||||
title: parsed.title,
|
||||
status: parsed.status,
|
||||
priority: parsed.priority,
|
||||
tag_names: parsed.tagNames,
|
||||
});
|
||||
setDraft("");
|
||||
setItems((prev) => [created, ...prev]);
|
||||
draftRef.current?.focus();
|
||||
if (view !== "done") setItems((prev) => [created, ...prev]);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "创建失败");
|
||||
}
|
||||
@@ -113,7 +126,6 @@ export function TasksPage() {
|
||||
version: item.version,
|
||||
});
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
|
||||
if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("这条任务刚被别人改过,已为你刷新");
|
||||
@@ -124,18 +136,23 @@ export function TasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const current = VIEWS.find((v) => v.key === view);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<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 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>
|
||||
<Link to="/" className="text-ink-500 hover:text-ink-900">
|
||||
切换工作区
|
||||
@@ -166,9 +183,8 @@ export function TasksPage() {
|
||||
<div className="card mb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={draftRef}
|
||||
className="input flex-1"
|
||||
placeholder="加一件要做的事,回车创建"
|
||||
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -190,13 +206,19 @@ export function TasksPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item) => {
|
||||
const due = formatDue(item.due_at);
|
||||
{items.map((item, index) => {
|
||||
const due = dueLabel(item.due_at);
|
||||
const done = item.status === "done";
|
||||
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
||||
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
||||
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 ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -211,7 +233,7 @@ export function TasksPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
@@ -219,6 +241,9 @@ export function TasksPage() {
|
||||
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
|
||||
{STATUS_LABEL[item.status]}
|
||||
</span>
|
||||
{item.priority > 0 && (
|
||||
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
|
||||
)}
|
||||
{due && (
|
||||
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
|
||||
截止 {due.text}
|
||||
@@ -231,29 +256,6 @@ export function TasksPage() {
|
||||
</span>
|
||||
))}
|
||||
</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 className="flex shrink-0 gap-1">
|
||||
@@ -286,6 +288,18 @@ export function TasksPage() {
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user