feat: 知识库树形视图(按来源分组),左侧树导航+右侧列表联动

This commit is contained in:
simonkoson
2026-05-27 09:35:36 +08:00
parent 7999fd14c8
commit 74a0508755
7 changed files with 477 additions and 55 deletions
+14
View File
@@ -90,3 +90,17 @@ def list_distinct_sources(
svc = KnowledgeService() svc = KnowledgeService()
sources = svc.get_distinct_sources() sources = svc.get_distinct_sources()
return [{"source": s} for s in sources] 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()
+84
View File
@@ -240,3 +240,87 @@ class KnowledgeService:
def get_embedding_count(self) -> int: def get_embedding_count(self) -> int:
with Session(engine) as session: with Session(engine) as session:
return len(session.exec(select(KnowledgeEmbedding)).all()) 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,
}]
@@ -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;
}
@@ -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 (
<TreeNode
key={node.key}
title={
<span className={`kt-node-title ${isRoot ? 'kt-node-root' : ''}`}>
{node.label}
</span>
}
icon={isRoot ? <NodeIndexOutlined /> : undefined}
>
{(node.children || []).map(child => renderNode(child, false))}
</TreeNode>
)
}
if (!treeData || treeData.length === 0) {
return (
<div className="kt-empty">
暂无知识库条目
</div>
)
}
return (
<div className="kt-container">
<div className="kt-header">
<Space size="small">
<Button size="small" onClick={handleExpandAll}>
全部展开
</Button>
<Button size="small" onClick={handleCollapseAll}>
全部收起
</Button>
</Space>
</div>
<div className="kt-tree-wrapper">
<Tree
showIcon
showLine={{ showLeafIcon: false }}
expandedKeys={expandedKeys}
selectedKeys={selectedKey ? [selectedKey] : []}
onExpand={(keys) => setExpandedKeys(keys)}
onSelect={handleSelect}
blockNode
switcherIcon={<CaretDownFilled />}
>
{treeData.map(node => renderNode(node, true))}
</Tree>
</div>
</div>
)
}
+102
View File
@@ -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,
},
]
}
@@ -3,6 +3,7 @@ import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag }
import { InboxOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons' import { InboxOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore' import useAuthStore from '../../stores/authStore'
import knowledgeService from '../../services/knowledgeService' import knowledgeService from '../../services/knowledgeService'
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
const { Dragger } = Upload const { Dragger } = Upload
@@ -25,11 +26,12 @@ const SOURCE_TYPE_LABEL = {
export default function KnowledgeBase() { export default function KnowledgeBase() {
const { user } = useAuthStore() const { user } = useAuthStore()
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [sources, setSources] = useState([]) const [treeData, setTreeData] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [sourceTypeFilter, setSourceTypeFilter] = useState('') const [sourceTypeFilter, setSourceTypeFilter] = useState('')
const [sourceDetailFilter, setSourceDetailFilter] = useState('') const [sourceDetailFilter, setSourceDetailFilter] = useState('')
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true) setLoading(true)
@@ -43,10 +45,10 @@ export default function KnowledgeBase() {
} }
} }
const fetchSources = async () => { const fetchTree = async () => {
try { try {
const data = await knowledgeService.listSources() const data = await knowledgeService.getGroupedItems()
setSources(data.map(s => ({ label: s.source, value: s.source }))) setTreeData(data || [])
} catch { } catch {
// ignore // ignore
} }
@@ -54,7 +56,7 @@ export default function KnowledgeBase() {
useEffect(() => { useEffect(() => {
fetchItems() fetchItems()
fetchSources() fetchTree()
}, [sourceTypeFilter]) }, [sourceTypeFilter])
// 上传 // 上传
@@ -70,7 +72,7 @@ export default function KnowledgeBase() {
if (uploaded.length > 0) { if (uploaded.length > 0) {
message.success(`成功入库 ${uploaded.length}`) message.success(`成功入库 ${uploaded.length}`)
fetchItems() fetchItems()
fetchSources() fetchTree()
} }
if (errors.length > 0) { if (errors.length > 0) {
errors.forEach(e => message.error(`${e.file}: ${e.error}`)) errors.forEach(e => message.error(`${e.file}: ${e.error}`))
@@ -89,16 +91,36 @@ export default function KnowledgeBase() {
await knowledgeService.deleteItem(id) await knowledgeService.deleteItem(id)
message.success('删除成功') message.success('删除成功')
fetchItems() fetchItems()
fetchSources() fetchTree()
} catch { } catch {
message.error('删除失败') message.error('删除失败')
} }
} }
// 列表筛选后过滤 source_detail // 树节点选中 → 联动过滤
const displayedItems = sourceDetailFilter const handleNodeSelect = (node) => {
? items.filter(i => i.source_detail === sourceDetailFilter) setSelectedTreeNode(node)
: items }
// 根据树节点过滤列表
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 = [ const columns = [
{ {
@@ -113,13 +135,13 @@ export default function KnowledgeBase() {
title: '作者', title: '作者',
dataIndex: 'author', dataIndex: 'author',
key: 'author', key: 'author',
render: (val) => val || null, // 无则不显示 render: (val) => val || null,
}, },
{ {
title: '播出/发表时间', title: '播出/发表时间',
dataIndex: 'publish_date', dataIndex: 'publish_date',
key: 'publish_date', key: 'publish_date',
render: (val) => val || null, // 无则不显示 render: (val) => val || null,
}, },
{ {
title: '出处', title: '出处',
@@ -164,7 +186,7 @@ export default function KnowledgeBase() {
] ]
return ( return (
<div style={{ maxWidth: 1100, padding: '12px' }}> <div style={{ maxWidth: 1200, padding: '12px' }}>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p> <p style={{ margin: '4px 0 0', fontSize: 12, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
@@ -192,47 +214,65 @@ export default function KnowledgeBase() {
</Dragger> </Dragger>
</Card> </Card>
{/* 筛选栏 */} {/* 主体:左侧树 + 右侧列表 */}
<Card style={{ borderRadius: 14, marginBottom: 12 }}> <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<Space size="middle" wrap> {/* 左侧树 */}
<span style={{ fontSize: 13, color: '#666' }}>筛选</span> <Card
<Select style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
placeholder="按类型" bodyStyle={{ padding: 0, height: '100%' }}
options={SOURCE_TYPE_OPTIONS} >
value={sourceTypeFilter} <KnowledgeTree
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter('') }} treeData={treeData}
style={{ width: 140 }} onNodeSelect={handleNodeSelect}
allowClear selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'}
/> />
<Select </Card>
placeholder="按出处"
options={[{ label: '全部出处', value: '' }, ...sources]}
value={sourceDetailFilter}
onChange={setSourceDetailFilter}
style={{ width: 200 }}
allowClear
/>
<Button
icon={<ReloadOutlined />}
onClick={() => { fetchItems(); fetchSources() }}
size="small"
>
刷新
</Button>
</Space>
</Card>
{/* 列表 */} {/* 右侧列表 + 筛选栏 */}
<Card style={{ borderRadius: 14 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Table {/* 筛选栏 */}
columns={columns} <Card style={{ borderRadius: 14, marginBottom: 12 }}>
dataSource={displayedItems} <Space size="middle" wrap>
rowKey="id" <span style={{ fontSize: 13, color: '#666' }}>筛选</span>
loading={loading} <Select
pagination={{ pageSize: 10, size: 'small' }} placeholder="按类型"
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }} options={SOURCE_TYPE_OPTIONS}
/> value={sourceTypeFilter}
</Card> onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setSelectedTreeNode(null) }}
style={{ width: 140 }}
allowClear
/>
<Select
placeholder="按出处"
options={[{ label: '全部出处', value: '' }, ...(items.length ? [] : [])]}
value={sourceDetailFilter}
onChange={val => { setSourceDetailFilter(val); setSelectedTreeNode(null) }}
style={{ width: 200 }}
allowClear
/>
<Button
icon={<ReloadOutlined />}
onClick={() => { fetchItems(); fetchTree() }}
size="small"
>
刷新
</Button>
</Space>
</Card>
{/* 列表 */}
<Card style={{ borderRadius: 14 }}>
<Table
columns={columns}
dataSource={displayedItems}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
/>
</Card>
</div>
</div>
</div> </div>
) )
} }
@@ -44,6 +44,14 @@ const knowledgeService = {
const resp = await http.get('/knowledge/sources') const resp = await http.get('/knowledge/sources')
return resp.data return resp.data
}, },
/**
* 获取按来源分组的树形结构(含「全部」根节点)
*/
async getGroupedItems() {
const resp = await http.get('/knowledge/grouped')
return resp.data
},
} }
export default knowledgeService export default knowledgeService