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 ( + + {node.label} + + } + icon={isRoot ? : undefined} + > + {(node.children || []).map(child => renderNode(child, false))} + + ) + } + + if (!treeData || treeData.length === 0) { + return ( +
+ 暂无知识库条目 +
+ ) + } + + return ( +
+
+ + + + +
+
+ setExpandedKeys(keys)} + onSelect={handleSelect} + blockNode + switcherIcon={} + > + {treeData.map(node => renderNode(node, true))} + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/hooks/useKnowledgeGrouping.js b/frontend/src/hooks/useKnowledgeGrouping.js new file mode 100644 index 0000000..e2c9ecd --- /dev/null +++ b/frontend/src/hooks/useKnowledgeGrouping.js @@ -0,0 +1,102 @@ +/** + * 知识库数据分组层(交互层与分组维度完全解耦) + * + * 本模块职责: + * - 接收 items 数组,按指定维度(当前为 source_type → source_detail)生成分组树数据 + * - 完全不涉及 UI(无 React 组件、无 Ant Design 引用) + * + * 将来切换为「按主题/装备分组」时: + * - 只改本文件中的 groupItemsBySource 函数 + * - 前端 KnowledgeTree 组件一行都不用动 + */ + +/** + * 按来源(source_type → source_detail)分组 + * @param {Array} items - 知识库条目数组,含 source_type, source_detail 字段 + * @returns {Array} 树形结构数据,供 KnowledgeTree 组件渲染 + * + * 返回格式: + * [ + * { + * key: "all", + * label: "全部(N条)", + * count: 总条目数, + * children: [ + * { + * key: "manuscript", + * label: "节目文稿(n条)", + * count: n, + * children: [ // 无 source_detail 则为空数组,不造空节点 + * { key: "manuscript|具体出处", label: "具体出处(n条)", count: n } + * ] + * }, + * ... + * ] + * } + * ] + * + * 注意:source_detail 为 null 的条目归入对应 source_type 大类, + * 但不单独成子节点(大类的 children 为空数组时,大类直接可点击)。 + */ +export function groupItemsBySource(items) { + const SOURCE_TYPE_LABEL = { + military_report: '杂志文章', + manuscript: '节目文稿', + baoti: '报题单', + manual: '其他', + } + + const totalCount = items.length + + // 按 source_type 分组 + const typeGroups = {} + for (const item of items) { + const st = item.source_type || 'manual' + if (!typeGroups[st]) { + typeGroups[st] = [] + } + typeGroups[st].push(item) + } + + const children = [] + for (const [st, stItems] of Object.entries(typeGroups)) { + // 按 source_detail 细分 + const detailGroups = {} + for (const item of stItems) { + const sd = item.source_detail || null + if (!detailGroups[sd]) { + detailGroups[sd] = [] + } + detailGroups[sd].push(item) + } + + // 构建二级节点:仅 source_detail 非 null 的才建子节点 + const grandchildren = [] + for (const [sd, sdItems] of Object.entries(detailGroups)) { + if (sd !== null) { + grandchildren.push({ + key: `${st}|${sd}`, + label: `${sd}(${sdItems.length}条)`, + count: sdItems.length, + }) + } + } + + const label = SOURCE_TYPE_LABEL[st] || st + children.push({ + key: st, + label: `${label}(${stItems.length}条)`, + count: stItems.length, + children: grandchildren, + }) + } + + return [ + { + key: 'all', + label: `全部(${totalCount}条)`, + count: totalCount, + children, + }, + ] +} \ No newline at end of file diff --git a/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx b/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx index 4a30d2b..6c45e1e 100644 --- a/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx +++ b/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx @@ -3,6 +3,7 @@ import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag } import { InboxOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons' import useAuthStore from '../../stores/authStore' import knowledgeService from '../../services/knowledgeService' +import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree' const { Dragger } = Upload @@ -25,11 +26,12 @@ const SOURCE_TYPE_LABEL = { export default function KnowledgeBase() { const { user } = useAuthStore() const [items, setItems] = useState([]) - const [sources, setSources] = useState([]) + const [treeData, setTreeData] = useState([]) const [loading, setLoading] = useState(false) const [uploading, setUploading] = useState(false) const [sourceTypeFilter, setSourceTypeFilter] = useState('') const [sourceDetailFilter, setSourceDetailFilter] = useState('') + const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail } const fetchItems = async () => { setLoading(true) @@ -43,10 +45,10 @@ export default function KnowledgeBase() { } } - const fetchSources = async () => { + const fetchTree = async () => { try { - const data = await knowledgeService.listSources() - setSources(data.map(s => ({ label: s.source, value: s.source }))) + const data = await knowledgeService.getGroupedItems() + setTreeData(data || []) } catch { // ignore } @@ -54,7 +56,7 @@ export default function KnowledgeBase() { useEffect(() => { fetchItems() - fetchSources() + fetchTree() }, [sourceTypeFilter]) // 上传 @@ -70,7 +72,7 @@ export default function KnowledgeBase() { if (uploaded.length > 0) { message.success(`成功入库 ${uploaded.length} 篇`) fetchItems() - fetchSources() + fetchTree() } if (errors.length > 0) { errors.forEach(e => message.error(`${e.file}: ${e.error}`)) @@ -89,16 +91,36 @@ export default function KnowledgeBase() { await knowledgeService.deleteItem(id) message.success('删除成功') fetchItems() - fetchSources() + fetchTree() } catch { message.error('删除失败') } } - // 列表筛选后过滤 source_detail - const displayedItems = sourceDetailFilter - ? items.filter(i => i.source_detail === sourceDetailFilter) - : items + // 树节点选中 → 联动过滤 + const handleNodeSelect = (node) => { + setSelectedTreeNode(node) + } + + // 根据树节点过滤列表 + const displayedItems = (() => { + let result = items + + // 树节点过滤优先(覆盖原有 Select 筛选) + if (selectedTreeNode) { + const { type, detail } = selectedTreeNode + result = result.filter(i => { + if (i.source_type !== type) return false + if (detail !== null && i.source_detail !== detail) return false + return true + }) + } else if (sourceDetailFilter) { + // 保留原有的 source_detail 筛选逻辑 + result = result.filter(i => i.source_detail === sourceDetailFilter) + } + + return result + })() const columns = [ { @@ -113,13 +135,13 @@ export default function KnowledgeBase() { title: '作者', dataIndex: 'author', key: 'author', - render: (val) => val || null, // 无则不显示 + render: (val) => val || null, }, { title: '播出/发表时间', dataIndex: 'publish_date', key: 'publish_date', - render: (val) => val || null, // 无则不显示 + render: (val) => val || null, }, { title: '出处', @@ -164,7 +186,7 @@ export default function KnowledgeBase() { ] return ( -
+

知识库

上传 md 笔记 · 语义检索 · 报题参考

@@ -192,47 +214,65 @@ export default function KnowledgeBase() { - {/* 筛选栏 */} - - - 筛选: - - - - + - {/* 列表 */} - - - + {/* 右侧列表 + 筛选栏 */} +
+ {/* 筛选栏 */} + + + 筛选: + { setSourceDetailFilter(val); setSelectedTreeNode(null) }} + style={{ width: 200 }} + allowClear + /> + + + + + {/* 列表 */} + +
+ + + ) } \ No newline at end of file diff --git a/frontend/src/services/knowledgeService.js b/frontend/src/services/knowledgeService.js index 84cbd54..d9bee80 100644 --- a/frontend/src/services/knowledgeService.js +++ b/frontend/src/services/knowledgeService.js @@ -44,6 +44,14 @@ const knowledgeService = { const resp = await http.get('/knowledge/sources') return resp.data }, + + /** + * 获取按来源分组的树形结构(含「全部」根节点) + */ + async getGroupedItems() { + const resp = await http.get('/knowledge/grouped') + return resp.data + }, } export default knowledgeService \ No newline at end of file