diff --git a/2.Design/API.md b/2.Design/API.md index caac97b..6f91573 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -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`,删后知识变未分类 | diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 02e471f..67acaa1 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -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] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴 diff --git a/2.Design/screens/knowledge-categories.png b/2.Design/screens/knowledge-categories.png index 03e003e..021d817 100644 Binary files a/2.Design/screens/knowledge-categories.png and b/2.Design/screens/knowledge-categories.png differ diff --git a/2.Design/screens/knowledge-read-view.png b/2.Design/screens/knowledge-read-view.png index 00be73a..95673bc 100644 Binary files a/2.Design/screens/knowledge-read-view.png and b/2.Design/screens/knowledge-read-view.png differ diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt index a4aa17d..73b5fa7 100644 --- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -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 = { diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index 15d45cc..98eecae 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -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)) diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index 44c8e6c..76cdebc 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -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: diff --git a/backend/tests/test_knowledge_categories.py b/backend/tests/test_knowledge_categories.py index e1dfa3b..38c096b 100644 --- a/backend/tests/test_knowledge_categories.py +++ b/backend/tests/test_knowledge_categories.py @@ -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] == [ + "技术开发", + "技术开发 / 技术", + "技术开发 / 技术 / 部署", + "餐饮业务", + ] diff --git a/web/e2e/knowledge-notebook.mjs b/web/e2e/knowledge-notebook.mjs index ab7aea8..99a2f61 100644 --- a/web/e2e/knowledge-notebook.mjs +++ b/web/e2e/knowledge-notebook.mjs @@ -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("回滚前先停灰度"); diff --git a/web/e2e/knowledge.mjs b/web/e2e/knowledge.mjs index e6d2b11..420c9d6 100644 --- a/web/e2e/knowledge.mjs +++ b/web/e2e/knowledge.mjs @@ -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()); diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx index 2096c3b..f4aa157 100644 --- a/web/src/pages/Knowledge.tsx +++ b/web/src/pages/Knowledge.tsx @@ -538,7 +538,15 @@ function CategoryButton({ : "text-ink-600 hover:bg-white/60 hover:text-ink-800" }`} > - {children} + + {/* 二级及以下带一个点,光靠缩进在小屏上不够分得清是子分类还是换了一棵树 */} + {indent > 0 ? ( + + · + + ) : null} + {children} + ); } @@ -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" },