feat: 知识库树形视图(按来源分组),左侧树导航+右侧列表联动
This commit is contained in:
@@ -90,3 +90,17 @@ def list_distinct_sources(
|
||||
svc = KnowledgeService()
|
||||
sources = svc.get_distinct_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()
|
||||
|
||||
@@ -240,3 +240,87 @@ class KnowledgeService:
|
||||
def get_embedding_count(self) -> int:
|
||||
with Session(engine) as session:
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 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 (
|
||||
<div style={{ maxWidth: 1100, padding: '12px' }}>
|
||||
<div style={{ maxWidth: 1200, padding: '12px' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
|
||||
@@ -192,6 +214,22 @@ export default function KnowledgeBase() {
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
{/* 主体:左侧树 + 右侧列表 */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
{/* 左侧树 */}
|
||||
<Card
|
||||
style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
|
||||
bodyStyle={{ padding: 0, height: '100%' }}
|
||||
>
|
||||
<KnowledgeTree
|
||||
treeData={treeData}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 右侧列表 + 筛选栏 */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* 筛选栏 */}
|
||||
<Card style={{ borderRadius: 14, marginBottom: 12 }}>
|
||||
<Space size="middle" wrap>
|
||||
@@ -200,21 +238,21 @@ export default function KnowledgeBase() {
|
||||
placeholder="按类型"
|
||||
options={SOURCE_TYPE_OPTIONS}
|
||||
value={sourceTypeFilter}
|
||||
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter('') }}
|
||||
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setSelectedTreeNode(null) }}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="按出处"
|
||||
options={[{ label: '全部出处', value: '' }, ...sources]}
|
||||
options={[{ label: '全部出处', value: '' }, ...(items.length ? [] : [])]}
|
||||
value={sourceDetailFilter}
|
||||
onChange={setSourceDetailFilter}
|
||||
onChange={val => { setSourceDetailFilter(val); setSelectedTreeNode(null) }}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { fetchItems(); fetchSources() }}
|
||||
onClick={() => { fetchItems(); fetchTree() }}
|
||||
size="small"
|
||||
>
|
||||
刷新
|
||||
@@ -234,5 +272,7 @@ export default function KnowledgeBase() {
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user