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`,不传为全部 | | `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` | | `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
| `source_item_id` | 按来源事项筛 | | `source_item_id` | 按来源事项筛 |
| `category_id` | 按分类筛,**包含子分类** |
| `uncategorized` | `true` 只看未分类 |
| `tag` | 按标签名筛(不区分大小写) |
| `q` | 标题 / 正文 / 摘要子串 | | `q` | 标题 / 正文 / 摘要子串 |
| `limit` | 1–100,默认 30 | | `limit` | 1–100,默认 30 |
| `cursor` | 上一页的 `next_cursor` | | `cursor` | 上一页的 `next_cursor` |
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`: 返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`:
```json ```json
{ {
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄", "id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
"body_md": "…", "status": "published", "tags": [], "body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}],
"knowledge": { "knowledge": {
"doc_type": "howto", "summary": "…", "status_hint": "published", "doc_type": "howto", "summary": "…", "status_hint": "published",
"source_kind": "task", "source_item_id": "…", "generated_by": "human", "source_kind": "task", "source_item_id": "…", "generated_by": "human",
"published_at": "2026-10-28T05:54:44Z", "archived_at": null, "published_at": "2026-10-28T05:54:44Z", "archived_at": null,
"chunk_count": 0, "author_id": "…" "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"} "source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
} }
``` ```
@@ -374,19 +382,43 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
```json ```json
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note", {"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
"tag_names": ["财务", "报销"], "category_id": "可选的分类 id",
"source_item_id": "可选的来源事项 id"} "source_item_id": "可选的来源事项 id"}
``` ```
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。 - 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。 - 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。
- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。
- 新知识一律 `draft`,不会直接对外可见。 - 新知识一律 `draft`,不会直接对外可见。
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容 ### 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`,先撤回审议或归档),避免"发布后被悄悄改写"。 **已发布的不许直接改**(`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 | 允许的前置状态 | 结果 | 谁能做 | | action | 允许的前置状态 | 结果 | 谁能做 |
|---|---|---|---| |---|---|---|---|
@@ -401,7 +433,7 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。 **发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识 ### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。 Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。 返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
@@ -409,7 +441,7 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`, **工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。 正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
### 5.7 检索 `GET /knowledge/search` ### 5.8 检索 `GET /knowledge/search`
Query: Query:
| 参数 | 说明 | | 参数 | 说明 |
@@ -434,7 +466,7 @@ Query:
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。 2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
3. **宁可返回空,也不凑数**。检索失败比误导便宜。 3. **宁可返回空,也不凑数**。检索失败比误导便宜。
发布(`5.5` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到; 发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
但改完正文不会自动重建,需要调用下面这条。 但改完正文不会自动重建,需要调用下面这条。
### `POST /knowledge/{item_id}/reindex` — 重建切片 ### `POST /knowledge/{item_id}/reindex` — 重建切片
@@ -447,7 +479,7 @@ Query:
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后 将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。 **必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
### 5.8 知识的错误码 ### 5.9 知识的错误码
| 码 | HTTP | 含义 | | 码 | HTTP | 含义 |
|---|---|---| |---|---|---|
@@ -456,6 +488,9 @@ Query:
| 7003 | 409 | 当前状态不允许这个动作 | | 7003 | 409 | 当前状态不允许这个动作 |
| 7004 | 403 | 权限不足(如非管理员发布) | | 7004 | 403 | 权限不足(如非管理员发布) |
| 7005 | 409 | 来源事项无效或缺失 | | 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.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) | | 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 | | 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) ### M3 完成定义(DOD)
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴 - [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项 - [x] 每条知识都能追到来源(除手记),点开能看到来源事项
- [x] 非管理员发布被拒;跨租户看不到别人的知识 - [x] 非管理员发布被拒;跨租户看不到别人的知识
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例) - [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 = "", 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 @Serializable
data class KnowledgeDoc( data class KnowledgeDoc(
val id: String, val id: String,
@@ -245,6 +265,7 @@ data class KnowledgeDoc(
val version: Int = 1, val version: Int = 1,
val tags: List<Tag> = emptyList(), val tags: List<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(), val knowledge: KnowledgeInfo = KnowledgeInfo(),
val category: KnowledgeCategory? = null,
val source: SourceRef? = null, val source: SourceRef? = null,
val attachments: List<Attachment> = emptyList(), val attachments: List<Attachment> = emptyList(),
) )
@@ -325,6 +346,8 @@ private data class KnowledgeCreateRequest(
val title: String, val title: String,
@SerialName("body_md") val bodyMd: String, @SerialName("body_md") val bodyMd: String,
@SerialName("doc_type") val docType: String, @SerialName("doc_type") val docType: String,
@SerialName("tag_names") val tagNames: List<String> = emptyList(),
@SerialName("category_id") val categoryId: String? = null,
) )
@Serializable @Serializable
@@ -351,6 +374,10 @@ private data class KnowledgeUpdateRequest(
val version: Int, val version: Int,
val title: String? = null, val title: String? = null,
@SerialName("body_md") val bodyMd: 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, workspaceId: String,
statuses: List<String> = listOf("draft", "in_review"), statuses: List<String> = listOf("draft", "in_review"),
query: String? = null, query: String? = null,
categoryId: String? = null,
uncategorized: Boolean = false,
): KnowledgePage { ): KnowledgePage {
val params = buildString { val params = buildString {
append("?limit=30") append("?limit=30")
statuses.forEach { append("&status=").append(it) } 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) 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( call(
path = "/workspaces/$workspaceId/knowledge", path = "/workspaces/$workspaceId/knowledge",
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")), body = json.encodeToString(
KnowledgeCreateRequest(title, body, docType, tags, categoryId),
),
auth = true, auth = true,
) )
@@ -709,9 +755,14 @@ class Api(private val tokens: TokenStore) {
version: Int, version: Int,
title: String? = null, title: String? = null,
body: String? = null, body: String? = null,
tags: List<String>? = null,
categoryId: String? = null,
clearCategory: Boolean = false,
): KnowledgeDoc = call( ): KnowledgeDoc = call(
path = "/workspaces/$workspaceId/knowledge/$itemId", path = "/workspaces/$workspaceId/knowledge/$itemId",
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)), body = json.encodeToString(
KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory),
),
auth = true, auth = true,
method = "PATCH", method = "PATCH",
) )
@@ -1,9 +1,12 @@
package com.dadao.task.ui 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.Api
import com.dadao.task.data.ApiException import com.dadao.task.data.ApiException
import com.dadao.task.data.KnowledgeCategory
import com.dadao.task.data.KnowledgeDoc import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,13 +52,26 @@ private val knowledgeTabs = listOf(
"all" to "全部", "all" to "全部",
) )
private fun docTypeLabel(type: String): String = when (type) { private val docTypes = listOf(
"howto" -> "怎么做" "note" to "笔记",
"faq" -> "问答" "howto" to "怎么做",
"postmortem" -> "复盘" "faq" to "问答",
"decision" -> "决策" "postmortem" to "复盘",
else -> "笔记" "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 @Composable
fun KnowledgeScreen( fun KnowledgeScreen(
@@ -66,12 +85,18 @@ fun KnowledgeScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var tab by remember { mutableStateOf("draft") } var tab by remember { mutableStateOf("draft") }
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) } var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
var open by remember { mutableStateOf<KnowledgeDoc?>(null) } var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
var adding by remember { mutableStateOf(false) } var adding by remember { mutableStateOf(false) }
var newTitle by remember { mutableStateOf("") } // null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
var newBody by remember { mutableStateOf("") } var catFilter by remember { mutableStateOf<String?>(null) }
suspend fun loadCategories() {
runCatching { api.knowledgeCategories(workspace.id) }
.onSuccess { categories = it.items }
}
suspend fun reload() { suspend fun reload() {
loading = true loading = true
@@ -81,7 +106,15 @@ fun KnowledgeScreen(
"all" -> listOf("draft", "in_review", "published", "archived") "all" -> listOf("draft", "in_review", "published", "archived")
else -> listOf("draft", "in_review") 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 { .onSuccess {
docs = it.items docs = it.items
message = null message = null
@@ -90,7 +123,8 @@ fun KnowledgeScreen(
loading = false loading = false
} }
LaunchedEffect(tab) { reload() } LaunchedEffect(tab, catFilter) { reload() }
LaunchedEffect(Unit) { loadCategories() }
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。 // 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
LaunchedEffect(reopenItem) { LaunchedEffect(reopenItem) {
@@ -125,11 +159,30 @@ fun KnowledgeScreen(
} }
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
GhostButton("记一条", onClick = { Row(
adding = true modifier = Modifier.horizontalScroll(rememberScrollState()),
newTitle = "" horizontalArrangement = Arrangement.spacedBy(6.dp),
newBody = "" ) {
}) 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()) { if (!loading && docs.isEmpty()) {
item { item {
EmptyState( EmptyState(
"还没有待核对的知识", if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
"把工单验收通过,或在任务详情里点「沉淀成知识」", "把工单验收通过,或在任务详情里点「沉淀成知识」",
) )
} }
@@ -165,6 +218,18 @@ fun KnowledgeScreen(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.height(6.dp)) 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( Text(
sourceLine(doc), sourceLine(doc),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -182,68 +247,35 @@ fun KnowledgeScreen(
api = api, api = api,
workspace = workspace, workspace = workspace,
doc = target, doc = target,
categories = categories,
onClose = { open = null }, onClose = { open = null },
onEdit = onEdit, onEdit = onEdit,
onChanged = { onChanged = {
scope.launch { reload() } scope.launch {
reload()
loadCategories()
}
}, },
onCategoriesChanged = { scope.launch { loadCategories() } },
onFail = { fail(it) }, onFail = { fail(it) },
) )
} }
if (adding) { if (adding) {
AlertDialog( KnowledgeComposerDialog(
onDismissRequest = { adding = false }, api = api,
title = { Text("记一条", style = MaterialTheme.typography.titleMedium) }, workspace = workspace,
text = { categories = categories,
Column { onDismiss = { adding = false },
Text( onCreated = { created ->
"手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。", adding = false
style = MaterialTheme.typography.bodySmall, scope.launch {
color = InkMuted, loadCategories()
) reload()
Spacer(Modifier.height(10.dp)) open = created
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,
),
)
} }
}, },
confirmButton = { onFail = { fail(it) },
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,
) )
} }
} }
@@ -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 @Composable
private fun KnowledgeDetailDialog( private fun KnowledgeDetailDialog(
api: Api, api: Api,
workspace: Workspace, workspace: Workspace,
doc: KnowledgeDoc, doc: KnowledgeDoc,
categories: List<KnowledgeCategory>,
onClose: () -> Unit, onClose: () -> Unit,
onChanged: () -> Unit, onChanged: () -> Unit,
onCategoriesChanged: () -> Unit,
onFail: (Throwable) -> Unit, onFail: (Throwable) -> Unit,
onEdit: (EditRequest) -> Unit, onEdit: (EditRequest) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var action by remember { mutableStateOf(doc.status) } 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) { val actions = when (action) {
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档") "draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档") "in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
@@ -292,15 +488,61 @@ private fun KnowledgeDetailDialog(
}, },
text = { text = {
Column( Column(
Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
) { ) {
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted) Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" }, doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = InkSoft, 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 = { confirmButton = {
@@ -312,7 +554,7 @@ private fun KnowledgeDetailDialog(
itemId = doc.id, itemId = doc.id,
title = doc.title, title = doc.title,
body = doc.bodyMd, body = doc.bodyMd,
version = doc.version, version = version,
titleEnabled = action == "draft", titleEnabled = action == "draft",
knowledge = true, knowledge = true,
), ),
@@ -320,6 +562,34 @@ private fun KnowledgeDetailDialog(
onClose() onClose()
}, },
) { Text("编辑正文") } ) { 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) -> actions.forEach { (key, label) ->
TextButton(onClick = { TextButton(onClick = {
scope.launch { 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 fastapi import APIRouter, Query
from pydantic import BaseModel, Field 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 sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record from ..audit import record as audit_record
from ..deps import MemberDep, SessionDep from ..deps import MemberDep, SessionDep
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
from ..errors import AppError, ErrorCode from ..errors import AppError, ErrorCode
from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc from ..models_knowledge import (
from ..models_task import Activity, Item 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 ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
from ..serializers import item_out, tags_of from ..serializers import item_out, tags_of
from ..services.knowledge import create_draft_from_source, find_doc_by_source from ..services.knowledge import create_draft_from_source, find_doc_by_source
@@ -47,6 +53,7 @@ class KnowledgeIn(BaseModel):
doc_type: str = "note" doc_type: str = "note"
source_item_id: uuid.UUID | None = None source_item_id: uuid.UUID | None = None
tag_names: list[str] = Field(default_factory=list) tag_names: list[str] = Field(default_factory=list)
category_id: uuid.UUID | None = None
class KnowledgePatch(BaseModel): class KnowledgePatch(BaseModel):
@@ -55,6 +62,24 @@ class KnowledgePatch(BaseModel):
summary: str | None = None summary: str | None = None
doc_type: str | None = None doc_type: str | None = None
tag_names: list[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 version: int
@@ -112,6 +137,7 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
"status_hint": doc.status_hint, "status_hint": doc.status_hint,
"source_kind": doc.source_kind, "source_kind": doc.source_kind,
"source_item_id": str(doc.source_item_id) if doc.source_item_id else None, "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, "generated_by": doc.generated_by,
"published_at": doc.published_at, "published_at": doc.published_at,
"archived_at": doc.archived_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( async def _knowledge_out(
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
) -> dict[str, Any]: ) -> dict[str, Any]:
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, []) tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
data = item_out(item, tags) data = item_out(item, tags)
data["knowledge"] = _doc_out(doc) 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["source"] = await _source_out(session, ws_id, doc.source_item_id)
data["attachments"] = await list_for_item(session, ws_id, item.id) data["attachments"] = await list_for_item(session, ws_id, item.id)
data["attachment_count"] = len(data["attachments"]) data["attachment_count"] = len(data["attachments"])
@@ -148,6 +258,9 @@ async def list_knowledge(
status: Annotated[list[str] | None, Query()] = None, status: Annotated[list[str] | None, Query()] = None,
doc_type: Annotated[str | None, Query()] = None, doc_type: Annotated[str | None, Query()] = None,
source_item_id: Annotated[uuid.UUID | 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, q: Annotated[str | None, Query(max_length=100)] = None,
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30, limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None, 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) stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
if source_item_id: if source_item_id:
stmt = stmt.where(KnowledgeDoc.source_item_id == 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: if q:
like = f"%{q.strip()}%" like = f"%{q.strip()}%"
stmt = stmt.where( stmt = stmt.where(
@@ -188,12 +316,16 @@ async def list_knowledge(
has_more = len(rows) > limit has_more = len(rows) > limit
rows = rows[:limit] rows = rows[:limit]
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows]) 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 = [] items = []
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows]) counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
for item, doc in rows: for item, doc in rows:
data = item_out(item, tag_map.get(item.id, [])) data = item_out(item, tag_map.get(item.id, []))
data["knowledge"] = _doc_out(doc) 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["source"] = await _source_out(session, ws_id, doc.source_item_id)
data["attachment_count"] = counts.get(str(item.id), 0) data["attachment_count"] = counts.get(str(item.id), 0)
items.append(data) items.append(data)
@@ -204,6 +336,207 @@ async def list_knowledge(
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more}) 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}` 之前, # 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
# 否则 "search" 会被当成 uuid 解析,返回 422。 # 否则 "search" 会被当成 uuid 解析,返回 422。
@router.get("/knowledge/search", summary="检索已发布知识") @router.get("/knowledge/search", summary="检索已发布知识")
@@ -257,6 +590,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED, ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
"来源必须是与本知识同一工作区的任务或工单", "来源必须是与本知识同一工作区的任务或工单",
) )
category = await _resolve_category(session, ws_id, body.category_id)
item = Item( item = Item(
workspace_id=ws_id, workspace_id=ws_id,
@@ -278,6 +612,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
doc_type=body.doc_type, doc_type=body.doc_type,
status_hint="draft", status_hint="draft",
summary=body.summary, summary=body.summary,
category_id=category.id if category else None,
generated_by="human", generated_by="human",
) )
session.add(doc) session.add(doc)
@@ -320,7 +655,10 @@ async def patch_knowledge(
if body.doc_type is not None: if body.doc_type is not None:
values["doc_type"] = body.doc_type 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) values["updated_at"] = datetime.now(timezone.utc)
result = await session.execute( result = await session.execute(
update(Item) update(Item)
@@ -344,6 +682,11 @@ async def patch_knowledge(
doc.doc_type = body.doc_type doc.doc_type = body.doc_type
if body.summary is not None: if body.summary is not None:
doc.summary = body.summary 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: if body.tag_names is not None:
await _set_tags_for(session, ws_id, item, body.tag_names) await _set_tags_for(session, ws_id, item, body.tag_names)
await session.flush() await session.flush()
+6
View File
@@ -56,6 +56,9 @@ class ErrorCode(IntEnum):
KNOWLEDGE_STATUS_INVALID = 7003 KNOWLEDGE_STATUS_INVALID = 7003
KNOWLEDGE_FORBIDDEN = 7004 KNOWLEDGE_FORBIDDEN = 7004
KNOWLEDGE_SOURCE_REQUIRED = 7005 KNOWLEDGE_SOURCE_REQUIRED = 7005
KNOWLEDGE_CATEGORY_NOT_FOUND = 7006
KNOWLEDGE_CATEGORY_DEPTH = 7007
KNOWLEDGE_CATEGORY_CONFLICT = 7008
# 8xxx 附件 # 8xxx 附件
ATTACHMENT_NOT_FOUND = 8001 ATTACHMENT_NOT_FOUND = 8001
@@ -101,6 +104,9 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409, ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
ErrorCode.KNOWLEDGE_FORBIDDEN: 403, ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409, 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_NOT_FOUND: 404,
ErrorCode.ATTACHMENT_TOO_LARGE: 413, ErrorCode.ATTACHMENT_TOO_LARGE: 413,
ErrorCode.ATTACHMENT_FORBIDDEN: 403, 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") SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
GENERATED_BY: tuple[str, ...] = ("human", "rule") 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): class KnowledgeDoc(Base, Timestamps):
"""知识的附加信息,与 item 一比一。""" """知识的附加信息,与 item 一比一。"""
@@ -47,6 +77,7 @@ class KnowledgeDoc(Base, Timestamps):
__table_args__ = ( __table_args__ = (
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"), Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"), Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
Index("ix_knowledge_ws_category", "workspace_id", "category_id"),
CheckConstraint( CheckConstraint(
"source_kind = 'manual' OR source_item_id IS NOT NULL", "source_kind = 'manual' OR source_item_id IS NOT NULL",
name="ck_knowledge_source_required", 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") doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note")
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表 # 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft") 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="") summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human") generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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)) 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 <Sheet
open open
onClose={onClose} onClose={onClose}
width="max-w-2xl" size="xl"
title={item ? item.title : "打开中…"} title={item ? item.title : "打开中…"}
subtitle={meta} subtitle={meta}
footer={ footer={
+13
View File
@@ -234,6 +234,19 @@ export const Link2 = (p: IconProps) => (
</svg> </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) => ( export const More = (p: IconProps) => (
<svg {...base(p)}> <svg {...base(p)}>
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" /> <circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
+38 -7
View File
@@ -8,7 +8,17 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } 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, subtitle,
children, children,
footer, footer,
width = "max-w-xl", size = "lg",
width,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -275,8 +286,14 @@ export function Sheet({
subtitle?: ReactNode; subtitle?: ReactNode;
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
/** 宽度档位;长文/多图用 xl,右上方还能一键放满整屏 */
size?: "md" | "lg" | "xl";
/** 兜底:直接给 Tailwind 宽度类 */
width?: string; width?: string;
}) { }) {
const [expanded, setExpanded] = useState(false);
const sizeClass = width ?? SHEET_SIZE[size];
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -298,17 +315,31 @@ export function Sheet({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={typeof title === "string" ? title : "详情"} 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"> <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="min-w-0">
<div className="text-base font-semibold text-ink-900">{title}</div> <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} {subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
</div> </div>
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭"> <div className="flex shrink-0 items-center gap-1.5">
<X size={15} /> <Button
关闭 variant="subtle"
</Button> 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> </header>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
{footer ? ( {footer ? (
+17
View File
@@ -150,8 +150,25 @@ export interface KnowledgeSource {
status: string; 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 { export interface Knowledge extends Item {
knowledge: KnowledgeInfo; knowledge: KnowledgeInfo;
category: KnowledgeCategory | null;
source: KnowledgeSource | null; source: KnowledgeSource | null;
attachments?: Attachment[]; attachments?: Attachment[];
attachment_count?: number; attachment_count?: number;
+471 -68
View File
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell"; import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel"; import { AttachmentPanel } from "../components/AttachmentPanel";
import { RichTextEditor } from "../components/RichTextEditor"; import { RichTextEditor } from "../components/RichTextEditor";
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons"; import { Link2, Plus, Search, Sparkle } from "../components/icons";
import { import {
Button, Button,
EmptyState, EmptyState,
@@ -14,7 +14,13 @@ import {
StatusPill, StatusPill,
} from "../components/ui"; } from "../components/ui";
import { ApiError, api } from "../lib/client"; 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 = [ const TABS = [
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] }, { key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
@@ -61,12 +67,48 @@ export function KnowledgePage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [adding, setAdding] = useState(false); const [composing, setComposing] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null); const [hits, setHits] = useState<SearchHit[] | null>(null);
const [searching, setSearching] = useState(false); 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 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( const load = useCallback(
async (nextCursor: string | null) => { async (nextCursor: string | null) => {
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果 // 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
@@ -78,6 +120,8 @@ export function KnowledgePage() {
const statuses = TABS.find((t) => t.key === tab)!.statuses; const statuses = TABS.find((t) => t.key === tab)!.statuses;
for (const s of statuses) qs.append("status", s); for (const s of statuses) qs.append("status", s);
if (q.trim()) qs.set("q", q.trim()); 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); if (nextCursor) qs.set("cursor", nextCursor);
const page = await api.get<{ const page = await api.get<{
items: Knowledge[]; items: Knowledge[];
@@ -94,7 +138,7 @@ export function KnowledgePage() {
if (mine === reqId.current) setLoading(false); if (mine === reqId.current) setLoading(false);
} }
}, },
[wsId, tab, q], [wsId, tab, q, catFilter],
); );
useEffect(() => { useEffect(() => {
@@ -127,22 +171,13 @@ export function KnowledgePage() {
} }
} }
async function addNote() { function onCreated(doc: Knowledge) {
const title = draftTitle.trim(); setComposing(false);
if (!title) return; setTab("draft");
try { setCatFilter(null);
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, { setOpenId(doc.id);
title, void load(null);
doc_type: "note", void loadCategories();
});
setDraftTitle("");
setAdding(false);
setTab("draft");
setOpenId(doc.id);
void load(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "保存失败");
}
} }
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length; const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
@@ -176,7 +211,7 @@ export function KnowledgePage() {
<Search size={14} /> <Search size={14} />
{searching ? "检索中…" : "检索"} {searching ? "检索中…" : "检索"}
</Button> </Button>
<Button variant="primary" onClick={() => setAdding((v) => !v)}> <Button variant="primary" onClick={() => setComposing(true)}>
<Plus size={14} /> <Plus size={14} />
记一条 记一条
</Button> </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"> <div className="mb-4 flex flex-wrap items-center gap-1.5">
{TABS.map((t) => { {TABS.map((t) => {
const active = tab === t.key; const active = tab === t.key;
@@ -208,34 +255,6 @@ export function KnowledgePage() {
) : null} ) : null}
</div> </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 && ( {error && (
<div className="mb-4"> <div className="mb-4">
<InlineAlert>{error}</InlineAlert> <InlineAlert>{error}</InlineAlert>
@@ -327,6 +346,11 @@ export function KnowledgePage() {
自动整理 自动整理
</span> </span>
) : null} ) : null}
{d.category ? (
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
{d.category.full_path}
</span>
) : null}
</div> </div>
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700"> <p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
{d.title} {d.title}
@@ -339,6 +363,16 @@ export function KnowledgePage() {
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null} {d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span> <span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
</p> </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>
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}> <Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
打开 打开
@@ -355,28 +389,353 @@ export function KnowledgePage() {
</Button> </Button>
</div> </div>
)} )}
</div>
</div>
{composing && (
<KnowledgeComposer
wsId={wsId}
categories={categories}
catBusy={catBusy}
onCreateCategory={createCategory}
onClose={() => setComposing(false)}
onCreated={onCreated}
/>
)}
{openId && ( {openId && (
<KnowledgeDrawer <KnowledgeDrawer
wsId={wsId} wsId={wsId}
itemId={openId} itemId={openId}
categories={categories}
onClose={() => setOpenId(null)} onClose={() => setOpenId(null)}
onChanged={() => void load(null)} onChanged={() => {
void load(null);
void loadCategories();
}}
/> />
)} )}
</AppShell> </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({ function KnowledgeDrawer({
wsId, wsId,
itemId, itemId,
categories,
onClose, onClose,
onChanged, onChanged,
}: { }: {
wsId: string; wsId: string;
itemId: string; itemId: string;
categories: KnowledgeCategoryNode[];
onClose: () => void; onClose: () => void;
onChanged: () => void; onChanged: () => void;
}) { }) {
@@ -384,6 +743,8 @@ function KnowledgeDrawer({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */ /** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
const resources = useMemo(() => { const resources = useMemo(() => {
@@ -394,18 +755,27 @@ function KnowledgeDrawer({
return map; return map;
}, [attachments]); }, [attachments]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
const loadedFor = useRef<string | null>(null);
const load = useCallback(async () => { const load = useCallback(
try { async (force = false) => {
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`); if (!force && loadedFor.current === itemId) return;
setDoc(data); try {
setTitle(data.title); const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
setBody(data.body_md); setDoc(data);
setAttachments(data.attachments ?? []); setTitle(data.title);
} catch (err) { setBody(data.body_md);
setError(err instanceof ApiError ? err.message : "加载失败"); setTags(data.tags.map((t) => t.name).join(" "));
} setCategoryId(data.category?.id ?? "");
}, [wsId, itemId]); setAttachments(data.attachments ?? []);
loadedFor.current = itemId;
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
},
[wsId, itemId],
);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -419,6 +789,8 @@ function KnowledgeDrawer({
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, { const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
title, title,
body_md: body, body_md: body,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
version: doc.version, version: doc.version,
}); });
setDoc(next); setDoc(next);
@@ -426,7 +798,7 @@ function KnowledgeDrawer({
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.code === 4009) { if (err instanceof ApiError && err.code === 4009) {
setError("刚被别人改过,已刷新为最新内容"); setError("刚被别人改过,已刷新为最新内容");
await load(); await load(true);
} else { } else {
setError(err instanceof ApiError ? err.message : "保存失败"); setError(err instanceof ApiError ? err.message : "保存失败");
} }
@@ -448,7 +820,7 @@ function KnowledgeDrawer({
onChanged(); onChanged();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败"); setError(err instanceof ApiError ? err.message : "操作失败");
await load(); await load(true);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -472,13 +844,13 @@ function KnowledgeDrawer({
<Sheet <Sheet
open open
onClose={onClose} onClose={onClose}
width="max-w-2xl" size="xl"
title={doc ? doc.title : "知识"} title={doc ? doc.title : "知识"}
subtitle={ subtitle={
doc doc
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${ ? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
STATUS_LABEL[doc.knowledge.status_hint] .filter(Boolean)
} · 版本 ${doc.version}` .join(" · ")
: "加载中…" : "加载中…"
} }
footer={ footer={
@@ -543,6 +915,37 @@ function KnowledgeDrawer({
aria-label="标题" 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"> <div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
<RichTextEditor <RichTextEditor
ariaLabel="正文" ariaLabel="正文"
+1
View File
@@ -302,6 +302,7 @@ export function TicketsPage() {
<Sheet <Sheet
open={creating} open={creating}
onClose={() => setCreating(false)} onClose={() => setCreating(false)}
size="xl"
title="新建工单" title="新建工单"
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。" subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
footer={ footer={