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:
dadao
2026-10-28 04:05:41 +08:00
parent c4868f04cf
commit 6e33e1024d
13 changed files with 1316 additions and 93 deletions
@@ -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),
)
}
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("邀请") }
}
}
}
}
@@ -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("关闭") } },
)
}
}
+39
View File
@@ -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:
+26
View File
@@ -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;
+126
View File
@@ -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);
}
+6
View File
@@ -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>
+2
View File
@@ -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 />} />
+284
View File
@@ -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>
);
}
+84
View File
@@ -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: "高",
};
+263
View File
@@ -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
View File
@@ -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);
return;
}
void api
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
.then(setDetail)
.catch(() => setDetail(null));
}, [openId, wsId]);
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;
}
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>
);
}