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
+347 -4
View File
@@ -21,15 +21,21 @@ from typing import Annotated, Any
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import Select, and_, or_, select, update
from sqlalchemy import Select, and_, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record
from ..deps import MemberDep, SessionDep
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
from ..errors import AppError, ErrorCode
from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc
from ..models_task import Activity, Item
from ..models_knowledge import (
DOC_TYPES,
KNOWLEDGE_STATUSES,
MAX_CATEGORY_DEPTH,
KnowledgeCategory,
KnowledgeDoc,
)
from ..models_task import Activity, Item, ItemTag, Tag
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
from ..serializers import item_out, tags_of
from ..services.knowledge import create_draft_from_source, find_doc_by_source
@@ -47,6 +53,7 @@ class KnowledgeIn(BaseModel):
doc_type: str = "note"
source_item_id: uuid.UUID | None = None
tag_names: list[str] = Field(default_factory=list)
category_id: uuid.UUID | None = None
class KnowledgePatch(BaseModel):
@@ -55,6 +62,24 @@ class KnowledgePatch(BaseModel):
summary: str | None = None
doc_type: str | None = None
tag_names: list[str] | None = None
category_id: uuid.UUID | None = None
# 单独一个开关:`category_id=None` 既可能表示"没改",也可能表示"清空分类",
# 靠 pydantic 的未传/传 null 区分太隐晦,不如显式给一个。
clear_category: bool = False
version: int
class CategoryIn(BaseModel):
name: str = Field(min_length=1, max_length=40)
parent_id: uuid.UUID | None = None
sort_order: int = 0
class CategoryPatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=40)
parent_id: uuid.UUID | None = None
move: bool = False
sort_order: int | None = None
version: int
@@ -112,6 +137,7 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
"status_hint": doc.status_hint,
"source_kind": doc.source_kind,
"source_item_id": str(doc.source_item_id) if doc.source_item_id else None,
"category_id": str(doc.category_id) if doc.category_id else None,
"generated_by": doc.generated_by,
"published_at": doc.published_at,
"archived_at": doc.archived_at,
@@ -120,12 +146,96 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
}
# ── 分类 ────────────────────────────────────────────────────────────────
async def _categories(
session: AsyncSession, ws_id: uuid.UUID
) -> dict[uuid.UUID, KnowledgeCategory]:
"""一次把分类全捞出来。分类是工作区级的小字典(几十条),比按需查祖先便宜。"""
rows = (
await session.execute(
select(KnowledgeCategory).where(KnowledgeCategory.workspace_id == ws_id)
)
).scalars()
return {row.id: row for row in rows}
def _category_payload(
cat: KnowledgeCategory, by_id: dict[uuid.UUID, KnowledgeCategory]
) -> dict[str, Any]:
"""分类带上完整路径:`研发 / 部署 / 回滚`。客户端不用自己去拼。"""
names: list[str] = []
node: KnowledgeCategory | None = cat
while node is not None and len(names) < MAX_CATEGORY_DEPTH:
names.append(node.name)
node = by_id.get(node.parent_id) if node.parent_id else None
names.reverse()
return {
"id": str(cat.id),
"parent_id": str(cat.parent_id) if cat.parent_id else None,
"name": cat.name,
"depth": cat.depth,
"sort_order": cat.sort_order,
"version": cat.version,
"path": names,
"full_path": " / ".join(names),
}
def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]:
children: dict[uuid.UUID, list[uuid.UUID]] = {}
for cat in by_id.values():
if cat.parent_id:
children.setdefault(cat.parent_id, []).append(cat.id)
out = [root_id]
stack = [root_id]
while stack:
for child in children.get(stack.pop(), []):
out.append(child)
stack.append(child)
return out
async def _resolve_category(
session: AsyncSession, ws_id: uuid.UUID, category_id: uuid.UUID | None
) -> KnowledgeCategory | None:
if category_id is None:
return None
cat = await session.get(KnowledgeCategory, category_id)
if cat is None or cat.workspace_id != ws_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
return cat
async def _category_name_taken(
session: AsyncSession,
ws_id: uuid.UUID,
parent_id: uuid.UUID | None,
name: str,
exclude: uuid.UUID | None = None,
) -> bool:
stmt = select(KnowledgeCategory.id).where(
KnowledgeCategory.workspace_id == ws_id,
func.lower(KnowledgeCategory.name) == name.strip().lower(),
)
stmt = (
stmt.where(KnowledgeCategory.parent_id.is_(None))
if parent_id is None
else stmt.where(KnowledgeCategory.parent_id == parent_id)
)
if exclude is not None:
stmt = stmt.where(KnowledgeCategory.id != exclude)
return (await session.execute(stmt)).first() is not None
async def _knowledge_out(
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
) -> dict[str, Any]:
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
data = item_out(item, tags)
data["knowledge"] = _doc_out(doc)
by_id = await _categories(session, ws_id)
cat = by_id.get(doc.category_id) if doc.category_id else None
data["category"] = _category_payload(cat, by_id) if cat else None
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
data["attachments"] = await list_for_item(session, ws_id, item.id)
data["attachment_count"] = len(data["attachments"])
@@ -148,6 +258,9 @@ async def list_knowledge(
status: Annotated[list[str] | None, Query()] = None,
doc_type: Annotated[str | None, Query()] = None,
source_item_id: Annotated[uuid.UUID | None, Query()] = None,
category_id: Annotated[uuid.UUID | None, Query(description="含子分类")] = None,
uncategorized: Annotated[bool, Query(description="只看未分类")] = False,
tag: Annotated[str | None, Query(max_length=32)] = None,
q: Annotated[str | None, Query(max_length=100)] = None,
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None,
@@ -165,6 +278,21 @@ async def list_knowledge(
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
if source_item_id:
stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id)
by_id: dict[uuid.UUID, KnowledgeCategory] = {}
if uncategorized:
stmt = stmt.where(KnowledgeDoc.category_id.is_(None))
elif category_id is not None:
by_id = await _categories(session, ws_id)
cat = by_id.get(category_id)
if cat is None:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
stmt = stmt.where(KnowledgeDoc.category_id.in_(_subtree_ids(category_id, by_id)))
if tag:
stmt = (
stmt.join(ItemTag, ItemTag.item_id == Item.id)
.join(Tag, Tag.id == ItemTag.tag_id)
.where(Tag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower())
)
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(
@@ -188,12 +316,16 @@ async def list_knowledge(
has_more = len(rows) > limit
rows = rows[:limit]
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
if not by_id:
by_id = await _categories(session, ws_id)
items = []
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
for item, doc in rows:
data = item_out(item, tag_map.get(item.id, []))
data["knowledge"] = _doc_out(doc)
cat = by_id.get(doc.category_id) if doc.category_id else None
data["category"] = _category_payload(cat, by_id) if cat else None
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
data["attachment_count"] = counts.get(str(item.id), 0)
items.append(data)
@@ -204,6 +336,207 @@ async def list_knowledge(
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
# ── 分类的增删改查 ──────────────────────────────────────────────────────
# 路由顺序:`/knowledge/categories` 必须排在 `/knowledge/{item_id}` 之前,
# 否则 "categories" 会被当成 uuid 解析,直接 422。
@router.get("/knowledge/categories", summary="知识分类")
async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
by_id = await _categories(session, ctx.ws_id)
counts = dict(
(
await session.execute(
select(KnowledgeDoc.category_id, func.count())
.join(Item, Item.id == KnowledgeDoc.item_id)
.where(
KnowledgeDoc.workspace_id == ctx.ws_id,
KnowledgeDoc.category_id.is_not(None),
KnowledgeDoc.status_hint != "archived",
Item.deleted_at.is_(None),
)
.group_by(KnowledgeDoc.category_id)
)
).all()
)
items = []
for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)):
data = _category_payload(cat, by_id)
data["doc_count"] = counts.get(cat.id, 0)
items.append(data)
return ok({"items": items, "max_depth": MAX_CATEGORY_DEPTH})
@router.post("/knowledge/categories", summary="新建分类")
async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep) -> dict:
ws_id = ctx.ws_id
depth = 1
if body.parent_id is not None:
parent = await _resolve_category(session, ws_id, body.parent_id)
depth = parent.depth + 1
if depth > MAX_CATEGORY_DEPTH:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
f"分类最多 {MAX_CATEGORY_DEPTH} 级,「{parent.name}」下面不能再建了",
details={"parent_depth": parent.depth},
)
name = body.name.strip()
if await _category_name_taken(session, ws_id, body.parent_id, name):
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
cat = KnowledgeCategory(
workspace_id=ws_id,
parent_id=body.parent_id,
name=name,
depth=depth,
sort_order=body.sort_order,
)
session.add(cat)
await session.flush()
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action="knowledge.category.create",
target_type="knowledge_category",
target_id=str(cat.id),
)
by_id = await _categories(session, ws_id)
return ok(_category_payload(cat, by_id))
@router.patch("/knowledge/categories/{category_id}", summary="改名或移动分类")
async def patch_category(
category_id: uuid.UUID, body: CategoryPatch, ctx: MemberDep, session: SessionDep
) -> dict:
ws_id = ctx.ws_id
cat = await session.get(KnowledgeCategory, category_id)
if cat is None or cat.workspace_id != ws_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
by_id = await _categories(session, ws_id)
now = datetime.now(timezone.utc)
new_parent_id = cat.parent_id
new_depth = cat.depth
shift = 0
subtree: list[uuid.UUID] = []
if body.move:
new_parent_id = body.parent_id
if new_parent_id is not None and new_parent_id not in by_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
if new_parent_id == category_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类挂到自己下面")
subtree = _subtree_ids(category_id, by_id)
if new_parent_id is not None and new_parent_id in subtree:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类移到它自己的子分类下")
new_depth = by_id[new_parent_id].depth + 1 if new_parent_id else 1
deepest = max(by_id[cid].depth for cid in subtree)
shift = new_depth - cat.depth
if deepest + shift > MAX_CATEGORY_DEPTH:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
f"移动后子树最深到第 {deepest + shift} 级,超过 {MAX_CATEGORY_DEPTH} 级",
details={"deepest_after_move": deepest + shift},
)
name = body.name.strip() if body.name is not None else cat.name
if name != cat.name and await _category_name_taken(
session, ws_id, new_parent_id, name, exclude=category_id
):
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
values: dict[str, Any] = {"name": name, "updated_at": now}
if body.move:
values.update(parent_id=new_parent_id, depth=new_depth)
if body.sort_order is not None:
values["sort_order"] = body.sort_order
result = await session.execute(
update(KnowledgeCategory)
.where(
KnowledgeCategory.id == category_id,
KnowledgeCategory.workspace_id == ws_id,
KnowledgeCategory.version == body.version,
)
.values(version=KnowledgeCategory.version + 1, **values)
)
if result.rowcount == 0:
await session.refresh(cat)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这个分类刚被改过",
details={"current_version": cat.version},
)
# 子树跟着整体升降;只改被移动的那棵子树以外的行会留下 depth 不一致
if body.move and shift:
for cid in subtree:
if cid == category_id:
continue
await session.execute(
update(KnowledgeCategory)
.where(KnowledgeCategory.id == cid, KnowledgeCategory.workspace_id == ws_id)
.values(
depth=KnowledgeCategory.depth + shift,
updated_at=now,
version=KnowledgeCategory.version + 1,
)
)
await session.flush()
fresh = await _categories(session, ws_id)
return ok(_category_payload(fresh[category_id], fresh))
@router.delete("/knowledge/categories/{category_id}", summary="删除分类")
async def delete_category(
category_id: uuid.UUID,
ctx: MemberDep,
session: SessionDep,
force: Annotated[bool, Query(description="真删:里面的知识变成未分类")] = False,
) -> dict:
ws_id = ctx.ws_id
cat = await session.get(KnowledgeCategory, category_id)
if cat is None or cat.workspace_id != ws_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
children = (
await session.execute(
select(func.count())
.select_from(KnowledgeCategory)
.where(
KnowledgeCategory.workspace_id == ws_id,
KnowledgeCategory.parent_id == category_id,
)
)
).scalar_one()
if children:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
f"「{cat.name}」下面还有 {children} 个子分类,先把它们移走或删掉",
details={"children": children},
)
docs = (
await session.execute(
select(func.count())
.select_from(KnowledgeDoc)
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.category_id == category_id)
)
).scalar_one()
if docs and not force:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
f"「{cat.name}」里还有 {docs} 条知识,先移走,或者带 force 删成未分类",
details={"doc_count": docs},
)
await session.delete(cat)
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action="knowledge.category.delete",
target_type="knowledge_category",
target_id=str(category_id),
payload={"orphaned_docs": docs},
)
return ok({"deleted": str(category_id), "uncategorized_docs": docs})
# 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
# 否则 "search" 会被当成 uuid 解析,返回 422。
@router.get("/knowledge/search", summary="检索已发布知识")
@@ -257,6 +590,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
"来源必须是与本知识同一工作区的任务或工单",
)
category = await _resolve_category(session, ws_id, body.category_id)
item = Item(
workspace_id=ws_id,
@@ -278,6 +612,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
doc_type=body.doc_type,
status_hint="draft",
summary=body.summary,
category_id=category.id if category else None,
generated_by="human",
)
session.add(doc)
@@ -320,7 +655,10 @@ async def patch_knowledge(
if body.doc_type is not None:
values["doc_type"] = body.doc_type
if values:
# 分类/标签也改的是这条知识的"样子",所以同样要推进 version:
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
if values or meta_changed:
values["updated_at"] = datetime.now(timezone.utc)
result = await session.execute(
update(Item)
@@ -344,6 +682,11 @@ async def patch_knowledge(
doc.doc_type = body.doc_type
if body.summary is not None:
doc.summary = body.summary
if body.clear_category:
doc.category_id = None
elif body.category_id is not None:
category = await _resolve_category(session, ws_id, body.category_id)
doc.category_id = category.id if category else None
if body.tag_names is not None:
await _set_tags_for(session, ws_id, item, body.tag_names)
await session.flush()