From 74a0508755fad48639ba14cd526340b5a160bc1d Mon Sep 17 00:00:00 2001
From: simonkoson <28867558@qq.com>
Date: Wed, 27 May 2026 09:35:36 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=BA=93=E6=A0=91?=
=?UTF-8?q?=E5=BD=A2=E8=A7=86=E5=9B=BE=EF=BC=88=E6=8C=89=E6=9D=A5=E6=BA=90?=
=?UTF-8?q?=E5=88=86=E7=BB=84=EF=BC=89=EF=BC=8C=E5=B7=A6=E4=BE=A7=E6=A0=91?=
=?UTF-8?q?=E5=AF=BC=E8=88=AA+=E5=8F=B3=E4=BE=A7=E5=88=97=E8=A1=A8?=
=?UTF-8?q?=E8=81=94=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/knowledge.py | 16 +-
backend/app/services/knowledge_service.py | 86 ++++++++++-
.../KnowledgeTree/KnowledgeTree.css | 44 ++++++
.../KnowledgeTree/KnowledgeTree.jsx | 130 ++++++++++++++++
frontend/src/hooks/useKnowledgeGrouping.js | 102 ++++++++++++
.../src/pages/KnowledgeBase/KnowledgeBase.jsx | 146 +++++++++++-------
frontend/src/services/knowledgeService.js | 8 +
7 files changed, 477 insertions(+), 55 deletions(-)
create mode 100644 frontend/src/components/KnowledgeTree/KnowledgeTree.css
create mode 100644 frontend/src/components/KnowledgeTree/KnowledgeTree.jsx
create mode 100644 frontend/src/hooks/useKnowledgeGrouping.js
diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py
index 64d59ef..910adae 100644
--- a/backend/app/api/knowledge.py
+++ b/backend/app/api/knowledge.py
@@ -89,4 +89,18 @@ def list_distinct_sources(
"""
svc = KnowledgeService()
sources = svc.get_distinct_sources()
- return [{"source": s} for s in sources]
\ No newline at end of file
+ return [{"source": s} for s in sources]
+
+
+@router.get("/grouped")
+def get_grouped_knowledge_items(
+ session: Session = Depends(get_session),
+ current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
+):
+ """
+ 返回按 source_type → source_detail 两层聚合的树形结构,
+ 含「全部」根节点,供知识库树形导航使用。
+ 三角色均可读。
+ """
+ svc = KnowledgeService()
+ return svc.get_grouped_items()
diff --git a/backend/app/services/knowledge_service.py b/backend/app/services/knowledge_service.py
index 3da2fd6..11058c1 100644
--- a/backend/app/services/knowledge_service.py
+++ b/backend/app/services/knowledge_service.py
@@ -239,4 +239,88 @@ class KnowledgeService:
def get_embedding_count(self) -> int:
with Session(engine) as session:
- return len(session.exec(select(KnowledgeEmbedding)).all())
\ No newline at end of file
+ return len(session.exec(select(KnowledgeEmbedding)).all())
+
+ def get_grouped_items(self) -> list[dict]:
+ """
+ 按 source_type → source_detail 两层聚合,返回树形结构数据。
+
+ 返回格式:
+ [
+ {
+ "key": "全部",
+ "label": "全部(N条)",
+ "count": 总条目数,
+ "children": [
+ {
+ "key": "manuscript",
+ "label": "节目文稿(n条)",
+ "count": n,
+ "children": [ # source_detail 为 null 时直接为空数组,不造空节点
+ {"key": "...", "label": "具体出处", "count": n}
+ ]
+ },
+ ...
+ ]
+ }
+ ]
+
+ 注意:source_detail 为 null 的条目(如节目文稿、报题单),
+ 归入对应 source_type 大类下,但不单独成子节点(大类的 children 直接为其条目列表,
+ 但前端 Tree 组件渲染时,若 children 为空则不展示二级节点,大类直接可点击)。
+ """
+ SOURCE_TYPE_LABEL = {
+ "military_report": "杂志文章",
+ "manuscript": "节目文稿",
+ "baoti": "报题单",
+ "manual": "其他",
+ }
+
+ with Session(engine) as session:
+ items = session.exec(select(KnowledgeItem)).all()
+
+ total_count = len(items)
+
+ # 按 source_type 分组
+ type_groups: dict = {}
+ for item in items:
+ st = item.source_type or "manual"
+ if st not in type_groups:
+ type_groups[st] = []
+ type_groups[st].append(item)
+
+ children = []
+ for st, st_items in type_groups.items():
+ # 按 source_detail 细分
+ detail_groups: dict = {}
+ for item in st_items:
+ tags = item.tags or {}
+ sd = tags.get("source_detail") if isinstance(tags, dict) else None
+ if sd not in detail_groups:
+ detail_groups[sd] = []
+ detail_groups[sd].append(item)
+
+ # 构建二级节点:仅 source_detail 非 null 的才建子节点
+ grandchildren = []
+ for sd, sd_items in detail_groups.items():
+ if sd is not None:
+ grandchildren.append({
+ "key": f"{st}|{sd}",
+ "label": f"{sd}({len(sd_items)}条)",
+ "count": len(sd_items),
+ })
+
+ label = SOURCE_TYPE_LABEL.get(st, st)
+ children.append({
+ "key": st,
+ "label": f"{label}({len(st_items)}条)",
+ "count": len(st_items),
+ "children": grandchildren,
+ })
+
+ return [{
+ "key": "all",
+ "label": f"全部({total_count}条)",
+ "count": total_count,
+ "children": children,
+ }]
diff --git a/frontend/src/components/KnowledgeTree/KnowledgeTree.css b/frontend/src/components/KnowledgeTree/KnowledgeTree.css
new file mode 100644
index 0000000..5986712
--- /dev/null
+++ b/frontend/src/components/KnowledgeTree/KnowledgeTree.css
@@ -0,0 +1,44 @@
+.kt-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.kt-header {
+ padding: 8px 12px;
+ border-bottom: 1px solid #f0f0f0;
+ background: #fafafa;
+}
+
+.kt-tree-wrapper {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px 4px;
+}
+
+.kt-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 120px;
+ color: #999;
+ font-size: 13px;
+}
+
+/* 节点文字 */
+.kt-node-title {
+ font-size: 13px;
+ color: #3b4a3b;
+}
+
+/* 全部根节点高亮 */
+.kt-node-root {
+ font-weight: 600;
+ color: #3b4a3b;
+}
+
+/* Tree 选中态 */
+.kt-container .ant-tree-node-selected .kt-node-title {
+ color: #6b8e6b;
+ font-weight: 600;
+}
\ No newline at end of file
diff --git a/frontend/src/components/KnowledgeTree/KnowledgeTree.jsx b/frontend/src/components/KnowledgeTree/KnowledgeTree.jsx
new file mode 100644
index 0000000..94b4459
--- /dev/null
+++ b/frontend/src/components/KnowledgeTree/KnowledgeTree.jsx
@@ -0,0 +1,130 @@
+/**
+ * 知识库树形导航组件(交互层)
+ *
+ * 职责:
+ * - 树的展开/收起/记忆状态
+ * - 节点高亮 + 联动回调
+ * - 「全部展开 / 全部收起」操作按钮
+ *
+ * 数据分组逻辑完全委托给 useKnowledgeGrouping(数据分组层),
+ * 本组件只管交互,不感知分组的业务含义。
+ * 将来切换分组维度时,只改 useKnowledgeGrouping,本组件一行不动。
+ */
+
+import { useState, useCallback } from 'react'
+import { Tree, Button, Space } from 'antd'
+import { CaretDownFilled, NodeIndexOutlined } from '@ant-design/icons'
+import './KnowledgeTree.css'
+
+const { TreeNode } = Tree
+
+export default function KnowledgeTree({
+ treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点)
+ onNodeSelect, // 选中节点时回调,参数: { key, type, detail } | null(全部)
+ selectedKey, // 当前选中的 key(用于高亮)
+}) {
+ const [expandedKeys, setExpandedKeys] = useState(() => {
+ // 默认全部展开
+ if (!treeData || treeData.length === 0) return []
+ const root = treeData[0]
+ return [root.key, ...(root.children || []).map(c => c.key)]
+ })
+
+ // 全部展开
+ const handleExpandAll = useCallback(() => {
+ if (!treeData || treeData.length === 0) return
+ const root = treeData[0]
+ const allKeys = [root.key]
+ for (const child of root.children || []) {
+ allKeys.push(child.key)
+ if (child.children) {
+ for (const grandchild of child.children) {
+ allKeys.push(grandchild.key)
+ }
+ }
+ }
+ setExpandedKeys(allKeys)
+ }, [treeData])
+
+ // 全部收起
+ const handleCollapseAll = useCallback(() => {
+ setExpandedKeys([])
+ }, [])
+
+ // 节点选择
+ const handleSelect = useCallback((selectedKeys, info) => {
+ if (selectedKeys.length === 0) {
+ onNodeSelect(null)
+ return
+ }
+ const key = selectedKeys[0]
+
+ // 解析 key 格式:
+ // "all" → 全部根节点
+ // "manuscript" → 大类节点
+ // "manuscript|具体出处" → 二级节点
+ if (key === 'all') {
+ onNodeSelect(null)
+ return
+ }
+
+ const parts = key.split('|')
+ const type = parts[0]
+ const detail = parts[1] || null
+ onNodeSelect({ key, type, detail })
+ }, [onNodeSelect])
+
+ // 渲染节点
+ const renderNode = (node, isRoot = false) => {
+ return (
+
上传 md 笔记 · 语义检索 · 报题参考
@@ -192,47 +214,65 @@ export default function KnowledgeBase() { - {/* 筛选栏 */} -