diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt index 8de8e76..e16d6c8 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -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(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 }) + } + } } } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index 451e9a4..903d8fb 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -36,7 +36,11 @@ private data class Envelope( // ---- 与 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 = 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 = emptyList(), + @SerialName("version") val version: Int = 1, +) { + val isDone: Boolean get() = status == "done" +} + +@Serializable +data class ItemPage( + val items: List = 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 = emptyList(), + val version: Int = 1, + val activities: List = 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 call( path: String, body: String? = null, diff --git a/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt index 7b334f0..0f85e7b 100644 --- a/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt @@ -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() } var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(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), ) } - Text( - ROLE_LABEL[ws.role] ?: (ws.role ?: ""), - style = MaterialTheme.typography.labelMedium, - color = Accent, - ) + 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("邀请") } + } } } } diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt new file mode 100644 index 0000000..fde612b --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt @@ -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() } + var view by remember { mutableStateOf("today") } + var draft by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var detailId by remember { mutableStateOf(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(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("关闭") } }, + ) + } +} diff --git a/deploy/docker-compose.vector.yml b/deploy/docker-compose.vector.yml new file mode 100644 index 0000000..38b59b6 --- /dev/null +++ b/deploy/docker-compose.vector.yml @@ -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: diff --git a/deploy/postgres/vector-init/01-vector.sql b/deploy/postgres/vector-init/01-vector.sql new file mode 100644 index 0000000..ae58469 --- /dev/null +++ b/deploy/postgres/vector-init/01-vector.sql @@ -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; diff --git a/web/e2e/smoke.mjs b/web/e2e/smoke.mjs new file mode 100644 index 0000000..5591541 --- /dev/null +++ b/web/e2e/smoke.mjs @@ -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); +} diff --git a/web/index.html b/web/index.html index 8e18742..c72aad7 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,12 @@ 达道Task + + +
diff --git a/web/src/App.tsx b/web/src/App.tsx index 9dbc0b3..bbcf645 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/TaskDetail.tsx b/web/src/components/TaskDetail.tsx new file mode 100644 index 0000000..a269905 --- /dev/null +++ b/web/src/components/TaskDetail.tsx @@ -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(null); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [tags, setTags] = useState(""); + const [due, setDue] = useState(""); + const [comments, setComments] = useState([]); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(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(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, + label: string, + ): Promise { + if (!item) return false; + setBusy(true); + setError(null); + try { + const updated = await api.patch(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(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(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(`${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 ( +
+