feat: 知识库树形视图调整:大类固定排序、节目文稿按作者二级分组、左右Grid双列对齐

This commit is contained in:
simonkoson
2026-05-27 13:54:14 +08:00
parent b3157216bb
commit a00bcd6c73
3 changed files with 167 additions and 64 deletions
@@ -8,7 +8,13 @@
* *
* 数据分组逻辑完全委托给 useKnowledgeGrouping(数据分组层), * 数据分组逻辑完全委托给 useKnowledgeGrouping(数据分组层),
* 本组件只管交互,不感知分组的业务含义。 * 本组件只管交互,不感知分组的业务含义。
* 将来切换分组维度时,只改 useKnowledgeGrouping,本组件一行不动。 *
* 二级节点 key 格式(由 useKnowledgeGrouping 控制):
* - 大类节点: "manuscript"
* - 二级节点(新格式): "manuscript|author|左鑫"
* type|二级字段名|字段值
*
* 将来切换分组维度时,只改 useKnowledgeGrouping,本组件只调整 key 解析逻辑(解包字段数)。
*/ */
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
@@ -20,7 +26,7 @@ const { TreeNode } = Tree
export default function KnowledgeTree({ export default function KnowledgeTree({
treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点) treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点)
onNodeSelect, // 选中节点时回调,参数: { key, type, detail } | null(全部) onNodeSelect, // 选中节点时回调,参数: { key, type, secondaryField, secondaryValue } | null(全部)
selectedKey, // 当前选中的 key(用于高亮) selectedKey, // 当前选中的 key(用于高亮)
}) { }) {
const [expandedKeys, setExpandedKeys] = useState(() => { const [expandedKeys, setExpandedKeys] = useState(() => {
@@ -62,16 +68,25 @@ export default function KnowledgeTree({
// 解析 key 格式: // 解析 key 格式:
// "all" → 全部根节点 // "all" → 全部根节点
// "manuscript" → 大类节点 // "manuscript" → 大类节点
// "manuscript|具体出处" → 二级节点 // "manuscript|author|左鑫" → 二级节点(type|二级字段名|字段值)
if (key === 'all') { if (key === 'all') {
onNodeSelect(null) onNodeSelect(null)
return return
} }
const parts = key.split('|') const parts = key.split('|')
const type = parts[0] if (parts.length === 1) {
const detail = parts[1] || null // 大类节点
onNodeSelect({ key, type, detail }) onNodeSelect({ key, type: parts[0], secondaryField: null, secondaryValue: null })
} else {
// 二级节点:新格式 type|fieldName|fieldValue
onNodeSelect({
key,
type: parts[0],
secondaryField: parts[1],
secondaryValue: parts[2],
})
}
}, [onNodeSelect]) }, [onNodeSelect])
// 渲染节点 // 渲染节点
+92 -23
View File
@@ -2,7 +2,7 @@
* 知识库数据分组层(交互层与分组维度完全解耦) * 知识库数据分组层(交互层与分组维度完全解耦)
* *
* 本模块职责: * 本模块职责:
* - 接收 items 数组,按指定维度(当前为 source_type → source_detail生成分组树数据 * - 接收 items 数组,按指定维度生成分组树数据
* - 完全不涉及 UI(无 React 组件、无 Ant Design 引用) * - 完全不涉及 UI(无 React 组件、无 Ant Design 引用)
* *
* 将来切换为「按主题/装备分组」时: * 将来切换为「按主题/装备分组」时:
@@ -11,8 +11,54 @@
*/ */
/** /**
* 按来源(source_type → source_detail)分组 * ============================================================
* @param {Array} items - 知识库条目数组,含 source_type, source_detail 字段 * 📌 二级分组维度映射表(核心配置,一处可改)
* ============================================================
* key = source_typevalue = 用来做二级分组的字段名(取 items 里的字段)
* null = 该大类不建二级节点,条目直接挂在大类下
*
* 当前配置:
* - manuscript(节目文稿) → 按 author(作者/编导)归堆
* - military_report(杂志文章)→ 按 source_detail(出处)归堆
* - baoti(报题单) → 不分组
* - manual(其他) → 不分组
*
* 将来调整某类的二级维度,只需改这一张表,本文件分组逻辑全部收敛。
*/
const SECONDARY_GROUP_FIELD = {
manuscript: 'author', // 节目文稿 → 按作者(编导)
military_report: 'source_detail', // 杂志文章 → 按出处
baoti: null, // 报题单 → 不分组
manual: null, // 其他 → 不分组
}
/**
* ============================================================
* 📌 来源大类固定显示顺序(制片人 Obsidian 习惯)
* ============================================================
* 即使某类暂时为空也按此顺序; 有数据时即按此次序排列。
* 已有类别按此次序排,未知类别兜底排末尾。
*/
const SOURCE_TYPE_ORDER = [
'manuscript', // 节目文稿
'military_report', // 杂志文章
'baoti', // 报题单
'manual', // 其他
]
/**
* source_type 中文标签
*/
const SOURCE_TYPE_LABEL = {
military_report: '杂志文章',
manuscript: '节目文稿',
baoti: '报题单',
manual: '其他',
}
/**
* 按来源(source_type → 二级字段)分组
* @param {Array} items - 知识库条目数组,含 source_type 字段,及 SECONDARY_GROUP_FIELD 中指定的二级字段
* @returns {Array} 树形结构数据,供 KnowledgeTree 组件渲染 * @returns {Array} 树形结构数据,供 KnowledgeTree 组件渲染
* *
* 返回格式: * 返回格式:
@@ -26,8 +72,8 @@
* key: "manuscript", * key: "manuscript",
* label: "节目文稿(n条)", * label: "节目文稿(n条)",
* count: n, * count: n,
* children: [ // 无 source_detail 为空数组,不造空节点 * children: [ // SECONDARY_GROUP_FIELD[st] 为 null 为空数组
* { key: "manuscript|具体出处", label: "具体出处(n条)", count: n } * { key: "manuscript|author|左鑫", label: "左鑫(3条)", count: 3 }
* ] * ]
* }, * },
* ... * ...
@@ -35,23 +81,27 @@
* } * }
* ] * ]
* *
* 注意:source_detail 为 null 的条目归入对应 source_type 大类, * 二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
* 但不单独成子节点(大类的 children 为空数组时,大类直接可点击)。 * 例:manuscript|author|左鑫
* military_report|source_detail|航空知识 2026年第1期
*
* 注意:
* - 二级字段值为 null / 空字串 → 归入对应大类,不造空节点
* - 无效的 source_type → 归入 'manual' 大类
*/ */
export function groupItemsBySource(items) { export function groupItemsBySource(items) {
const SOURCE_TYPE_LABEL = {
military_report: '杂志文章',
manuscript: '节目文稿',
baoti: '报题单',
manual: '其他',
}
const totalCount = items.length const totalCount = items.length
// 按 source_type 分组 // 按 source_type 分组(按固定顺序遍历)
const typeGroups = {} const typeGroups = {}
// 初始化所有已知类别,确保空类别也出现在树中
for (const st of SOURCE_TYPE_ORDER) {
typeGroups[st] = []
}
for (const item of items) { for (const item of items) {
const st = item.source_type || 'manual' const st = item.source_type || 'manual'
// 未知类别兜底(如 'journal' 等新加类型)
if (!typeGroups[st]) { if (!typeGroups[st]) {
typeGroups[st] = [] typeGroups[st] = []
} }
@@ -59,33 +109,52 @@ export function groupItemsBySource(items) {
} }
const children = [] const children = []
for (const [st, stItems] of Object.entries(typeGroups)) {
// 按 source_detail 细分 // 按固定顺序遍历 SOURCE_TYPE_ORDER
for (const st of SOURCE_TYPE_ORDER) {
const stItems = typeGroups[st] || []
if (stItems.length === 0) {
// 空类别也按顺序出现在树中(0条)
children.push({
key: st,
label: `${SOURCE_TYPE_LABEL[st] || st}0条)`,
count: 0,
children: [],
})
continue
}
const secondaryField = SECONDARY_GROUP_FIELD[st] || null
const grandchildren = []
if (secondaryField !== null) {
// 按二级字段分组
const detailGroups = {} const detailGroups = {}
for (const item of stItems) { for (const item of stItems) {
const sd = item.source_detail || null // 取二级字段值,null/空字串统一为 null 处理(不造空节点)
const sd = (item[secondaryField] || '').trim() || null
if (!detailGroups[sd]) { if (!detailGroups[sd]) {
detailGroups[sd] = [] detailGroups[sd] = []
} }
detailGroups[sd].push(item) detailGroups[sd].push(item)
} }
// 构建二级节点:仅 source_detail 非 null 的才建子节点
const grandchildren = []
for (const [sd, sdItems] of Object.entries(detailGroups)) { for (const [sd, sdItems] of Object.entries(detailGroups)) {
if (sd !== null) { if (sd !== null) {
// 二级节点 key 格式:type|fieldName|fieldValue
grandchildren.push({ grandchildren.push({
key: `${st}|${sd}`, key: `${st}|${secondaryField}|${sd}`,
label: `${sd}${sdItems.length}条)`, label: `${sd}${sdItems.length}条)`,
count: sdItems.length, count: sdItems.length,
}) })
} }
} }
}
// secondaryField 为 null 时,grandchildren 保持为空数组 → 大类直接可点击
const label = SOURCE_TYPE_LABEL[st] || st
children.push({ children.push({
key: st, key: st,
label: `${label}${stItems.length}条)`, label: `${SOURCE_TYPE_LABEL[st] || st}${stItems.length}条)`,
count: stItems.length, count: stItems.length,
children: grandchildren, children: grandchildren,
}) })
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag } from 'antd' import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag, Row, Col } from 'antd'
import { InboxOutlined, DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons' import { InboxOutlined, 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'
@@ -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) // { type, detail } const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, secondaryField, secondaryValue }
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true) setLoading(true)
@@ -110,20 +110,26 @@ export default function KnowledgeBase() {
} }
// 树节点选中 → 联动过滤 // 树节点选中 → 联动过滤
// 新格式回调:{ key, type, secondaryField, secondaryValue }
const handleNodeSelect = (node) => { const handleNodeSelect = (node) => {
setSelectedTreeNode(node) setSelectedTreeNode(node)
} }
// 根据树节点过滤列表 // 根据树节点过滤列表
// 支持新旧两种 key 格式兼容(secondaryField 为 null 时走旧逻辑)
const displayedItems = (() => { const displayedItems = (() => {
let result = items let result = items
// 树节点过滤优先(覆盖原有 Select 筛选)
if (selectedTreeNode) { if (selectedTreeNode) {
const { type, detail } = selectedTreeNode const { type, secondaryField, secondaryValue } = selectedTreeNode
result = result.filter(i => { result = result.filter(i => {
if (i.source_type !== type) return false if (i.source_type !== type) return false
if (detail !== null && i.source_detail !== detail) return false // 二级过滤:新格式(type|field|value
if (secondaryField !== null && secondaryValue !== null) {
// 按指定二级字段过滤
const itemFieldVal = (i[secondaryField] || '').trim() || null
if (itemFieldVal !== secondaryValue) return false
}
return true return true
}) })
} else if (sourceDetailFilter) { } else if (sourceDetailFilter) {
@@ -134,6 +140,17 @@ export default function KnowledgeBase() {
return result return result
})() })()
// 构建 selectedKey 供 KnowledgeTree 高亮
// 格式:type|field|value(与 useKnowledgeGrouping 输出的 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: '标题',
@@ -221,22 +238,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>
{/* 主体:左侧树 + 右侧列表 */} {/* 双列对齐布局:左侧树 | 右侧筛选+列表 */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}> <Row gutter={12} align="stretch">
{/* 左树 */} {/* 左列:树 */}
<Col span={5} style={{ display: 'flex', flexDirection: 'column' }}>
<Card <Card
style={{ borderRadius: 14, width: 280, flexShrink: 0 }} style={{ borderRadius: 14, flex: 1 }}
bodyStyle={{ padding: 0, height: '100%' }} bodyStyle={{ padding: 0, flex: 1, display: 'flex', flexDirection: 'column' }}
> >
<KnowledgeTree <KnowledgeTree
treeData={treeData} treeData={treeData}
onNodeSelect={handleNodeSelect} onNodeSelect={handleNodeSelect}
selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'} selectedKey={buildSelectedKey()}
/> />
</Card> </Card>
</Col>
{/* 右侧列表 + 筛选栏 */} {/* 右列:筛选栏 + 列表 */}
<div style={{ flex: 1, minWidth: 0 }}> <Col span={19} style={{ display: 'flex', flexDirection: 'column' }}>
{/* 筛选栏 */} {/* 筛选栏 */}
<Card style={{ borderRadius: 14, marginBottom: 12 }}> <Card style={{ borderRadius: 14, marginBottom: 12 }}>
<Space size="middle" wrap> <Space size="middle" wrap>
@@ -278,8 +297,8 @@ export default function KnowledgeBase() {
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }} locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
/> />
</Card> </Card>
</div> </Col>
</div> </Row>
</div> </div>
) )
} }