两个都是"看着不对劲"的问题: 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:
+6
-2
@@ -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`,删后知识变未分类 |
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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] == [
|
||||
"技术开发",
|
||||
"技术开发 / 技术",
|
||||
"技术开发 / 技术 / 部署",
|
||||
"餐饮业务",
|
||||
]
|
||||
|
||||
@@ -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("回滚前先停灰度");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user