feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s

知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用
  `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、
  RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE
  SET NULL。
- 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、
  跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树
  一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。
- 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带
  `category`(含 `path` / `full_path`)。
- PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞
  乐观锁 `4009`。

Web
- `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一
  加宽(`TaskDetail` / `Tickets` / `Knowledge`)。
- 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer`
  一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类
  与标签。
- 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。

安卓
- `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、
  列表带分类与标签筛选。
- 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表
  顶部按分类筛选,详情里能改分类与标签后单独保存。

文档与测试
- `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节,
  错误码补 `7006`/`7007`/`7008`。
- `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。
- 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、
  三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需
  `force`、跨租户隔离。
- 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次
  写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。

验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、
`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。
安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
This commit is contained in:
Steven
2026-10-30 03:51:43 +08:00
parent 91b3dde670
commit ed31af3f46
18 changed files with 1932 additions and 164 deletions
+43 -8
View File
@@ -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`) |
---
+3
View File
@@ -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 仍待独立实例)
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

@@ -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<String> = emptyList(),
@SerialName("full_path") val fullPath: String = "",
@SerialName("doc_count") val docCount: Int = 0,
)
@Serializable
data class KnowledgeCategoryPage(
val items: List<KnowledgeCategory> = 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<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(),
val category: KnowledgeCategory? = null,
val source: SourceRef? = null,
val attachments: List<Attachment> = 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<String> = 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<String>? = 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<String> = 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<String> = 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<String>? = 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",
)
@@ -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<String> =
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<List<KnowledgeDoc>>(emptyList()) }
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var message by remember { mutableStateOf<String?>(null) }
var open by remember { mutableStateOf<KnowledgeDoc?>(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<String?>(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<KnowledgeCategory>,
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<String?>(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<KnowledgeCategory>,
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<String?>(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 {
@@ -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")
+347 -4
View File
@@ -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()
+6
View File
@@ -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,
+36 -1
View File
@@ -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")
+280
View File
@@ -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
+201
View File
@@ -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);
}
+1 -1
View File
@@ -353,7 +353,7 @@ export function TaskDetail({
<Sheet
open
onClose={onClose}
width="max-w-2xl"
size="xl"
title={item ? item.title : "打开中…"}
subtitle={meta}
footer={
+13
View File
@@ -234,6 +234,19 @@ export const Link2 = (p: IconProps) => (
</svg>
);
/** 放大到整屏 / 收回原来的宽度:长文和图片需要有地方放 */
export const Expand = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
</svg>
);
export const Collapse = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5" />
</svg>
);
export const More = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
+38 -7
View File
@@ -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`}
>
<header className="flex items-start justify-between gap-3 border-b border-line-100 px-6 py-4">
<div className="min-w-0">
<div className="text-base font-semibold text-ink-900">{title}</div>
{subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
</div>
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
<X size={15} />
关闭
</Button>
<div className="flex shrink-0 items-center gap-1.5">
<Button
variant="subtle"
size="sm"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "收起到侧栏" : "展开整屏"}
title={expanded ? "收起到侧栏" : "展开整屏"}
>
{expanded ? <Collapse size={15} /> : <Expand size={15} />}
<span className="hidden sm:inline">{expanded ? "收起" : "展开"}</span>
</Button>
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
<X size={15} />
关闭
</Button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
{footer ? (
+17
View File
@@ -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;
+471 -68
View File
@@ -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<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [composing, setComposing] = useState(false);
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [searching, setSearching] = useState(false);
/** null = 不按分类筛(全部);"none" = 未分类;其它是分类 id(含子分类) */
const [catFilter, setCatFilter] = useState<string | null>(null);
const [categories, setCategories] = useState<KnowledgeCategoryNode[]>([]);
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<KnowledgeCategoryNode>(
`/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<Knowledge>(`/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() {
<Search size={14} />
{searching ? "检索中…" : "检索"}
</Button>
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
<Button variant="primary" onClick={() => setComposing(true)}>
<Plus size={14} />
记一条
</Button>
@@ -184,6 +219,18 @@ export function KnowledgePage() {
}
/>
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<CategorySidebar
categories={categories}
selected={catFilter}
onSelect={(value) => {
setCatFilter(value);
setHits(null);
}}
busy={catBusy}
onCreate={(name, parentId) => void createCategory(name, parentId)}
/>
<div className="min-w-0">
<div className="mb-4 flex flex-wrap items-center gap-1.5">
{TABS.map((t) => {
const active = tab === t.key;
@@ -208,34 +255,6 @@ export function KnowledgePage() {
) : null}
</div>
{adding && (
<div className="card mb-4 p-4">
<div className="flex items-start gap-2 text-xs text-ink-500">
<span className="mt-0.5 text-clay-500">
<Book size={14} />
</span>
<p className="leading-relaxed">
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
</p>
</div>
<div className="mt-3 flex gap-2">
<input
className="input flex-1"
placeholder="一句话标题,比如「报销单要贴发票原件」"
value={draftTitle}
autoFocus
onChange={(e) => setDraftTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addNote();
}}
/>
<Button variant="primary" disabled={!draftTitle.trim()} onClick={() => void addNote()}>
保存
</Button>
</div>
</div>
)}
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
@@ -327,6 +346,11 @@ export function KnowledgePage() {
自动整理
</span>
) : null}
{d.category ? (
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
{d.category.full_path}
</span>
) : null}
</div>
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
{d.title}
@@ -339,6 +363,16 @@ export function KnowledgePage() {
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
</p>
{/* 标签是备忘录的检索入口,卡片上就露出来,不用点开才知道归在哪 */}
{d.tags.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{d.tags.slice(0, 5).map((t) => (
<span key={t.id} className="chip bg-primary-50 text-primary-700">
#{t.name}
</span>
))}
</div>
) : null}
</button>
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
打开
@@ -355,28 +389,353 @@ export function KnowledgePage() {
</Button>
</div>
)}
</div>
</div>
{composing && (
<KnowledgeComposer
wsId={wsId}
categories={categories}
catBusy={catBusy}
onCreateCategory={createCategory}
onClose={() => setComposing(false)}
onCreated={onCreated}
/>
)}
{openId && (
<KnowledgeDrawer
wsId={wsId}
itemId={openId}
categories={categories}
onClose={() => setOpenId(null)}
onChanged={() => void load(null)}
onChanged={() => {
void load(null);
void loadCategories();
}}
/>
)}
</AppShell>
);
}
/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */
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 (
<aside className="card h-fit p-3 lg:sticky lg:top-4">
<div className="flex items-center justify-between px-1 py-1">
<span className="text-2xs font-medium uppercase tracking-wide text-ink-400">分类</span>
<button className="link text-2xs" onClick={() => setAdding((v) => !v)}>
{adding ? "取消" : "+ 新建"}
</button>
</div>
{adding && (
<form
className="mt-2 space-y-1.5 rounded-lg border border-line-200 bg-paper-50 p-2"
onSubmit={(e) => {
e.preventDefault();
const value = name.trim();
if (!value) return;
onCreate(value, parentId || null);
setName("");
setAdding(false);
}}
>
<input
className="input h-8 text-sm"
placeholder="分类名,例如「部署」"
value={name}
autoFocus
onChange={(e) => setName(e.target.value)}
aria-label="新分类名"
/>
<select
className="input h-8 text-sm"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
aria-label="父分类"
>
<option value="">放在最外层</option>
{categories
.filter((c) => c.depth < 3)
.map((c) => (
<option key={c.id} value={c.id}>
{c.full_path} 下
</option>
))}
</select>
<Button variant="primary" size="sm" type="submit" disabled={busy || !name.trim()}>
创建
</Button>
</form>
)}
<nav className="mt-2 space-y-0.5">
<CategoryButton active={selected === null} onClick={() => onSelect(null)}>
全部知识
</CategoryButton>
<CategoryButton active={selected === "none"} onClick={() => onSelect("none")}>
未分类
</CategoryButton>
{categories.map((c) => (
<CategoryButton
key={c.id}
active={selected === c.id}
indent={c.depth - 1}
onClick={() => onSelect(c.id)}
>
<span className="truncate">{c.name}</span>
{c.doc_count > 0 ? <span className="text-2xs text-ink-400">{c.doc_count}</span> : null}
</CategoryButton>
))}
</nav>
{categories.length === 0 ? (
<p className="mt-2 px-1 text-2xs leading-relaxed text-ink-400">
还没有分类。先建一层「研发 / 部署」这样的目录,知识才有地方落脚。
</p>
) : null}
</aside>
);
}
function CategoryButton({
active,
indent = 0,
onClick,
children,
}: {
active: boolean;
indent?: number;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
style={{ paddingLeft: `${8 + indent * 14}px` }}
className={`flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pr-2 text-sm transition-colors ${
active
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
: "text-ink-600 hover:bg-white/60 hover:text-ink-800"
}`}
>
{children}
</button>
);
}
/** 新建知识:标题、类型、分类、标签、正文一起填,不再是「输入标题就算完」。 */
function KnowledgeComposer({
wsId,
categories,
catBusy,
onCreateCategory,
onClose,
onCreated,
}: {
wsId: string;
categories: KnowledgeCategoryNode[];
catBusy: boolean;
onCreateCategory: (name: string, parentId: string | null) => Promise<KnowledgeCategoryNode | null>;
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<string | null>(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<Knowledge>(`/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 (
<Sheet
open
onClose={onClose}
size="xl"
title="记一条知识"
subtitle="标题、正文、标签、分类一次填完;保存后可以继续贴图、传附件。"
footer={
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
{busy ? "保存中…" : "保存并继续写"}
</Button>
<Button variant="ghost" disabled={busy} onClick={onClose}>
取消
</Button>
<span className="ml-auto text-2xs text-ink-400">
先存成草稿,核对发布后才进团队检索
</span>
</div>
}
>
<div className="space-y-4">
<label className="block">
<span className="label">标题</span>
<input
className="input text-base font-medium"
placeholder="一句话说清这条知识是什么,比如「回滚前先停掉灰度」"
value={title}
autoFocus
onChange={(e) => setTitle(e.target.value)}
aria-label="标题"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="label">类型</span>
<select
className="input"
value={docType}
onChange={(e) => setDocType(e.target.value)}
aria-label="类型"
>
{Object.entries(DOC_TYPE_LABEL).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">分类</span>
<select
className="input"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
aria-label="分类"
>
<option value="">未分类</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.full_path}
</option>
))}
</select>
</label>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 p-2.5">
<span className="label">在当前分类下新建子分类(最多三级)</span>
<div className="flex gap-2">
<input
className="input h-9 flex-1 text-sm"
placeholder={parentDepth >= 3 ? "已经到第三级了" : "子分类名"}
value={newCat}
disabled={parentDepth >= 3}
onChange={(e) => setNewCat(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void addCategory();
}
}}
aria-label="新建子分类"
/>
<Button
variant="ghost"
disabled={!canAddCategory || catBusy}
onClick={() => void addCategory()}
>
新建
</Button>
</div>
</div>
<label className="block">
<span className="label">标签(空格或逗号分隔)</span>
<input
className="input"
placeholder="例如:发布 事故"
value={tags}
onChange={(e) => setTags(e.target.value)}
aria-label="标签"
/>
</label>
<label className="block">
<span className="label">正文</span>
<textarea
className="input min-h-64 resize-y font-mono text-sm leading-relaxed"
rows={14}
placeholder={"可以直接写 Markdown:\n\n## 步骤\n1. 停灰度\n2. 回滚镜像\n3. 验证关键接口"}
value={body}
onChange={(e) => setBody(e.target.value)}
aria-label="正文"
/>
</label>
{error && <InlineAlert>{error}</InlineAlert>}
</div>
</Sheet>
);
}
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
function KnowledgeDrawer({
wsId,
itemId,
categories,
onClose,
onChanged,
}: {
wsId: string;
itemId: string;
categories: KnowledgeCategoryNode[];
onClose: () => void;
onChanged: () => void;
}) {
@@ -384,6 +743,8 @@ function KnowledgeDrawer({
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
const resources = useMemo(() => {
@@ -394,18 +755,27 @@ function KnowledgeDrawer({
return map;
}, [attachments]);
const [busy, setBusy] = useState(false);
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
const loadedFor = useRef<string | null>(null);
const load = useCallback(async () => {
try {
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
setDoc(data);
setTitle(data.title);
setBody(data.body_md);
setAttachments(data.attachments ?? []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
}, [wsId, itemId]);
const load = useCallback(
async (force = false) => {
if (!force && loadedFor.current === itemId) return;
try {
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
setDoc(data);
setTitle(data.title);
setBody(data.body_md);
setTags(data.tags.map((t) => t.name).join(" "));
setCategoryId(data.category?.id ?? "");
setAttachments(data.attachments ?? []);
loadedFor.current = itemId;
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
},
[wsId, itemId],
);
useEffect(() => {
void load();
@@ -419,6 +789,8 @@ function KnowledgeDrawer({
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
title,
body_md: body,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
version: doc.version,
});
setDoc(next);
@@ -426,7 +798,7 @@ function KnowledgeDrawer({
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("刚被别人改过,已刷新为最新内容");
await load();
await load(true);
} else {
setError(err instanceof ApiError ? err.message : "保存失败");
}
@@ -448,7 +820,7 @@ function KnowledgeDrawer({
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
await load();
await load(true);
} finally {
setBusy(false);
}
@@ -472,13 +844,13 @@ function KnowledgeDrawer({
<Sheet
open
onClose={onClose}
width="max-w-2xl"
size="xl"
title={doc ? doc.title : "知识"}
subtitle={
doc
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${
STATUS_LABEL[doc.knowledge.status_hint]
} · 版本 ${doc.version}`
? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
.filter(Boolean)
.join(" · ")
: "加载中…"
}
footer={
@@ -543,6 +915,37 @@ function KnowledgeDrawer({
aria-label="标题"
/>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="label">分类</span>
<select
className="input"
value={categoryId}
disabled={!canEdit}
onChange={(e) => setCategoryId(e.target.value)}
aria-label="分类"
>
<option value="">未分类</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.full_path}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">标签(空格或逗号分隔)</span>
<input
className="input"
value={tags}
disabled={!canEdit}
onChange={(e) => setTags(e.target.value)}
placeholder="例如:发布 事故"
aria-label="标签"
/>
</label>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
<RichTextEditor
ariaLabel="正文"
+1
View File
@@ -302,6 +302,7 @@ export function TicketsPage() {
<Sheet
open={creating}
onClose={() => setCreating(false)}
size="xl"
title="新建工单"
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
footer={