diff --git a/2.Design/API.md b/2.Design/API.md index 50f20cb..64cfa02 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -342,22 +342,30 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后 | `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 | | `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` | | `source_item_id` | 按来源事项筛 | +| `category_id` | 按分类筛,**包含子分类** | +| `uncategorized` | `true` 只看未分类 | +| `tag` | 按标签名筛(不区分大小写) | | `q` | 标题 / 正文 / 摘要子串 | | `limit` | 1–100,默认 30 | | `cursor` | 上一页的 `next_cursor` | -返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`: +返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`: ```json { "id": "…", "kind": "knowledge", "title": "发票原件必须一起寄", - "body_md": "…", "status": "published", "tags": [], + "body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}], "knowledge": { "doc_type": "howto", "summary": "…", "status_hint": "published", "source_kind": "task", "source_item_id": "…", "generated_by": "human", "published_at": "2026-10-28T05:54:44Z", "archived_at": null, "chunk_count": 0, "author_id": "…" }, + "category": { + "id": "…", "parent_id": "…", "name": "回滚", "depth": 3, + "sort_order": 0, "version": 1, + "path": ["研发", "部署", "回滚"], "full_path": "研发 / 部署 / 回滚" + }, "source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"} } ``` @@ -374,19 +382,43 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后 ```json {"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note", + "tag_names": ["财务", "报销"], "category_id": "可选的分类 id", "source_item_id": "可选的来源事项 id"} ``` - 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。 - 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。 +- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。 +- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。 - 新知识一律 `draft`,不会直接对外可见。 ### 5.4 `PATCH /knowledge/{item_id}` — 修改内容 -必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`。 +必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`; +传 `clear_category: true` 表示移到未分类。 **已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。 -### 5.5 `POST /knowledge/{item_id}/status` — 状态动作 +### 5.5 知识分类 `/knowledge/categories` + +分类是工作区级的三级小树,用来给知识安一个位置;它**不是权限边界**,只是归类。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/knowledge/categories` | 扁平列表(按 depth/sort_order 排好),每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` | +| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` | +| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` | +| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 | + +三条规则: + +1. **最多三级**。第四级不是"更细",是"找不到",创建/移动越界一律 `7007`。 +2. **同一层不能重名**(不区分大小写),否则 `7008`;不同层可以同名。 +3. **移动带着子树走**:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(`7007`), + 不静默改层级。 + +`PATCH` 里 `move=true` 才处理 `parent_id`,避免"只改名字"被误当成移动。改名的 `version` 冲突是 `4009`。 + +### 5.6 `POST /knowledge/{item_id}/status` — 状态动作 | action | 允许的前置状态 | 结果 | 谁能做 | |---|---|---|---| @@ -401,7 +433,7 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后 **发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。 -### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识 +### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识 Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。 返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。 @@ -409,7 +441,7 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单; **工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`, 正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。 -### 5.7 检索 `GET /knowledge/search` +### 5.8 检索 `GET /knowledge/search` Query: | 参数 | 说明 | @@ -434,7 +466,7 @@ Query: 2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。 3. **宁可返回空,也不凑数**。检索失败比误导便宜。 -发布(`5.5` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到; +发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到; 但改完正文不会自动重建,需要调用下面这条。 ### `POST /knowledge/{item_id}/reindex` — 重建切片 @@ -447,7 +479,7 @@ Query: 将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后 **必须整体重建**,否则新旧向量混在一张表里,分数没法比较。 -### 5.8 知识的错误码 +### 5.9 知识的错误码 | 码 | HTTP | 含义 | |---|---|---| @@ -456,6 +488,9 @@ Query: | 7003 | 409 | 当前状态不允许这个动作 | | 7004 | 403 | 权限不足(如非管理员发布) | | 7005 | 409 | 来源事项无效或缺失 | +| 7006 | 404 | 分类不存在,或不是本工作区的 | +| 7007 | 422 | 分类层级超过三级,或移动后会让子树超过三级 | +| 7008 | 409 | 分类重名 / 还有子分类 / 分类下还有知识(需 `force`) | --- diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index d9b2e13..3bf1f70 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -291,11 +291,14 @@ | 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` | | 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) | | 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 | +| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` | +| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓大对话框;`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` | ### M3 完成定义(DOD) - [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴 - [x] 每条知识都能追到来源(除手记),点开能看到来源事项 - [x] 非管理员发布被拒;跨租户看不到别人的知识 +- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致) - [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例) - [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队) diff --git a/2.Design/screens/knowledge-categories.png b/2.Design/screens/knowledge-categories.png new file mode 100644 index 0000000..03e003e Binary files /dev/null and b/2.Design/screens/knowledge-categories.png differ diff --git a/2.Design/screens/knowledge-composer.png b/2.Design/screens/knowledge-composer.png new file mode 100644 index 0000000..5d41f43 Binary files /dev/null and b/2.Design/screens/knowledge-composer.png differ 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 97761f5..ee2c9c4 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 @@ -236,6 +236,26 @@ data class SourceRef( val status: String = "", ) +/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */ +@Serializable +data class KnowledgeCategory( + val id: String, + val name: String = "", + @SerialName("parent_id") val parentId: String? = null, + val depth: Int = 1, + @SerialName("sort_order") val sortOrder: Int = 0, + val version: Int = 1, + val path: List = emptyList(), + @SerialName("full_path") val fullPath: String = "", + @SerialName("doc_count") val docCount: Int = 0, +) + +@Serializable +data class KnowledgeCategoryPage( + val items: List = emptyList(), + @SerialName("max_depth") val maxDepth: Int = 3, +) + @Serializable data class KnowledgeDoc( val id: String, @@ -245,6 +265,7 @@ data class KnowledgeDoc( val version: Int = 1, val tags: List = emptyList(), val knowledge: KnowledgeInfo = KnowledgeInfo(), + val category: KnowledgeCategory? = null, val source: SourceRef? = null, val attachments: List = emptyList(), ) @@ -325,6 +346,8 @@ private data class KnowledgeCreateRequest( val title: String, @SerialName("body_md") val bodyMd: String, @SerialName("doc_type") val docType: String, + @SerialName("tag_names") val tagNames: List = emptyList(), + @SerialName("category_id") val categoryId: String? = null, ) @Serializable @@ -351,6 +374,10 @@ private data class KnowledgeUpdateRequest( val version: Int, val title: String? = null, @SerialName("body_md") val bodyMd: String? = null, + @SerialName("tag_names") val tagNames: List? = null, + @SerialName("category_id") val categoryId: String? = null, + // 想把分类清成"未分类"就传 clear_category=true:只传 null 会被服务端当成"不改" + @SerialName("clear_category") val clearCategory: Boolean = false, ) /** @@ -666,20 +693,39 @@ class Api(private val tokens: TokenStore) { workspaceId: String, statuses: List = listOf("draft", "in_review"), query: String? = null, + categoryId: String? = null, + uncategorized: Boolean = false, ): KnowledgePage { val params = buildString { append("?limit=30") statuses.forEach { append("&status=").append(it) } - if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8")) + if (!query.isNullOrBlank()) { + append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8")) + } + if (categoryId != null) append("&category_id=").append(categoryId) + if (uncategorized) append("&uncategorized=true") } return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true) } + /** 工作区三级分类小树(扁平返回,depth 表示层级)。 */ + suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage = + call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true) + /** 手记:没有来源的知识,得自己写清楚。 */ - suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc = + suspend fun createKnowledge( + workspaceId: String, + title: String, + body: String = "", + docType: String = "note", + tags: List = emptyList(), + categoryId: String? = null, + ): KnowledgeDoc = call( path = "/workspaces/$workspaceId/knowledge", - body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")), + body = json.encodeToString( + KnowledgeCreateRequest(title, body, docType, tags, categoryId), + ), auth = true, ) @@ -709,9 +755,14 @@ class Api(private val tokens: TokenStore) { version: Int, title: String? = null, body: String? = null, + tags: List? = null, + categoryId: String? = null, + clearCategory: Boolean = false, ): KnowledgeDoc = call( path = "/workspaces/$workspaceId/knowledge/$itemId", - body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)), + body = json.encodeToString( + KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory), + ), auth = true, method = "PATCH", ) diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt index 58de4c3..34c9855 100644 --- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -1,9 +1,12 @@ package com.dadao.task.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -33,8 +36,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.dadao.task.data.Api import com.dadao.task.data.ApiException +import com.dadao.task.data.KnowledgeCategory import com.dadao.task.data.KnowledgeDoc import com.dadao.task.data.Workspace import kotlinx.coroutines.launch @@ -46,13 +52,26 @@ private val knowledgeTabs = listOf( "all" to "全部", ) -private fun docTypeLabel(type: String): String = when (type) { - "howto" -> "怎么做" - "faq" -> "问答" - "postmortem" -> "复盘" - "decision" -> "决策" - else -> "笔记" -} +private val docTypes = listOf( + "note" to "笔记", + "howto" to "怎么做", + "faq" to "问答", + "postmortem" to "复盘", + "decision" to "决策", +) + +private fun docTypeLabel(type: String): String = + docTypes.firstOrNull { it.first == type }?.second ?: "笔记" + +private fun parseTags(raw: String): List = + raw.split(' ', '\n', ',', ',', '、') + .map { it.trim().removePrefix("#") } + .filter { it.isNotBlank() } + .distinct() + +/** 分类在筛选条里的显示名:用点前缀表达层级,手机上比缩进更稳。 */ +private fun catLabel(cat: KnowledgeCategory): String = + "·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name @Composable fun KnowledgeScreen( @@ -66,12 +85,18 @@ fun KnowledgeScreen( val scope = rememberCoroutineScope() var tab by remember { mutableStateOf("draft") } var docs by remember { mutableStateOf>(emptyList()) } + var categories by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(true) } var message by remember { mutableStateOf(null) } var open by remember { mutableStateOf(null) } var adding by remember { mutableStateOf(false) } - var newTitle by remember { mutableStateOf("") } - var newBody by remember { mutableStateOf("") } + // null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类) + var catFilter by remember { mutableStateOf(null) } + + suspend fun loadCategories() { + runCatching { api.knowledgeCategories(workspace.id) } + .onSuccess { categories = it.items } + } suspend fun reload() { loading = true @@ -81,7 +106,15 @@ fun KnowledgeScreen( "all" -> listOf("draft", "in_review", "published", "archived") else -> listOf("draft", "in_review") } - runCatching { api.knowledge(workspace.id, statuses) } + val filter = catFilter + runCatching { + api.knowledge( + workspace.id, + statuses, + categoryId = if (filter != null && filter != "none") filter else null, + uncategorized = filter == "none", + ) + } .onSuccess { docs = it.items message = null @@ -90,7 +123,8 @@ fun KnowledgeScreen( loading = false } - LaunchedEffect(tab) { reload() } + LaunchedEffect(tab, catFilter) { reload() } + LaunchedEffect(Unit) { loadCategories() } // 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。 LaunchedEffect(reopenItem) { @@ -125,11 +159,30 @@ fun KnowledgeScreen( } } Spacer(Modifier.height(10.dp)) - GhostButton("记一条", onClick = { - adding = true - newTitle = "" - newBody = "" - }) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SurfaceChip( + text = "全部分类", + selected = catFilter == null, + onClick = { catFilter = null }, + ) + SurfaceChip( + text = "未分类", + selected = catFilter == "none", + onClick = { catFilter = "none" }, + ) + categories.forEach { cat -> + SurfaceChip( + text = catLabel(cat), + selected = catFilter == cat.id, + onClick = { catFilter = cat.id }, + ) + } + } + Spacer(Modifier.height(10.dp)) + GhostButton("记一条", onClick = { adding = true }) } } @@ -141,7 +194,7 @@ fun KnowledgeScreen( if (!loading && docs.isEmpty()) { item { EmptyState( - "还没有待核对的知识", + if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识", "把工单验收通过,或在任务详情里点「沉淀成知识」", ) } @@ -165,6 +218,18 @@ fun KnowledgeScreen( fontWeight = FontWeight.Medium, ) Spacer(Modifier.height(6.dp)) + val meta = buildList { + doc.category?.let { add(it.fullPath.ifBlank { it.name }) } + doc.tags.forEach { add("#${it.name}") } + } + if (meta.isNotEmpty()) { + Text( + meta.joinToString(" "), + style = MaterialTheme.typography.labelSmall, + color = Primary, + ) + Spacer(Modifier.height(4.dp)) + } Text( sourceLine(doc), style = MaterialTheme.typography.labelSmall, @@ -182,68 +247,35 @@ fun KnowledgeScreen( api = api, workspace = workspace, doc = target, + categories = categories, onClose = { open = null }, onEdit = onEdit, onChanged = { - scope.launch { reload() } + scope.launch { + reload() + loadCategories() + } }, + onCategoriesChanged = { scope.launch { loadCategories() } }, onFail = { fail(it) }, ) } if (adding) { - AlertDialog( - onDismissRequest = { adding = false }, - title = { Text("记一条", style = MaterialTheme.typography.titleMedium) }, - text = { - Column { - Text( - "手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。", - style = MaterialTheme.typography.bodySmall, - color = InkMuted, - ) - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = newTitle, - onValueChange = { newTitle = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) }, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White, - unfocusedContainerColor = Color.White, - focusedIndicatorColor = Primary, - unfocusedIndicatorColor = Line, - ), - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = newBody, - onValueChange = { newBody = it }, - modifier = Modifier.fillMaxWidth().heightIn(min = 90.dp), - placeholder = { Text("正文(可空,之后再补)", color = InkMuted) }, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White, - unfocusedContainerColor = Color.White, - focusedIndicatorColor = Primary, - unfocusedIndicatorColor = Line, - ), - ) + KnowledgeComposerDialog( + api = api, + workspace = workspace, + categories = categories, + onDismiss = { adding = false }, + onCreated = { created -> + adding = false + scope.launch { + loadCategories() + reload() + open = created } }, - confirmButton = { - TextButton(onClick = { - scope.launch { - adding = false - runCatching { api.createKnowledge(workspace.id, newTitle.trim(), newBody) } - .onSuccess { reload() } - .onFailure { fail(it) } - } - }) { Text("保存草稿") } - }, - dismissButton = { TextButton(onClick = { adding = false }) { Text("取消") } }, - containerColor = Paper, + onFail = { fail(it) }, ) } } @@ -257,18 +289,182 @@ private fun sourceLine(doc: KnowledgeDoc): String { } } +/** + * 新建知识:手机上也要能一次写全(标题/类型/分类/标签/正文)。 + * 用整屏 94% 宽的 Dialog 而不是 AlertDialog —— AlertDialog 的宽度上限装不下一篇备忘录。 + */ +@Composable +private fun KnowledgeComposerDialog( + api: Api, + workspace: Workspace, + categories: List, + onDismiss: () -> Unit, + onCreated: (KnowledgeDoc) -> Unit, + onFail: (Throwable) -> Unit, +) { + val scope = rememberCoroutineScope() + var title by remember { mutableStateOf("") } + var docType by remember { mutableStateOf("note") } + var categoryId by remember { mutableStateOf(null) } + var tags by remember { mutableStateOf("") } + var body by remember { mutableStateOf("") } + var saving by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + Modifier + .fillMaxWidth(0.94f) + .fillMaxHeight(0.88f) + .background(Paper, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "记一条知识", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + GhostButton("取消", onClick = onDismiss) + Spacer(Modifier.width(8.dp)) + PrimaryButton( + "保存", + enabled = !saving && title.isNotBlank(), + onClick = { + scope.launch { + saving = true + runCatching { + api.createKnowledge( + workspace.id, + title.trim(), + body, + docType, + parseTags(tags), + categoryId, + ) + } + .onSuccess { onCreated(it) } + .onFailure { onFail(it) } + saving = false + } + }, + ) + } + Spacer(Modifier.height(12.dp)) + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Spacer(Modifier.height(10.dp)) + SectionLabel("类型") + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + docTypes.forEach { (key, label) -> + SurfaceChip( + text = label, + selected = docType == key, + onClick = { docType = key }, + ) + } + } + Spacer(Modifier.height(10.dp)) + SectionLabel("分类") + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SurfaceChip( + text = "未分类", + selected = categoryId == null, + onClick = { categoryId = null }, + ) + categories.forEach { cat -> + SurfaceChip( + text = catLabel(cat), + selected = categoryId == cat.id, + onClick = { categoryId = cat.id }, + ) + } + } + if (categories.isEmpty()) { + Text( + "还没有分类,Web 端可以建三级分类", + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + modifier = Modifier.padding(top = 4.dp), + ) + } + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Spacer(Modifier.height(10.dp)) + SectionLabel("正文 · Markdown") + OutlinedTextField( + value = body, + onValueChange = { body = it }, + modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp), + placeholder = { Text("背景、做法、结论……写清楚以后自己还能看懂", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Spacer(Modifier.height(12.dp)) + } + } + } +} + @Composable private fun KnowledgeDetailDialog( api: Api, workspace: Workspace, doc: KnowledgeDoc, + categories: List, onClose: () -> Unit, onChanged: () -> Unit, + onCategoriesChanged: () -> Unit, onFail: (Throwable) -> Unit, onEdit: (EditRequest) -> Unit, ) { val scope = rememberCoroutineScope() var action by remember { mutableStateOf(doc.status) } + var version by remember { mutableStateOf(doc.version) } + var categoryId by remember { mutableStateOf(doc.category?.id) } + var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) } + var savingMeta by remember { mutableStateOf(false) } + var metaNotice by remember { mutableStateOf(null) } val actions = when (action) { "draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档") "in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档") @@ -292,15 +488,61 @@ private fun KnowledgeDetailDialog( }, text = { Column( - Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), + Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), ) { Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted) Spacer(Modifier.height(10.dp)) Text( - doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" }, + doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" }, style = MaterialTheme.typography.bodySmall, color = InkSoft, ) + + // 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属" + if (action != "published") { + Spacer(Modifier.height(14.dp)) + SectionLabel("分类") + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SurfaceChip( + text = "未分类", + selected = categoryId == null, + onClick = { categoryId = null }, + ) + categories.forEach { cat -> + SurfaceChip( + text = catLabel(cat), + selected = categoryId == cat.id, + onClick = { categoryId = cat.id }, + ) + } + } + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + if (metaNotice != null) { + Text( + metaNotice.orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = Primary, + modifier = Modifier.padding(top = 6.dp), + ) + } + } } }, confirmButton = { @@ -312,7 +554,7 @@ private fun KnowledgeDetailDialog( itemId = doc.id, title = doc.title, body = doc.bodyMd, - version = doc.version, + version = version, titleEnabled = action == "draft", knowledge = true, ), @@ -320,6 +562,34 @@ private fun KnowledgeDetailDialog( onClose() }, ) { Text("编辑正文") } + if (action != "published") { + TextButton( + enabled = !savingMeta, + onClick = { + scope.launch { + savingMeta = true + runCatching { + api.updateKnowledge( + workspace.id, + doc.id, + version, + tags = parseTags(tags), + categoryId = categoryId, + clearCategory = categoryId == null, + ) + } + .onSuccess { + version = it.version + metaNotice = "分类与标签已保存" + onChanged() + onCategoriesChanged() + } + .onFailure { onFail(it) } + savingMeta = false + } + }, + ) { Text("保存分类标签") } + } actions.forEach { (key, label) -> TextButton(onClick = { scope.launch { diff --git a/backend/alembic/versions/0007_knowledge_categories.py b/backend/alembic/versions/0007_knowledge_categories.py new file mode 100644 index 0000000..e79a476 --- /dev/null +++ b/backend/alembic/versions/0007_knowledge_categories.py @@ -0,0 +1,79 @@ +"""知识分类(M3 补充) + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-10-30 + +知识库的基本形态是"备忘录",不是"任务":一条知识要有标题、正文、标签, +还要能归类。这里补上分类树(一级大类 / 二级 / 三级,深不过三)。 + +两个取舍: + +1. **分类独立成表,不塞进 item**。分类是工作区级的字典,一条知识挂一个分类; + 塞进 item 会让"分类"变成每条知识自己的一份副本,改个名字要改几百行。 +2. **删分类不删知识**。外键是 `ON DELETE SET NULL`:知识退回"未分类", + 分类只是入口,不是知识的容器。真要连内容一起删,那是删知识,得单独确认。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0007" +down_revision: str = "0006" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + +CREATE_SQL = """ +CREATE TABLE knowledge_category ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + parent_id uuid REFERENCES knowledge_category(id) ON DELETE CASCADE, + name varchar(40) NOT NULL, + depth integer NOT NULL DEFAULT 1, + sort_order integer NOT NULL DEFAULT 0, + version integer NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_kcat_depth CHECK (depth BETWEEN 1 AND 3), + CONSTRAINT ck_kcat_name CHECK (length(btrim(name)) > 0) +) +""" + +INDEX_STATEMENTS = ( + "CREATE INDEX ix_kcat_ws_parent ON knowledge_category (workspace_id, parent_id)", + # 同一父级下不许重名。根分类的 parent_id 是 NULL,而 PG 里 NULL 互不相等, + # 所以根要单独一条 `WHERE parent_id IS NULL` 的部分索引,否则可以建出两个同名大类。 + "CREATE UNIQUE INDEX uq_kcat_ws_parent_name ON knowledge_category " + "(workspace_id, parent_id, lower(name)) WHERE parent_id IS NOT NULL", + "CREATE UNIQUE INDEX uq_kcat_ws_root_name ON knowledge_category " + "(workspace_id, lower(name)) WHERE parent_id IS NULL", + "COMMENT ON TABLE knowledge_category IS '知识分类树,深不过三'", + "ALTER TABLE knowledge_doc ADD COLUMN category_id uuid " + "REFERENCES knowledge_category(id) ON DELETE SET NULL", + "CREATE INDEX ix_knowledge_ws_category ON knowledge_doc (workspace_id, category_id)", +) + +POLICY_SQL = ( + "ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY", + "CREATE POLICY knowledge_category_isolation ON knowledge_category " + "USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())", + "GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO dadao_app", +) + + +def upgrade() -> None: + op.execute(CREATE_SQL) + for statement in INDEX_STATEMENTS: + op.execute(statement) + for statement in POLICY_SQL: + op.execute(statement) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_knowledge_ws_category") + op.execute("ALTER TABLE knowledge_doc DROP COLUMN IF EXISTS category_id") + op.execute("DROP POLICY IF EXISTS knowledge_category_isolation ON knowledge_category") + op.execute("DROP TABLE IF EXISTS knowledge_category") diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index 04f16f9..e755c26 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -21,15 +21,21 @@ from typing import Annotated, Any from fastapi import APIRouter, Query from pydantic import BaseModel, Field -from sqlalchemy import Select, and_, or_, select, update +from sqlalchemy import Select, and_, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from ..audit import record as audit_record from ..deps import MemberDep, SessionDep from ..domain.knowledge import can_publish_role, sidebar_fields, target_status from ..errors import AppError, ErrorCode -from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc -from ..models_task import Activity, Item +from ..models_knowledge import ( + DOC_TYPES, + KNOWLEDGE_STATUSES, + MAX_CATEGORY_DEPTH, + KnowledgeCategory, + KnowledgeDoc, +) +from ..models_task import Activity, Item, ItemTag, Tag from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok from ..serializers import item_out, tags_of from ..services.knowledge import create_draft_from_source, find_doc_by_source @@ -47,6 +53,7 @@ class KnowledgeIn(BaseModel): doc_type: str = "note" source_item_id: uuid.UUID | None = None tag_names: list[str] = Field(default_factory=list) + category_id: uuid.UUID | None = None class KnowledgePatch(BaseModel): @@ -55,6 +62,24 @@ class KnowledgePatch(BaseModel): summary: str | None = None doc_type: str | None = None tag_names: list[str] | None = None + category_id: uuid.UUID | None = None + # 单独一个开关:`category_id=None` 既可能表示"没改",也可能表示"清空分类", + # 靠 pydantic 的未传/传 null 区分太隐晦,不如显式给一个。 + clear_category: bool = False + version: int + + +class CategoryIn(BaseModel): + name: str = Field(min_length=1, max_length=40) + parent_id: uuid.UUID | None = None + sort_order: int = 0 + + +class CategoryPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=40) + parent_id: uuid.UUID | None = None + move: bool = False + sort_order: int | None = None version: int @@ -112,6 +137,7 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]: "status_hint": doc.status_hint, "source_kind": doc.source_kind, "source_item_id": str(doc.source_item_id) if doc.source_item_id else None, + "category_id": str(doc.category_id) if doc.category_id else None, "generated_by": doc.generated_by, "published_at": doc.published_at, "archived_at": doc.archived_at, @@ -120,12 +146,96 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]: } +# ── 分类 ──────────────────────────────────────────────────────────────── +async def _categories( + session: AsyncSession, ws_id: uuid.UUID +) -> dict[uuid.UUID, KnowledgeCategory]: + """一次把分类全捞出来。分类是工作区级的小字典(几十条),比按需查祖先便宜。""" + rows = ( + await session.execute( + select(KnowledgeCategory).where(KnowledgeCategory.workspace_id == ws_id) + ) + ).scalars() + return {row.id: row for row in rows} + + +def _category_payload( + cat: KnowledgeCategory, by_id: dict[uuid.UUID, KnowledgeCategory] +) -> dict[str, Any]: + """分类带上完整路径:`研发 / 部署 / 回滚`。客户端不用自己去拼。""" + names: list[str] = [] + node: KnowledgeCategory | None = cat + while node is not None and len(names) < MAX_CATEGORY_DEPTH: + names.append(node.name) + node = by_id.get(node.parent_id) if node.parent_id else None + names.reverse() + return { + "id": str(cat.id), + "parent_id": str(cat.parent_id) if cat.parent_id else None, + "name": cat.name, + "depth": cat.depth, + "sort_order": cat.sort_order, + "version": cat.version, + "path": names, + "full_path": " / ".join(names), + } + + +def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]: + children: dict[uuid.UUID, list[uuid.UUID]] = {} + for cat in by_id.values(): + if cat.parent_id: + children.setdefault(cat.parent_id, []).append(cat.id) + out = [root_id] + stack = [root_id] + while stack: + for child in children.get(stack.pop(), []): + out.append(child) + stack.append(child) + return out + + +async def _resolve_category( + session: AsyncSession, ws_id: uuid.UUID, category_id: uuid.UUID | None +) -> KnowledgeCategory | None: + if category_id is None: + return None + cat = await session.get(KnowledgeCategory, category_id) + if cat is None or cat.workspace_id != ws_id: + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") + return cat + + +async def _category_name_taken( + session: AsyncSession, + ws_id: uuid.UUID, + parent_id: uuid.UUID | None, + name: str, + exclude: uuid.UUID | None = None, +) -> bool: + stmt = select(KnowledgeCategory.id).where( + KnowledgeCategory.workspace_id == ws_id, + func.lower(KnowledgeCategory.name) == name.strip().lower(), + ) + stmt = ( + stmt.where(KnowledgeCategory.parent_id.is_(None)) + if parent_id is None + else stmt.where(KnowledgeCategory.parent_id == parent_id) + ) + if exclude is not None: + stmt = stmt.where(KnowledgeCategory.id != exclude) + return (await session.execute(stmt)).first() is not None + + async def _knowledge_out( session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc ) -> dict[str, Any]: tags = (await tags_of(session, ws_id, [item.id])).get(item.id, []) data = item_out(item, tags) data["knowledge"] = _doc_out(doc) + by_id = await _categories(session, ws_id) + cat = by_id.get(doc.category_id) if doc.category_id else None + data["category"] = _category_payload(cat, by_id) if cat else None data["source"] = await _source_out(session, ws_id, doc.source_item_id) data["attachments"] = await list_for_item(session, ws_id, item.id) data["attachment_count"] = len(data["attachments"]) @@ -148,6 +258,9 @@ async def list_knowledge( status: Annotated[list[str] | None, Query()] = None, doc_type: Annotated[str | None, Query()] = None, source_item_id: Annotated[uuid.UUID | None, Query()] = None, + category_id: Annotated[uuid.UUID | None, Query(description="含子分类")] = None, + uncategorized: Annotated[bool, Query(description="只看未分类")] = False, + tag: Annotated[str | None, Query(max_length=32)] = None, q: Annotated[str | None, Query(max_length=100)] = None, limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30, cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None, @@ -165,6 +278,21 @@ async def list_knowledge( stmt = stmt.where(KnowledgeDoc.doc_type == doc_type) if source_item_id: stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id) + by_id: dict[uuid.UUID, KnowledgeCategory] = {} + if uncategorized: + stmt = stmt.where(KnowledgeDoc.category_id.is_(None)) + elif category_id is not None: + by_id = await _categories(session, ws_id) + cat = by_id.get(category_id) + if cat is None: + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") + stmt = stmt.where(KnowledgeDoc.category_id.in_(_subtree_ids(category_id, by_id))) + if tag: + stmt = ( + stmt.join(ItemTag, ItemTag.item_id == Item.id) + .join(Tag, Tag.id == ItemTag.tag_id) + .where(Tag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower()) + ) if q: like = f"%{q.strip()}%" stmt = stmt.where( @@ -188,12 +316,16 @@ async def list_knowledge( has_more = len(rows) > limit rows = rows[:limit] tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows]) + if not by_id: + by_id = await _categories(session, ws_id) items = [] counts = await count_for_items(session, ws_id, [it.id for it, _ in rows]) for item, doc in rows: data = item_out(item, tag_map.get(item.id, [])) data["knowledge"] = _doc_out(doc) + cat = by_id.get(doc.category_id) if doc.category_id else None + data["category"] = _category_payload(cat, by_id) if cat else None data["source"] = await _source_out(session, ws_id, doc.source_item_id) data["attachment_count"] = counts.get(str(item.id), 0) items.append(data) @@ -204,6 +336,207 @@ async def list_knowledge( return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more}) +# ── 分类的增删改查 ────────────────────────────────────────────────────── +# 路由顺序:`/knowledge/categories` 必须排在 `/knowledge/{item_id}` 之前, +# 否则 "categories" 会被当成 uuid 解析,直接 422。 +@router.get("/knowledge/categories", summary="知识分类") +async def list_categories(ctx: MemberDep, session: SessionDep) -> dict: + by_id = await _categories(session, ctx.ws_id) + counts = dict( + ( + await session.execute( + select(KnowledgeDoc.category_id, func.count()) + .join(Item, Item.id == KnowledgeDoc.item_id) + .where( + KnowledgeDoc.workspace_id == ctx.ws_id, + KnowledgeDoc.category_id.is_not(None), + KnowledgeDoc.status_hint != "archived", + Item.deleted_at.is_(None), + ) + .group_by(KnowledgeDoc.category_id) + ) + ).all() + ) + items = [] + for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)): + data = _category_payload(cat, by_id) + data["doc_count"] = counts.get(cat.id, 0) + items.append(data) + return ok({"items": items, "max_depth": MAX_CATEGORY_DEPTH}) + + +@router.post("/knowledge/categories", summary="新建分类") +async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep) -> dict: + ws_id = ctx.ws_id + depth = 1 + if body.parent_id is not None: + parent = await _resolve_category(session, ws_id, body.parent_id) + depth = parent.depth + 1 + if depth > MAX_CATEGORY_DEPTH: + raise AppError( + ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, + f"分类最多 {MAX_CATEGORY_DEPTH} 级,「{parent.name}」下面不能再建了", + details={"parent_depth": parent.depth}, + ) + name = body.name.strip() + if await _category_name_taken(session, ws_id, body.parent_id, name): + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类") + cat = KnowledgeCategory( + workspace_id=ws_id, + parent_id=body.parent_id, + name=name, + depth=depth, + sort_order=body.sort_order, + ) + session.add(cat) + await session.flush() + await audit_record( + session, + workspace_id=ws_id, + actor_user_id=ctx.user.id, + action="knowledge.category.create", + target_type="knowledge_category", + target_id=str(cat.id), + ) + by_id = await _categories(session, ws_id) + return ok(_category_payload(cat, by_id)) + + +@router.patch("/knowledge/categories/{category_id}", summary="改名或移动分类") +async def patch_category( + category_id: uuid.UUID, body: CategoryPatch, ctx: MemberDep, session: SessionDep +) -> dict: + ws_id = ctx.ws_id + cat = await session.get(KnowledgeCategory, category_id) + if cat is None or cat.workspace_id != ws_id: + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") + by_id = await _categories(session, ws_id) + now = datetime.now(timezone.utc) + + new_parent_id = cat.parent_id + new_depth = cat.depth + shift = 0 + subtree: list[uuid.UUID] = [] + if body.move: + new_parent_id = body.parent_id + if new_parent_id is not None and new_parent_id not in by_id: + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") + if new_parent_id == category_id: + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类挂到自己下面") + subtree = _subtree_ids(category_id, by_id) + if new_parent_id is not None and new_parent_id in subtree: + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类移到它自己的子分类下") + new_depth = by_id[new_parent_id].depth + 1 if new_parent_id else 1 + deepest = max(by_id[cid].depth for cid in subtree) + shift = new_depth - cat.depth + if deepest + shift > MAX_CATEGORY_DEPTH: + raise AppError( + ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, + f"移动后子树最深到第 {deepest + shift} 级,超过 {MAX_CATEGORY_DEPTH} 级", + details={"deepest_after_move": deepest + shift}, + ) + + name = body.name.strip() if body.name is not None else cat.name + if name != cat.name and await _category_name_taken( + session, ws_id, new_parent_id, name, exclude=category_id + ): + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类") + + values: dict[str, Any] = {"name": name, "updated_at": now} + if body.move: + values.update(parent_id=new_parent_id, depth=new_depth) + if body.sort_order is not None: + values["sort_order"] = body.sort_order + + result = await session.execute( + update(KnowledgeCategory) + .where( + KnowledgeCategory.id == category_id, + KnowledgeCategory.workspace_id == ws_id, + KnowledgeCategory.version == body.version, + ) + .values(version=KnowledgeCategory.version + 1, **values) + ) + if result.rowcount == 0: + await session.refresh(cat) + raise AppError( + ErrorCode.VERSION_CONFLICT, + "这个分类刚被改过", + details={"current_version": cat.version}, + ) + + # 子树跟着整体升降;只改被移动的那棵子树以外的行会留下 depth 不一致 + if body.move and shift: + for cid in subtree: + if cid == category_id: + continue + await session.execute( + update(KnowledgeCategory) + .where(KnowledgeCategory.id == cid, KnowledgeCategory.workspace_id == ws_id) + .values( + depth=KnowledgeCategory.depth + shift, + updated_at=now, + version=KnowledgeCategory.version + 1, + ) + ) + await session.flush() + fresh = await _categories(session, ws_id) + return ok(_category_payload(fresh[category_id], fresh)) + + +@router.delete("/knowledge/categories/{category_id}", summary="删除分类") +async def delete_category( + category_id: uuid.UUID, + ctx: MemberDep, + session: SessionDep, + force: Annotated[bool, Query(description="真删:里面的知识变成未分类")] = False, +) -> dict: + ws_id = ctx.ws_id + cat = await session.get(KnowledgeCategory, category_id) + if cat is None or cat.workspace_id != ws_id: + raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") + children = ( + await session.execute( + select(func.count()) + .select_from(KnowledgeCategory) + .where( + KnowledgeCategory.workspace_id == ws_id, + KnowledgeCategory.parent_id == category_id, + ) + ) + ).scalar_one() + if children: + raise AppError( + ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, + f"「{cat.name}」下面还有 {children} 个子分类,先把它们移走或删掉", + details={"children": children}, + ) + docs = ( + await session.execute( + select(func.count()) + .select_from(KnowledgeDoc) + .where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.category_id == category_id) + ) + ).scalar_one() + if docs and not force: + raise AppError( + ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, + f"「{cat.name}」里还有 {docs} 条知识,先移走,或者带 force 删成未分类", + details={"doc_count": docs}, + ) + await session.delete(cat) + await audit_record( + session, + workspace_id=ws_id, + actor_user_id=ctx.user.id, + action="knowledge.category.delete", + target_type="knowledge_category", + target_id=str(category_id), + payload={"orphaned_docs": docs}, + ) + return ok({"deleted": str(category_id), "uncategorized_docs": docs}) + + # 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前, # 否则 "search" 会被当成 uuid 解析,返回 422。 @router.get("/knowledge/search", summary="检索已发布知识") @@ -257,6 +590,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe ErrorCode.KNOWLEDGE_SOURCE_REQUIRED, "来源必须是与本知识同一工作区的任务或工单", ) + category = await _resolve_category(session, ws_id, body.category_id) item = Item( workspace_id=ws_id, @@ -278,6 +612,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe doc_type=body.doc_type, status_hint="draft", summary=body.summary, + category_id=category.id if category else None, generated_by="human", ) session.add(doc) @@ -320,7 +655,10 @@ async def patch_knowledge( if body.doc_type is not None: values["doc_type"] = body.doc_type - if values: + # 分类/标签也改的是这条知识的"样子",所以同样要推进 version: + # 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。 + meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None + if values or meta_changed: values["updated_at"] = datetime.now(timezone.utc) result = await session.execute( update(Item) @@ -344,6 +682,11 @@ async def patch_knowledge( doc.doc_type = body.doc_type if body.summary is not None: doc.summary = body.summary + if body.clear_category: + doc.category_id = None + elif body.category_id is not None: + category = await _resolve_category(session, ws_id, body.category_id) + doc.category_id = category.id if category else None if body.tag_names is not None: await _set_tags_for(session, ws_id, item, body.tag_names) await session.flush() diff --git a/backend/app/errors.py b/backend/app/errors.py index c67a9ec..0f9b2c3 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -56,6 +56,9 @@ class ErrorCode(IntEnum): KNOWLEDGE_STATUS_INVALID = 7003 KNOWLEDGE_FORBIDDEN = 7004 KNOWLEDGE_SOURCE_REQUIRED = 7005 + KNOWLEDGE_CATEGORY_NOT_FOUND = 7006 + KNOWLEDGE_CATEGORY_DEPTH = 7007 + KNOWLEDGE_CATEGORY_CONFLICT = 7008 # 8xxx 附件 ATTACHMENT_NOT_FOUND = 8001 @@ -101,6 +104,9 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.KNOWLEDGE_STATUS_INVALID: 409, ErrorCode.KNOWLEDGE_FORBIDDEN: 403, ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409, + ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND: 404, + ErrorCode.KNOWLEDGE_CATEGORY_DEPTH: 422, + ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT: 409, ErrorCode.ATTACHMENT_NOT_FOUND: 404, ErrorCode.ATTACHMENT_TOO_LARGE: 413, ErrorCode.ATTACHMENT_FORBIDDEN: 403, diff --git a/backend/app/models_knowledge.py b/backend/app/models_knowledge.py index 9b54dda..3771ef9 100644 --- a/backend/app/models_knowledge.py +++ b/backend/app/models_knowledge.py @@ -39,6 +39,36 @@ DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision") SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket") GENERATED_BY: tuple[str, ...] = ("human", "rule") +# 分类树最多三级。再深就不是"归档"而是"迷宫"了:找东西的人记不住第四层叫什么。 +MAX_CATEGORY_DEPTH = 3 + + +class KnowledgeCategory(Base, Timestamps): + """知识分类:一级大类 → 二级 → 三级,深不过三。 + + `depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用 + 递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。 + """ + + __tablename__ = "knowledge_category" + __table_args__ = ( + Index("ix_kcat_ws_parent", "workspace_id", "parent_id"), + CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"), + CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"), + {"comment": "知识分类树,深不过三"}, + ) + + id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + parent_id: Mapped[uuid.UUID | None] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="CASCADE") + ) + name: Mapped[str] = mapped_column(String(40), nullable=False) + depth: Mapped[int] = mapped_column(nullable=False, default=1) + sort_order: Mapped[int] = mapped_column(nullable=False, default=0) + class KnowledgeDoc(Base, Timestamps): """知识的附加信息,与 item 一比一。""" @@ -47,6 +77,7 @@ class KnowledgeDoc(Base, Timestamps): __table_args__ = ( Index("ix_knowledge_ws_status", "workspace_id", "status_hint"), Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"), + Index("ix_knowledge_ws_category", "workspace_id", "category_id"), CheckConstraint( "source_kind = 'manual' OR source_item_id IS NOT NULL", name="ck_knowledge_source_required", @@ -72,6 +103,10 @@ class KnowledgeDoc(Base, Timestamps): doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note") # 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表 status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft") + # 分类可以为空("未分类");分类被删时知识不跟着消失,只是回到未分类 + category_id: Mapped[uuid.UUID | None] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="SET NULL") + ) summary: Mapped[str] = mapped_column(Text, nullable=False, default="") generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human") published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) @@ -109,4 +144,4 @@ class Chunk(Base): embed_model: Mapped[str | None] = mapped_column(String(64)) -KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc", "chunk") +KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_category", "knowledge_doc", "chunk") diff --git a/backend/tests/test_knowledge_categories.py b/backend/tests/test_knowledge_categories.py new file mode 100644 index 0000000..c3f0bf8 --- /dev/null +++ b/backend/tests/test_knowledge_categories.py @@ -0,0 +1,280 @@ +"""知识库的"备忘录"形态:分类树、正文、标签、筛选。 + +知识最容易做坏的地方,是把它做成"只填标题的任务"——那样的库三个月后没人会打开。 +所以这里断言的是:一条知识能装下正文和标签、能被归到三级分类里、能按分类和标签找回来。 + +三条边界单独盯着: + +1. **三级到底**。第四级不是"更细",是"找不到";越界必须当场拒绝(7007)。 +2. **移动会带着子树一起动**。把一棵三层树挪到第二层下面,结果会是第五层, + 这种操作要么重算层级、要么拒绝——这里选择拒绝,因为静默改层级同样会让人找不到。 +3. **删分类不删知识**。有内容时必须显式 `force`,否则容易点一下就把别人的整理工作打散。 +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from httpx import AsyncClient + +from .conftest import create_workspace, login, unwrap + +V1 = "/api/v1" + + +def _code(resp: Any) -> int: + return int(resp.json().get("code", 0)) + + +async def _ws(client: AsyncClient) -> tuple[dict[str, Any], str]: + user = await login(client) + ws = await create_workspace(client, user, "知识库") + return user, ws["id"] + + +async def _category( + client: AsyncClient, user: dict[str, Any], ws_id: str, name: str, parent_id: str | None = None +) -> dict[str, Any]: + return unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/knowledge/categories", + json={"name": name, "parent_id": parent_id}, + headers=user["headers"], + ) + ) + + +async def _note( + client: AsyncClient, + user: dict[str, Any], + ws_id: str, + title: str, + *, + body: str = "", + tags: list[str] | None = None, + category_id: str | None = None, +) -> dict[str, Any]: + return unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/knowledge", + json={ + "title": title, + "body_md": body, + "doc_type": "note", + "tag_names": tags or [], + "category_id": category_id, + }, + headers=user["headers"], + ) + ) + + +async def _list( + client: AsyncClient, user: dict[str, Any], ws_id: str, **params: Any +) -> list[dict[str, Any]]: + return unwrap( + await client.get( + f"{V1}/workspaces/{ws_id}/knowledge", params=params, headers=user["headers"] + ) + )["items"] + + +@pytest.mark.asyncio +async def test_note_keeps_body_tags_and_category_path(client: AsyncClient) -> None: + user, ws_id = await _ws(client) + root = await _category(client, user, ws_id, "研发") + child = await _category(client, user, ws_id, "部署", root["id"]) + leaf = await _category(client, user, ws_id, "回滚", child["id"]) + assert leaf["depth"] == 3 + assert leaf["full_path"] == "研发 / 部署 / 回滚" + + doc = await _note( + client, + user, + ws_id, + "回滚前先停掉灰度", + body="# 步骤\n\n1. 停灰度\n2. 回滚镜像\n", + tags=["发布", "事故"], + category_id=leaf["id"], + ) + assert doc["category"]["id"] == leaf["id"] + assert doc["category"]["full_path"] == "研发 / 部署 / 回滚" + assert {t["name"] for t in doc["tags"]} == {"发布", "事故"} + + detail = unwrap( + await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"]) + ) + assert "停灰度" in detail["body_md"] + assert detail["category"]["full_path"] == "研发 / 部署 / 回滚" + + +@pytest.mark.asyncio +async def test_category_depth_stops_at_three(client: AsyncClient) -> None: + user, ws_id = await _ws(client) + l1 = await _category(client, user, ws_id, "一级") + l2 = await _category(client, user, ws_id, "二级", l1["id"]) + l3 = await _category(client, user, ws_id, "三级", l2["id"]) + resp = await client.post( + f"{V1}/workspaces/{ws_id}/knowledge/categories", + json={"name": "四级", "parent_id": l3["id"]}, + headers=user["headers"], + ) + assert resp.status_code == 422, resp.text + assert _code(resp) == 7007 + + +@pytest.mark.asyncio +async def test_move_cannot_exceed_depth_or_loop(client: AsyncClient) -> None: + user, ws_id = await _ws(client) + a = await _category(client, user, ws_id, "A") + b = await _category(client, user, ws_id, "B", a["id"]) + c = await _category(client, user, ws_id, "C", b["id"]) + other = await _category(client, user, ws_id, "X") + + # 把 A(下面还有 B、C)移到 C 下面:自己是自己的祖先 + resp = await client.patch( + f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}", + json={"move": True, "parent_id": c["id"], "version": a["version"]}, + headers=user["headers"], + ) + assert resp.status_code in (409, 422), resp.text + assert _code(resp) == 7007 + + # 把 A 移到 X 下面:A 现在是一级,B、C 跟着下沉,C 会到第四级 -> 拒绝 + resp = await client.patch( + f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}", + json={"move": True, "parent_id": other["id"], "version": a["version"]}, + headers=user["headers"], + ) + assert _code(resp) == 7007 + del c, b + + +@pytest.mark.asyncio +async def test_rename_conflict_and_move_to_root(client: AsyncClient) -> None: + user, ws_id = await _ws(client) + a = await _category(client, user, ws_id, "研发") + b = await _category(client, user, ws_id, "市场") + dup = await client.post( + f"{V1}/workspaces/{ws_id}/knowledge/categories", + json={"name": "研发"}, + headers=user["headers"], + ) + assert dup.status_code == 409, dup.text + assert _code(dup) == 7008 + + child = await _category(client, user, ws_id, "部署", a["id"]) + moved = unwrap( + await client.patch( + f"{V1}/workspaces/{ws_id}/knowledge/categories/{child['id']}", + json={"move": True, "parent_id": None, "version": child["version"]}, + headers=user["headers"], + ) + ) + assert moved["parent_id"] is None + assert moved["depth"] == 1 + assert moved["full_path"] == "部署" + del b + + +@pytest.mark.asyncio +async def test_list_filters_by_category_subtree_tag_and_search(client: AsyncClient) -> None: + user, ws_id = await _ws(client) + root = await _category(client, user, ws_id, "研发") + sub = await _category(client, user, ws_id, "部署", root["id"]) + other = await _category(client, user, ws_id, "市场") + + deep = await _note( + client, + user, + ws_id, + "灰度发布流程", + body="先停灰度再回滚", + tags=["发布"], + category_id=sub["id"], + ) + await _note( + client, user, ws_id, "季度预算", body="市场预算表", tags=["财务"], category_id=other["id"] + ) + loose = await _note(client, user, ws_id, "散记", body="还没归类", tags=["草稿"]) + + by_category = await _list(client, user, ws_id, category_id=root["id"]) + assert {d["id"] for d in by_category} == {deep["id"]} + by_none = await _list(client, user, ws_id, uncategorized="true") + assert {d["id"] for d in by_none} == {loose["id"]} + assert {d["id"] for d in await _list(client, user, ws_id, tag="发布")} == {deep["id"]} + assert {d["id"] for d in await _list(client, user, ws_id, q="回滚")} == {deep["id"]} + + +@pytest.mark.asyncio +async def test_delete_category_needs_force_when_docs_exist(client: AsyncClient) -> None: + user, ws_id = await _ws(client) + cat = await _category(client, user, ws_id, "待清理") + doc = await _note(client, user, ws_id, "有主的东西", category_id=cat["id"]) + + blocked = await client.delete( + f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}", headers=user["headers"] + ) + assert blocked.status_code == 409, blocked.text + assert _code(blocked) == 7008 + + forced = unwrap( + await client.delete( + f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}?force=true", + headers=user["headers"], + ) + ) + assert forced["uncategorized_docs"] == 1 + detail = unwrap( + await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"]) + ) + assert detail["category"] is None + + +@pytest.mark.asyncio +async def test_category_is_tenant_scoped(client: AsyncClient) -> None: + owner, ws_id = await _ws(client) + intruder = await login(client) + other_ws = await create_workspace(client, intruder, "别人的工作区") + foreign = await _category(client, intruder, other_ws["id"], "他们的分类") + + listed = unwrap( + await client.get(f"{V1}/workspaces/{ws_id}/knowledge/categories", headers=owner["headers"]) + ) + assert listed["items"] == [] + + # 用别的工作区的分类 id 建知识:应被当作"不存在" + resp = await client.post( + f"{V1}/workspaces/{ws_id}/knowledge", + json={"title": "越界", "category_id": foreign["id"]}, + headers=owner["headers"], + ) + assert resp.status_code == 404, resp.text + assert _code(resp) == 7006 + + +@pytest.mark.asyncio +async def test_patch_knowledge_moves_and_clears_category(client: AsyncClient) -> None: + user, ws_id = await _ws(client) + cat = await _category(client, user, ws_id, "研发") + doc = await _note(client, user, ws_id, "一篇知识", category_id=cat["id"]) + + moved = unwrap( + await client.patch( + f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", + json={"category_id": cat["id"], "tag_names": ["运维"], "version": doc["version"]}, + headers=user["headers"], + ) + ) + assert moved["category"]["name"] == "研发" + assert {t["name"] for t in moved["tags"]} == {"运维"} + + cleared = unwrap( + await client.patch( + f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", + json={"clear_category": True, "version": moved["version"]}, + headers=user["headers"], + ) + ) + assert cleared["category"] is None diff --git a/web/e2e/knowledge-notebook.mjs b/web/e2e/knowledge-notebook.mjs new file mode 100644 index 0000000..769d94d --- /dev/null +++ b/web/e2e/knowledge-notebook.mjs @@ -0,0 +1,201 @@ +/** + * 知识备忘录形态 e2e:分类树 + 新建时一次填全(标题/类型/分类/标签/正文)+ 按分类筛。 + * + * 与 knowledge.mjs 的分工:那个脚本验"沉淀 → 发布"闭环,这个只验"知识是最像备忘录的形态"。 + * 前面造数据走接口,界面部分点真实按钮。 + */ +import { chromium } from "playwright-core"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; +const stamp = String(Date.now()).slice(-7); +const phone = `137${stamp}0`; + +let failures = 0; +function check(name, ok, extra = "") { + console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`); + if (!ok) failures += 1; +} + +async function call(method, path, body, token) { + const res = await fetch(`${API}${path}`, { + method, + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : {}; + if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`); + return json.data; +} + +const browser = await chromium.launch({ + executablePath: + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`, +}); +const consoleErrors = []; +const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); +const page = await context.newPage(); +page.on("console", (m) => { + if (m.type() === "error") consoleErrors.push(m.text()); +}); +page.on("response", (r) => { + if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`); +}); + +try { + await call("POST", "/auth/sms/send", { phone }); + const login = await call("POST", "/auth/login", { phone, code: "123456" }); + const token = login.tokens.access_token; + const ws = await call("POST", "/workspaces", { name: `知识备忘 ${stamp}` }, token); + + await page.addInitScript( + ([access, refresh]) => { + localStorage.setItem("dadao.access", access); + localStorage.setItem("dadao.refresh", refresh); + }, + [token, login.tokens.refresh_token], + ); + await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 10_000 }); + + // ① 建两级分类:研发 / 部署 + await page.getByRole("button", { name: "+ 新建", exact: true }).click(); + await page.getByLabel("新分类名").fill("研发"); + await page.getByRole("button", { name: "创建", exact: true }).click(); + await page.getByRole("button", { name: /^研发/, exact: false }).first().waitFor(); + await page.getByRole("button", { name: "+ 新建", exact: true }).click(); + await page.getByLabel("新分类名").fill("部署"); + await page.getByLabel("父分类").selectOption({ label: "研发 下" }); + await page.getByRole("button", { name: "创建", exact: true }).click(); + await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor(); + check("能在侧栏建出「研发 / 部署」两级分类", true); + + // ② 记一条:一个面板里填完标题/类型/分类/标签/正文 + await page.getByRole("button", { name: "记一条", exact: true }).click(); + await page.getByLabel("标题").fill("回滚前先停灰度"); + await page.getByLabel("类型").selectOption("howto"); + await page.getByLabel("分类", { exact: true }).selectOption({ label: "研发 / 部署" }); + await page.getByLabel("标签").fill("发布 事故"); + await page + .getByLabel("正文") + .fill("## 步骤\n1. 停掉灰度流量\n2. 回滚镜像到上一个 tag\n3. 验证关键接口"); + await page.getByRole("button", { name: "保存并继续写", exact: true }).click(); + + // 保存后直接打开详情:标题、分类路径、标签都在 + await page.getByText("回滚前先停灰度", { exact: false }).first().waitFor({ timeout: 10_000 }); + const drawerText = (await page.textContent("body")) ?? ""; + check( + "新建面板能把分类与标签一起存下来", + drawerText.includes("研发 / 部署") && drawerText.includes("发布"), + ); + await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false }); + + // ③ 列表卡片上能看到分类路径与标签(不用点开) + await page.getByRole("button", { name: "关闭", exact: true }).click(); + const card = page.locator("li").filter({ hasText: "回滚前先停灰度" }).first(); + await card.waitFor({ timeout: 10_000 }); + const cardText = (await card.textContent()) ?? ""; + check("列表卡片带分类路径与标签", cardText.includes("研发 / 部署") && cardText.includes("#发布")); + + // ④ 侧栏按分类筛:部署里有,未分类里没有 + await page.getByRole("button", { name: /^部署/, exact: false }).first().click(); + await page.waitForTimeout(400); + check( + "点「部署」能看到归属该分类的知识", + await page.getByText("回滚前先停灰度", { exact: false }).first().isVisible(), + ); + await page.getByRole("button", { name: "未分类", exact: true }).click(); + await page.waitForTimeout(400); + const uncategorizedHasCard = await page + .getByText("回滚前先停灰度", { exact: false }) + .first() + .isVisible() + .catch(() => false); + check("「未分类」里不出现已归类的知识", !uncategorizedHasCard); + await page.screenshot({ path: "e2e/artifacts/knowledge-category-filter.png", fullPage: false }); + + // ⑤ 抽屉里能改标签与分类,改完接口里就是新的 + await page.getByRole("button", { name: "全部知识", exact: true }).click(); + // 先等抽屉自己把详情拉回来再填:GET 比手快一步的话,刚填的字会被它覆盖掉 + await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes("/knowledge/") && + r.request().method() === "GET" && + !r.url().endsWith("/knowledge/categories") && + r.status() === 200, + ), + page.getByRole("button", { name: "打开", exact: true }).first().click(), + ]); + const tagInput = page.getByLabel("标签", { exact: true }).last(); + await tagInput.waitFor({ timeout: 10_000 }); + // 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入 + await page + .waitForFunction( + () => { + const el = document.querySelector('input[aria-label="标签"]'); + return !!el && el.value.includes("事故"); + }, + null, + { timeout: 10_000 }, + ) + .catch(() => {}); + // 用真实键盘输入而不是 fill:fill 走原生 setter,React 的受控 input 有时收不到这次变更 + await tagInput.click(); + await tagInput.press("Control+A"); + await page.keyboard.type("发布 回滚", { delay: 20 }); + check("抽屉里的标签输入框拿到新值", (await tagInput.inputValue()) === "发布 回滚"); + const [patchResp] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes("/knowledge/") && + r.request().method() === "PATCH" && + r.status() === 200, + ), + page.getByRole("button", { name: "保存", exact: true }).last().click(), + ]); + check("抽屉里保存标签/分类成功", patchResp.ok()); + + const list = await call( + "GET", + `/workspaces/${ws.id}/knowledge?status=draft&status=in_review`, + undefined, + token, + ); + const doc = list.items.find((d) => d.title === "回滚前先停灰度"); + check( + "接口里标签已更新、分类是「研发 / 部署」", + String((doc?.tags ?? []).map((t) => t.name).join(" ")).includes("回滚") && + doc?.category?.full_path === "研发 / 部署", + `tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`, + ); + check("正文按 Markdown 存下来了", String(doc?.body_md ?? "").includes("停掉灰度流量")); + + // ⑥ 分类重名与三级上限:接口层面确认约束还在 + const dup = await fetch( + `${API}/workspaces/${ws.id}/knowledge/categories`, + { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: "部署", parent_id: doc.category.parent_id }), + }, + ).then((r) => r.json()); + check("同层重名被拒(7008)", dup.code === 7008, `code=${dup.code}`); + + check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); +} catch (err) { + failures += 1; + console.log(`FAIL 脚本异常 — ${err.message}`); + await page.screenshot({ path: "e2e/artifacts/knowledge-notebook-failure.png" }).catch(() => {}); + console.log((await page.textContent("body"))?.slice(0, 600)); +} finally { + await browser.close(); + console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`); + process.exit(failures === 0 ? 0 : 1); +} diff --git a/web/src/components/TaskDetail.tsx b/web/src/components/TaskDetail.tsx index 34b181a..21d8f7b 100644 --- a/web/src/components/TaskDetail.tsx +++ b/web/src/components/TaskDetail.tsx @@ -353,7 +353,7 @@ export function TaskDetail({ ( ); +/** 放大到整屏 / 收回原来的宽度:长文和图片需要有地方放 */ +export const Expand = (p: IconProps) => ( + + + +); + +export const Collapse = (p: IconProps) => ( + + + +); + export const More = (p: IconProps) => ( diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index f8a61f2..63347b8 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -8,7 +8,17 @@ import { useState, type ReactNode, } from "react"; -import { Check, X } from "./icons"; +import { Check, Collapse, Expand, X } from "./icons"; + +/** + * 抽屉宽度档位。以前的抽屉默认 `max-w-xl`(576px),写长文像在门缝里写字; + * 知识、工单这类要贴图的内容统一走 xl,上面还有"展开整屏"。 + */ +const SHEET_SIZE = { + md: "max-w-2xl", + lg: "max-w-4xl", + xl: "max-w-6xl", +} as const; /* ——————————————————————————— 按钮 ——————————————————————————— */ @@ -267,7 +277,8 @@ export function Sheet({ subtitle, children, footer, - width = "max-w-xl", + size = "lg", + width, }: { open: boolean; onClose: () => void; @@ -275,8 +286,14 @@ export function Sheet({ subtitle?: ReactNode; children: ReactNode; footer?: ReactNode; + /** 宽度档位;长文/多图用 xl,右上方还能一键放满整屏 */ + size?: "md" | "lg" | "xl"; + /** 兜底:直接给 Tailwind 宽度类 */ width?: string; }) { + const [expanded, setExpanded] = useState(false); + const sizeClass = width ?? SHEET_SIZE[size]; + useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { @@ -298,17 +315,31 @@ export function Sheet({ role="dialog" aria-modal="true" aria-label={typeof title === "string" ? title : "详情"} - className={`relative flex h-full w-full ${width} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`} + className={`relative flex h-full w-full ${ + expanded ? "max-w-none" : sizeClass + } animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`} >
{title}
{subtitle ?
{subtitle}
: null}
- +
+ + +
{children}
{footer ? ( diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3fc5eca..6ed7e02 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -150,8 +150,25 @@ export interface KnowledgeSource { status: string; } +export interface KnowledgeCategory { + id: string; + parent_id: string | null; + name: string; + depth: number; + sort_order: number; + version: number; + /** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */ + path: string[]; + full_path: string; +} + +export interface KnowledgeCategoryNode extends KnowledgeCategory { + doc_count: number; +} + export interface Knowledge extends Item { knowledge: KnowledgeInfo; + category: KnowledgeCategory | null; source: KnowledgeSource | null; attachments?: Attachment[]; attachment_count?: number; diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx index 930ce5c..cbf45c1 100644 --- a/web/src/pages/Knowledge.tsx +++ b/web/src/pages/Knowledge.tsx @@ -3,7 +3,7 @@ import { useParams } from "react-router-dom"; import { AppShell } from "../components/AppShell"; import { AttachmentPanel } from "../components/AttachmentPanel"; import { RichTextEditor } from "../components/RichTextEditor"; -import { Book, Link2, Plus, Search, Sparkle } from "../components/icons"; +import { Link2, Plus, Search, Sparkle } from "../components/icons"; import { Button, EmptyState, @@ -14,7 +14,13 @@ import { StatusPill, } from "../components/ui"; import { ApiError, api } from "../lib/client"; -import type { Attachment, Knowledge, KnowledgeStatus, SearchHit } from "../lib/types"; +import type { + Attachment, + Knowledge, + KnowledgeCategoryNode, + KnowledgeStatus, + SearchHit, +} from "../lib/types"; const TABS = [ { key: "draft", label: "待核对", statuses: ["draft", "in_review"] }, @@ -61,12 +67,48 @@ export function KnowledgePage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); - const [adding, setAdding] = useState(false); - const [draftTitle, setDraftTitle] = useState(""); + const [composing, setComposing] = useState(false); const [hits, setHits] = useState(null); const [searching, setSearching] = useState(false); + /** null = 不按分类筛(全部);"none" = 未分类;其它是分类 id(含子分类) */ + const [catFilter, setCatFilter] = useState(null); + const [categories, setCategories] = useState([]); + const [catBusy, setCatBusy] = useState(false); const reqId = useRef(0); + const loadCategories = useCallback(async () => { + try { + const page = await api.get<{ items: KnowledgeCategoryNode[] }>( + `/workspaces/${wsId}/knowledge/categories`, + ); + setCategories(page.items); + } catch (err) { + setError(err instanceof ApiError ? err.message : "分类加载失败"); + } + }, [wsId]); + + useEffect(() => { + void loadCategories(); + }, [loadCategories]); + + async function createCategory(name: string, parentId: string | null) { + setCatBusy(true); + setError(null); + try { + const created = await api.post( + `/workspaces/${wsId}/knowledge/categories`, + { name, parent_id: parentId }, + ); + await loadCategories(); + return created; + } catch (err) { + setError(err instanceof ApiError ? err.message : "分类创建失败"); + return null; + } finally { + setCatBusy(false); + } + } + const load = useCallback( async (nextCursor: string | null) => { // 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果 @@ -78,6 +120,8 @@ export function KnowledgePage() { const statuses = TABS.find((t) => t.key === tab)!.statuses; for (const s of statuses) qs.append("status", s); if (q.trim()) qs.set("q", q.trim()); + if (catFilter === "none") qs.set("uncategorized", "true"); + else if (catFilter) qs.set("category_id", catFilter); if (nextCursor) qs.set("cursor", nextCursor); const page = await api.get<{ items: Knowledge[]; @@ -94,7 +138,7 @@ export function KnowledgePage() { if (mine === reqId.current) setLoading(false); } }, - [wsId, tab, q], + [wsId, tab, q, catFilter], ); useEffect(() => { @@ -127,22 +171,13 @@ export function KnowledgePage() { } } - async function addNote() { - const title = draftTitle.trim(); - if (!title) return; - try { - const doc = await api.post(`/workspaces/${wsId}/knowledge`, { - title, - doc_type: "note", - }); - setDraftTitle(""); - setAdding(false); - setTab("draft"); - setOpenId(doc.id); - void load(null); - } catch (err) { - setError(err instanceof ApiError ? err.message : "保存失败"); - } + function onCreated(doc: Knowledge) { + setComposing(false); + setTab("draft"); + setCatFilter(null); + setOpenId(doc.id); + void load(null); + void loadCategories(); } const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length; @@ -176,7 +211,7 @@ export function KnowledgePage() { {searching ? "检索中…" : "检索"} - @@ -184,6 +219,18 @@ export function KnowledgePage() { } /> +
+ { + setCatFilter(value); + setHits(null); + }} + busy={catBusy} + onCreate={(name, parentId) => void createCategory(name, parentId)} + /> +
{TABS.map((t) => { const active = tab === t.key; @@ -208,34 +255,6 @@ export function KnowledgePage() { ) : null}
- {adding && ( -
-
- - - -

- 手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。 -

-
-
- setDraftTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void addNote(); - }} - /> - -
-
- )} - {error && (
{error} @@ -327,6 +346,11 @@ export function KnowledgePage() { 自动整理 ) : null} + {d.category ? ( + + {d.category.full_path} + + ) : null}

{d.title} @@ -339,6 +363,16 @@ export function KnowledgePage() { {d.source ? 「{d.source.title}」 : null} {new Date(d.updated_at).toLocaleString("zh-CN")}

+ {/* 标签是备忘录的检索入口,卡片上就露出来,不用点开才知道归在哪 */} + {d.tags.length > 0 ? ( +
+ {d.tags.slice(0, 5).map((t) => ( + + #{t.name} + + ))} +
+ ) : null}
)} +
+ + + {composing && ( + setComposing(false)} + onCreated={onCreated} + /> + )} {openId && ( setOpenId(null)} - onChanged={() => void load(null)} + onChanged={() => { + void load(null); + void loadCategories(); + }} /> )} ); } +/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */ +function CategorySidebar({ + categories, + selected, + onSelect, + busy, + onCreate, +}: { + categories: KnowledgeCategoryNode[]; + selected: string | null; + onSelect: (value: string | null) => void; + busy: boolean; + onCreate: (name: string, parentId: string | null) => void; +}) { + const [adding, setAdding] = useState(false); + const [name, setName] = useState(""); + const [parentId, setParentId] = useState(""); + + return ( + + ); +} + +function CategoryButton({ + active, + indent = 0, + onClick, + children, +}: { + active: boolean; + indent?: number; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** 新建知识:标题、类型、分类、标签、正文一起填,不再是「输入标题就算完」。 */ +function KnowledgeComposer({ + wsId, + categories, + catBusy, + onCreateCategory, + onClose, + onCreated, +}: { + wsId: string; + categories: KnowledgeCategoryNode[]; + catBusy: boolean; + onCreateCategory: (name: string, parentId: string | null) => Promise; + onClose: () => void; + onCreated: (doc: Knowledge) => void; +}) { + const [title, setTitle] = useState(""); + const [docType, setDocType] = useState("note"); + const [body, setBody] = useState(""); + const [tags, setTags] = useState(""); + const [categoryId, setCategoryId] = useState(""); + const [newCat, setNewCat] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0; + const canAddCategory = parentDepth < 3 && Boolean(newCat.trim()); + const canSave = Boolean(title.trim()) && !busy; + + async function addCategory() { + const name = newCat.trim(); + if (!name || !canAddCategory) return; + const created = await onCreateCategory(name, categoryId || null); + if (created) { + setCategoryId(created.id); + setNewCat(""); + } + } + + async function save() { + if (!canSave) return; + setBusy(true); + setError(null); + try { + const doc = await api.post(`/workspaces/${wsId}/knowledge`, { + title: title.trim(), + body_md: body, + doc_type: docType, + tag_names: tags.split(/[\s,,]+/).filter(Boolean), + category_id: categoryId || null, + }); + onCreated(doc); + } catch (err) { + setError(err instanceof ApiError ? err.message : "保存失败"); + } finally { + setBusy(false); + } + } + + return ( + + + + + 先存成草稿,核对发布后才进团队检索 + + + } + > +
+ + +
+ + +
+ +
+ 在当前分类下新建子分类(最多三级) +
+ = 3 ? "已经到第三级了" : "子分类名"} + value={newCat} + disabled={parentDepth >= 3} + onChange={(e) => setNewCat(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void addCategory(); + } + }} + aria-label="新建子分类" + /> + +
+
+ + + +