fix: 回退Grid布局恢复flex并列,保留二级分组映射,空大类隐藏

This commit is contained in:
simonkoson
2026-05-27 14:35:32 +08:00
parent fff2cfe395
commit 347a06e48b
3 changed files with 57 additions and 86 deletions
+5 -11
View File
@@ -35,7 +35,7 @@ class KnowledgeService:
"manual", # 其他 "manual", # 其他
] ]
# 二级分组维度映射(与前端 useKnowledgeGrouping.js 的 SECONDARY_GROUP_FIELD 保持一致) # 二级分组维度映射(与前端 useKnowledgeGrouping.js 的 SECONDARY_GROUP_FIELD 一致)
# key = source_type, value = 用来做二级分组的字段名,None = 不建二级节点 # key = source_type, value = 用来做二级分组的字段名,None = 不建二级节点
SECONDARY_GROUP_FIELD = { SECONDARY_GROUP_FIELD = {
"manuscript": "author", # 节目文稿 → 按作者(编导) "manuscript": "author", # 节目文稿 → 按作者(编导)
@@ -268,20 +268,21 @@ class KnowledgeService:
def get_grouped_items(self) -> list[dict]: def get_grouped_items(self) -> list[dict]:
""" """
按 source_type → 二级字段(author / source_detail)两层聚合,返回树形结构数据。 按 source_type → 二级字段(author / source_detail)两层聚合,返回树形结构数据。
按 SOURCE_TYPE_ORDER 固定顺序排列。 按 SOURCE_TYPE_ORDER 固定顺序排列,仅显示有数据的大类(count > 0
二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}` 二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
例:manuscript|author|左鑫 例:manuscript|author|左鑫
military_report|source_detail|航空知识 2026年第1期 military_report|source_detail|航空知识 2026年第1期
二级字段值为 null / 空字串 → 归入对应大类,不造空节点。 二级字段值为 null / 空字串 → 归入对应大类,不造空节点。
空大类(0条)不渲染。
""" """
with Session(engine) as session: with Session(engine) as session:
items = session.exec(select(KnowledgeItem)).all() items = session.exec(select(KnowledgeItem)).all()
total_count = len(items) total_count = len(items)
# 按 source_type 分组,初始化所有已知类别(保证空类别也按顺序出现) # 按 source_type 分组,初始化所有已知类别
type_groups: dict = {st: [] for st in self.SOURCE_TYPE_ORDER} type_groups: dict = {st: [] for st in self.SOURCE_TYPE_ORDER}
for item in items: for item in items:
st = item.source_type or "manual" st = item.source_type or "manual"
@@ -292,14 +293,8 @@ class KnowledgeService:
children = [] children = []
for st in self.SOURCE_TYPE_ORDER: for st in self.SOURCE_TYPE_ORDER:
st_items = type_groups.get(st, []) st_items = type_groups.get(st, [])
# 空类别(0条)不渲染
if not st_items: if not st_items:
# 空类别也按顺序出现(0条)
children.append({
"key": st,
"label": f"{self.SOURCE_TYPE_LABEL.get(st, st)}0条)",
"count": 0,
"children": [],
})
continue continue
secondary_field = self.SECONDARY_GROUP_FIELD.get(st) secondary_field = self.SECONDARY_GROUP_FIELD.get(st)
@@ -309,7 +304,6 @@ class KnowledgeService:
# 按二级字段分组 # 按二级字段分组
detail_groups: dict = {} detail_groups: dict = {}
for item in st_items: for item in st_items:
# 取二级字段值:author 直接取,source_detail 从 tags(JSONB) 取
if secondary_field == "source_detail": if secondary_field == "source_detail":
tags = item.tags or {} tags = item.tags or {}
sd = tags.get("source_detail") if isinstance(tags, dict) else None sd = tags.get("source_detail") if isinstance(tags, dict) else None
@@ -6,15 +6,20 @@
* - 节点高亮 + 联动回调 * - 节点高亮 + 联动回调
* - 「全部展开 / 全部收起」操作按钮 * - 「全部展开 / 全部收起」操作按钮
* *
* 数据分组逻辑完全委托给 useKnowledgeGrouping(数据分组层), * 数据分组逻辑完全委托给后端 get_grouped_items(数据分组层),
* 本组件只管交互,不感知分组的业务含义。 * 本组件只管交互,不感知分组的业务含义。
* *
* 二级节点 key 格式(由 useKnowledgeGrouping 控制): * 二级节点 key 格式(由后端控制):
* - 大类节点: "manuscript" * - 大类节点: "manuscript"
* - 二级节点(新格式): "manuscript|author|左鑫" * - 二级节点(新格式): "manuscript|author|左鑫"
* type|二级字段名|字段值 * type|二级字段名|字段值
* - 二级节点(出处,杂志文章): "military_report|source_detail|航空知识..."
* *
* 将来切换分组维度时,只改 useKnowledgeGrouping,本组件只调整 key 解析逻辑(解包字段数)。 * 回调给 KnowledgeBase:统一返回 { type, detail }
* - 大类节点 → { type: "manuscript", detail: null }
* - 作者二级节点 → { type: "manuscript", detail: "左鑫" }
* - 出处二级节点 → { type: "military_report", detail: "航空知识..." }
* detail=author名 或 source_detail原文,与 displayedItems 过滤逻辑对齐)
*/ */
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
@@ -26,7 +31,7 @@ const { TreeNode } = Tree
export default function KnowledgeTree({ export default function KnowledgeTree({
treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点) treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点)
onNodeSelect, // 选中节点时回调,参数: { key, type, secondaryField, secondaryValue } | null(全部) onNodeSelect, // 选中节点时回调,参数: { key, type, detail } | null(全部)
selectedKey, // 当前选中的 key(用于高亮) selectedKey, // 当前选中的 key(用于高亮)
}) { }) {
const [expandedKeys, setExpandedKeys] = useState(() => { const [expandedKeys, setExpandedKeys] = useState(() => {
@@ -68,7 +73,8 @@ export default function KnowledgeTree({
// 解析 key 格式: // 解析 key 格式:
// "all" → 全部根节点 // "all" → 全部根节点
// "manuscript" → 大类节点 // "manuscript" → 大类节点
// "manuscript|author|左鑫" → 二级节点(type|二级字段名|字段值 // "manuscript|author|左鑫" → 二级节点(新格式:type|fieldName|fieldValue
// "military_report|source_detail|..." → 二级节点(出处)
if (key === 'all') { if (key === 'all') {
onNodeSelect(null) onNodeSelect(null)
return return
@@ -77,15 +83,11 @@ export default function KnowledgeTree({
const parts = key.split('|') const parts = key.split('|')
if (parts.length === 1) { if (parts.length === 1) {
// 大类节点 // 大类节点
onNodeSelect({ key, type: parts[0], secondaryField: null, secondaryValue: null }) onNodeSelect({ key, type: parts[0], detail: null })
} else { } else {
// 二级节点:新格式 type|fieldName|fieldValue // 二级节点:parts[0]=type, parts[1]=字段名(废弃不用), parts[2]=字段值
onNodeSelect({ // 统一以 parts[2] 作为 detail(作者名 或 出处名),与 displayedItems 过滤逻辑对齐
key, onNodeSelect({ key, type: parts[0], detail: parts[2] })
type: parts[0],
secondaryField: parts[1],
secondaryValue: parts[2],
})
} }
}, [onNodeSelect]) }, [onNodeSelect])
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag, Row, Col } from 'antd' import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag } from 'antd'
import { InboxOutlined, DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons' import { DeleteOutlined, ReloadOutlined, UploadOutlined } 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' import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
@@ -32,7 +32,7 @@ export default function KnowledgeBase() {
const [sources, setSources] = useState([]) // 出处下拉选项 const [sources, setSources] = useState([]) // 出处下拉选项
const [sourceTypeFilter, setSourceTypeFilter] = useState('') const [sourceTypeFilter, setSourceTypeFilter] = useState('')
const [sourceDetailFilter, setSourceDetailFilter] = useState('') const [sourceDetailFilter, setSourceDetailFilter] = useState('')
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { key, type, secondaryField, secondaryValue } const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true) setLoading(true)
@@ -110,52 +110,30 @@ export default function KnowledgeBase() {
} }
// 树节点选中 → 联动过滤 // 树节点选中 → 联动过滤
// 回调参数:{ key, type, secondaryField, secondaryValue } | null
const handleNodeSelect = (node) => { const handleNodeSelect = (node) => {
setSelectedTreeNode(node) setSelectedTreeNode(node)
} }
// 根据树节点过滤列表 // 根据树节点过滤列表
// 二级节点 key 格式:type|fieldName|fieldValue
// secondaryField === 'source_detail' → 用 item.source_detail 过滤
// secondaryField === 'author' → 用 item.author 过滤
const displayedItems = (() => { const displayedItems = (() => {
let result = items let result = items
// 树节点过滤优先(覆盖原有 Select 筛选)
if (selectedTreeNode) { if (selectedTreeNode) {
const { type, secondaryField, secondaryValue } = selectedTreeNode const { type, detail } = selectedTreeNode
result = result.filter(i => { result = result.filter(i => {
if (i.source_type !== type) return false if (i.source_type !== type) return false
if (secondaryField !== null && secondaryValue !== null) { if (detail !== null && i.source_detail !== detail) return false
if (secondaryField === 'source_detail') {
if (i.source_detail !== secondaryValue) return false
} else {
// 其他二级字段(如 author)直接用字段名匹配
const itemFieldVal = (i[secondaryField] || '').trim() || null
if (itemFieldVal !== secondaryValue) return false
}
}
return true return true
}) })
} else if (sourceDetailFilter) { } else if (sourceDetailFilter) {
// 保留原有的 source_detail 筛选逻辑(来自右侧 Select 下拉) // 保留原有的 source_detail 筛选逻辑
result = result.filter(i => i.source_detail === sourceDetailFilter) result = result.filter(i => i.source_detail === sourceDetailFilter)
} }
return result return result
})() })()
// 构建 selectedKey 供 KnowledgeTree 高亮
// 格式:type|field|value(三段,与后端 get_grouped_items 输出的 key 一致)
const buildSelectedKey = () => {
if (!selectedTreeNode) return 'all'
const { type, secondaryField, secondaryValue } = selectedTreeNode
if (secondaryField === null || secondaryValue === null) {
return type
}
return `${type}|${secondaryField}|${secondaryValue}`
}
const columns = [ const columns = [
{ {
title: '标题', title: '标题',
@@ -220,14 +198,14 @@ export default function KnowledgeBase() {
] ]
return ( return (
<div style={{ padding: '12px 16px' }}> <div style={{ maxWidth: 1200, padding: '12px 16px' }}>
{/* 标题栏 */} {/* 标题栏 */}
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2> <h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p> <p style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
</div> </div>
{/* 上传区(收小为一行) */} {/* 上传区(收小为一行按钮 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<Dragger <Dragger
accept=".md" accept=".md"
@@ -243,27 +221,24 @@ export default function KnowledgeBase() {
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传系统自动解析 yaml frontmatter 并生成语义向量</span> <span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传系统自动解析 yaml frontmatter 并生成语义向量</span>
</div> </div>
{/* 双列对齐布局:左侧树 | 右侧筛选+列表 */} {/* 主体:左侧树 + 右侧列表flex 左右并列,宽度稳定) */}
{/* 左列固定 280px,右列撑满,ant-row gutter=12 保持两列间距 */} <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<Row gutter={12}> {/* 左侧树 */}
{/* 左列:树(固定宽度,min-width 防止收缩) */}
<Col span={5} style={{ minWidth: 280 }}>
<Card <Card
style={{ borderRadius: 14 }} style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0, height: '100%' }}
> >
<KnowledgeTree <KnowledgeTree
treeData={treeData} treeData={treeData}
onNodeSelect={handleNodeSelect} onNodeSelect={handleNodeSelect}
selectedKey={buildSelectedKey()} selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'}
/> />
</Card> </Card>
</Col>
{/* 右列:筛选栏 + 列表(两栏独立等高,顶部对齐) */} {/* 右侧列表 + 筛选栏 */}
<Col span={19} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ flex: 1, minWidth: 0 }}>
{/* 筛选栏(顶部对齐,与左列卡片顶线平齐) */} {/* 筛选栏 */}
<Card style={{ borderRadius: 14 }}> <Card style={{ borderRadius: 14, marginBottom: 12 }}>
<Space size="middle" wrap> <Space size="middle" wrap>
<span style={{ fontSize: 14, color: '#666' }}>筛选</span> <span style={{ fontSize: 14, color: '#666' }}>筛选</span>
<Select <Select
@@ -303,8 +278,8 @@ export default function KnowledgeBase() {
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }} locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
/> />
</Card> </Card>
</Col> </div>
</Row> </div>
</div> </div>
) )
} }