fix(knowledge): 分类侧栏按树的顺序排,已发布的知识也能直接改
CI / backend (push) Canceled after 0s

两个都是"看着不对劲"的问题:

1. 侧栏把二级分类全堆在一级分类后面(按 depth 排序的副作用),
   「技术」和它的上级「我的备忘」隔着十几行,看着像一棵假树。
   后端改成按树的先序返回(父分类后紧跟它的子分类,兄弟仍按 sort_order、name),
   前端照 depth 缩进就是个真树;二级以下再加一个「·」标记,小屏也分得清。

2. 点开已发布的知识只有「归档」,没有改的入口——因为后端原来硬性拒绝
   修改已发布内容(7003)。但"发布"是给团队看的,写错了总要能改:
   现在已发布的可以直接 PATCH,改完立刻重建检索切片(chunk_count 当场更新),
   状态和版本号照常推进;归档的仍然要先「恢复成草稿」再改。

Web 与安卓同步:抽屉/详情里已发布也显示「编辑」,安卓分类标签区不再锁。

验证:pytest 95 passed(新增"分类按树返回"用例、发布后可改用例)、
ruff 全绿、tsc 0、vite build ✓;`knowledge-notebook.mjs` 17 项、
`knowledge.mjs` 9 项 e2e 全绿。安卓仍未编译(JDK 25 / Gradle 8.7 不兼容)。
This commit is contained in:
Steven
2026-10-31 02:06:49 +08:00
parent 441b1b93e1
commit b29286778f
11 changed files with 119 additions and 20 deletions
+6 -2
View File
@@ -396,7 +396,11 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
传 `clear_category: true` 表示移到未分类。
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
**已发布的可以直接改**:改的是"团队现在看到的这一版",改完立刻重建检索切片(`chunk_count` 当场更新),
避免"正文改了、搜出来还是旧句子"。版本号照旧 `+1`,历史仍能靠审计追。
**已归档的不许直接改**(`7003`,`allowed_actions: ["restore"]`):先恢复成草稿再改,
免得改完还挂着"现役"的样子。
### 5.5 知识分类 `/knowledge/categories`
@@ -404,7 +408,7 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/knowledge/categories` | 扁平列表(按 depth/sort_order 排好),每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
| GET | `/knowledge/categories` | 扁平列表,但按**树的先序**排好(父分类后紧跟它的子分类,兄弟按 `sort_order`、`name`),前端照 `depth` 缩进即可;每条带 `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`,删后知识变未分类 |
+2 -2
View File
@@ -291,10 +291,10 @@
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` |
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force`;列表按树的**先序**返回(父分类后紧跟子分类),侧栏照 `depth` 缩进就是一棵树 |
| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器);`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
| 3.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) |
| 3.14 | 点开一条知识默认是「看」:Markdown 渲染成正文样式;要改再点「编辑」进编辑器,取消即丢弃未保存改动 | 1.0 | [x] `KnowledgeDrawer` 分 `editing` 两态(只读用 `Markdown` + `prose-dadao`,编辑才挂 `RichTextEditor`);e2e 断言默认 `.vditor-ir` 为 0;截图 `2.Design/screens/knowledge-read-view.png` |
| 3.14 | 点开一条知识默认是「看」:Markdown 渲染成正文样式;要改再点「编辑」进编辑器,取消即丢弃未保存改动 | 1.0 | [x] `KnowledgeDrawer` 分 `editing` 两态(只读用 `Markdown` + `prose-dadao`,编辑才挂 `RichTextEditor`);**已发布也能编辑**(PATCH 后自动重建检索切片),归档的要先「恢复成草稿」;e2e 断言默认 `.vditor-ir` 为 0 且已发布也有「编辑」;截图 `2.Design/screens/knowledge-read-view.png` |
### M3 完成定义(DOD)
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

@@ -618,8 +618,8 @@ private fun KnowledgeDetailDialog(
}
}
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
if (action != "published") {
// 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写
if (action != "archived") {
Spacer(Modifier.height(14.dp))
SectionLabel("分类")
Row(
@@ -675,14 +675,14 @@ private fun KnowledgeDetailDialog(
title = doc.title,
body = doc.bodyMd,
version = version,
titleEnabled = action == "draft",
titleEnabled = action != "archived",
knowledge = true,
),
)
onClose()
},
) { Text("编辑正文") }
if (action != "published") {
if (action != "archived") {
TextButton(
enabled = !savingMeta,
onClick = {
+35 -4
View File
@@ -181,6 +181,31 @@ def _category_payload(
}
def _ordered_categories(
by_id: dict[uuid.UUID, KnowledgeCategory],
) -> list[KnowledgeCategory]:
"""深度优先:父分类在前,它的子分类紧跟其后。
早先按 `(depth, sort_order, name)` 排,二级分类会全部堆在一级分类后面,
前端照着 depth 缩进就成了一棵"假树"——「技术」和它的上级「我的备忘」隔着十几行。
"""
children: dict[uuid.UUID | None, list[KnowledgeCategory]] = {}
for cat in by_id.values():
children.setdefault(cat.parent_id, []).append(cat)
for group in children.values():
group.sort(key=lambda c: (c.sort_order, c.name))
ordered: list[KnowledgeCategory] = []
def walk(parent_id: uuid.UUID | None) -> None:
for cat in children.get(parent_id, []):
ordered.append(cat)
walk(cat.id)
walk(None)
return ordered
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():
@@ -358,7 +383,7 @@ async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
).all()
)
items = []
for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)):
for cat in _ordered_categories(by_id):
data = _category_payload(cat, by_id)
data["doc_count"] = counts.get(cat.id, 0)
items.append(data)
@@ -636,11 +661,12 @@ async def patch_knowledge(
) -> dict:
ws_id = ctx.ws_id
item, doc = await _load_doc(session, ws_id, item_id)
if item.status == "published":
if item.status == "archived":
# 已归档是先收起来,不是不能改:要改先恢复成草稿,免得改完"发布着"的样子被人当成现役
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
"已发布的知识不能直接改内容,先撤回成草稿",
details={"from": item.status, "allowed_actions": ["archive"]},
"已归档的知识不能直接改内容,先恢复成草稿",
details={"from": item.status, "allowed_actions": ["restore"]},
)
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
@@ -689,6 +715,11 @@ async def patch_knowledge(
if body.tag_names is not None:
await _set_tags_for(session, ws_id, item, body.tag_names)
await session.flush()
if (body.title is not None or body.body_md is not None) and item.status == "published":
# 已发布的知识改完立刻重建切片:检索只认 published,
# 不重建就会出现"正文改了、搜出来还是旧句子"。
await reindex_document(session, ws_id, item.id)
await session.flush()
return ok(await _knowledge_out(session, ws_id, item, doc))
+19 -6
View File
@@ -200,13 +200,18 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
assert published["status"] == "published"
assert published["knowledge"]["published_at"] is not None
# 已发布的内容不许直接改,先撤回
blocked = await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v2", "version": published["version"]},
headers=owner["headers"],
# 已发布的能直接改正文:改完后端自动重建切片,状态仍是已发布
edited = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v2", "version": published["version"]},
headers=owner["headers"],
)
)
assert blocked.json()["code"] == 7003
assert edited["title"] == "值班手册 v2"
assert edited["knowledge"]["status_hint"] == "published"
assert edited["version"] == published["version"] + 1
published = edited
archived = unwrap(
await client.post(
@@ -218,6 +223,14 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
assert archived["status"] == "archived"
assert archived["knowledge"]["archived_at"] is not None
# 归档是先收起来;要改先恢复成草稿,免得改完还挂着"现役"的样子
blocked = await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v3", "version": archived["version"]},
headers=owner["headers"],
)
assert blocked.json()["code"] == 7003
@pytest.mark.asyncio
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
@@ -303,3 +303,29 @@ async def test_patch_knowledge_accepts_doc_type_and_bumps_version(client: AsyncC
assert patched["body_md"] == "改过的正文"
assert patched["version"] == doc["version"] + 1
assert {t["name"] for t in patched["tags"]} == {"写法"}
@pytest.mark.asyncio
async def test_categories_come_back_as_a_tree(client: AsyncClient) -> None:
"""列表按「父在前、子紧跟」排:前端照着 depth 缩进才像一棵树。
之前按 (depth, sort_order) 排,二级分类全堆在一级后面,
侧栏看着像"所有一级在一起、所有二级在一起"。
"""
user, ws_id = await _ws(client)
tech = await _category(client, user, ws_id, "技术开发")
biz = await _category(client, user, ws_id, "餐饮业务")
child = await _category(client, user, ws_id, "技术", tech["id"])
await _category(client, user, ws_id, "部署", child["id"])
assert biz["depth"] == 1
items = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/categories", headers=user["headers"])
)["items"]
assert [i["name"] for i in items] == ["技术开发", "技术", "部署", "餐饮业务"]
assert [i["full_path"] for i in items] == [
"技术开发",
"技术开发 / 技术",
"技术开发 / 技术 / 部署",
"餐饮业务",
]
+9
View File
@@ -77,6 +77,15 @@ try {
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
check("能在侧栏建出「研发 / 部署」两级分类", true);
// 树要按「父在前、子紧跟」排:部署必须紧挨在研发后面,而不是堆在别的根分类后面
const navTexts = await page.locator("aside nav button").allInnerTexts();
const navNames = navTexts.map((t) => t.replace(/[·\d]/g, "").trim());
check(
"侧栏分类按树的顺序排(子紧跟在父后面)",
navNames.indexOf("部署") === navNames.indexOf("研发") + 1,
navNames.join(" / "),
);
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
await page.getByRole("button", { name: "记一条", exact: true }).click();
await page.getByLabel("标题").fill("回滚前先停灰度");
+7
View File
@@ -108,6 +108,13 @@ try {
const published = await page.getByText(/发布于 /, { exact: false }).first().isVisible();
check("核对后可以发布", published);
// 发布之后也得能改:点「编辑」回到编辑器,改完还是已发布
const editButton = page.getByRole("button", { name: "编辑", exact: true }).last();
check("已发布的知识也有「编辑」按钮", await editButton.isVisible());
await editButton.click();
await page.locator(".vditor-ir").first().waitFor({ timeout: 10_000 });
check("点「编辑」回到编辑器", (await page.locator(".vditor-ir").count()) > 0);
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
headers: { authorization: `Bearer ${token}` },
}).then((r) => r.json());
+11 -2
View File
@@ -538,7 +538,15 @@ function CategoryButton({
: "text-ink-600 hover:bg-white/60 hover:text-ink-800"
}`}
>
{children}
<span className="flex min-w-0 items-center gap-1">
{/* 二级及以下带一个点,光靠缩进在小屏上不够分得清是子分类还是换了一棵树 */}
{indent > 0 ? (
<span aria-hidden className="text-ink-300">
·
</span>
) : null}
{children}
</span>
</button>
);
}
@@ -908,7 +916,8 @@ function KnowledgeDrawer({
}
const status = doc?.knowledge.status_hint;
const canEdit = status === "draft" || status === "in_review";
// 已发布的也能改:改完后端会自动重建检索切片。归档是先收起来,要改得先「恢复成草稿」。
const canEdit = status === "draft" || status === "in_review" || status === "published";
const actions: { key: string; label: string; show: boolean }[] = [
{ key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" },
{ key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" },