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,本组件一行不动。
*
* 二级节点 key 格式(由 useKnowledgeGrouping 控制):
* - 大类节点: "manuscript"
* - 二级节点(新格式): "manuscript|author|左鑫"
* type|二级字段名|字段值
*
* 将来切换分组维度时,只改 useKnowledgeGrouping,本组件只调整 key 解析逻辑(解包字段数)。
*/
import { useState, useCallback } from 'react'
@@ -20,7 +26,7 @@ const { TreeNode } = Tree
export default function KnowledgeTree({
treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点)
onNodeSelect, // 选中节点时回调,参数: { key, type, detail } | null(全部)
onNodeSelect, // 选中节点时回调,参数: { key, type, secondaryField, secondaryValue } | null(全部)
selectedKey, // 当前选中的 key(用于高亮)
}) {
const [expandedKeys, setExpandedKeys] = useState(() => {
@@ -60,18 +66,27 @@ export default function KnowledgeTree({
const key = selectedKeys[0]
// 解析 key 格式:
// "all" → 全部根节点
// "manuscript" → 大类节点
// "manuscript|具体出处" → 二级节点
// "all" → 全部根节点
// "manuscript" → 大类节点
// "manuscript|author|左鑫" → 二级节点(type|二级字段名|字段值)
if (key === 'all') {
onNodeSelect(null)
return
}
const parts = key.split('|')
const type = parts[0]
const detail = parts[1] || null
onNodeSelect({ key, type, detail })
if (parts.length === 1) {
// 大类节点
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])
// 渲染节点
+103 -34
View File
@@ -2,7 +2,7 @@
* 知识库数据分组层(交互层与分组维度完全解耦)
*
* 本模块职责:
* - 接收 items 数组,按指定维度(当前为 source_type → source_detail生成分组树数据
* - 接收 items 数组,按指定维度生成分组树数据
* - 完全不涉及 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 组件渲染
*
* 返回格式:
@@ -26,8 +72,8 @@
* key: "manuscript",
* label: "节目文稿(n条)",
* count: n,
* children: [ // 无 source_detail 为空数组,不造空节点
* { key: "manuscript|具体出处", label: "具体出处(n条)", count: n }
* children: [ // SECONDARY_GROUP_FIELD[st] 为 null 为空数组
* { key: "manuscript|author|左鑫", label: "左鑫(3条)", count: 3 }
* ]
* },
* ...
@@ -35,23 +81,27 @@
* }
* ]
*
* 注意:source_detail 为 null 的条目归入对应 source_type 大类,
* 但不单独成子节点(大类的 children 为空数组时,大类直接可点击)。
* 二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
* 例:manuscript|author|左鑫
* military_report|source_detail|航空知识 2026年第1期
*
* 注意:
* - 二级字段值为 null / 空字串 → 归入对应大类,不造空节点
* - 无效的 source_type → 归入 'manual' 大类
*/
export function groupItemsBySource(items) {
const SOURCE_TYPE_LABEL = {
military_report: '杂志文章',
manuscript: '节目文稿',
baoti: '报题单',
manual: '其他',
}
const totalCount = items.length
// 按 source_type 分组
// 按 source_type 分组(按固定顺序遍历)
const typeGroups = {}
// 初始化所有已知类别,确保空类别也出现在树中
for (const st of SOURCE_TYPE_ORDER) {
typeGroups[st] = []
}
for (const item of items) {
const st = item.source_type || 'manual'
// 未知类别兜底(如 'journal' 等新加类型)
if (!typeGroups[st]) {
typeGroups[st] = []
}
@@ -59,33 +109,52 @@ export function groupItemsBySource(items) {
}
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_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
}
// 构建二级节点:仅 source_detail 非 null 的才建子节点
const secondaryField = SECONDARY_GROUP_FIELD[st] || 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,
})
if (secondaryField !== null) {
// 按二级字段分组
const detailGroups = {}
for (const item of stItems) {
// 取二级字段值,null/空字串统一为 null 处理(不造空节点)
const sd = (item[secondaryField] || '').trim() || null
if (!detailGroups[sd]) {
detailGroups[sd] = []
}
detailGroups[sd].push(item)
}
for (const [sd, sdItems] of Object.entries(detailGroups)) {
if (sd !== null) {
// 二级节点 key 格式:type|fieldName|fieldValue
grandchildren.push({
key: `${st}|${secondaryField}|${sd}`,
label: `${sd}${sdItems.length}条)`,
count: sdItems.length,
})
}
}
}
// secondaryField 为 null 时,grandchildren 保持为空数组 → 大类直接可点击
const label = SOURCE_TYPE_LABEL[st] || st
children.push({
key: st,
label: `${label}${stItems.length}条)`,
label: `${SOURCE_TYPE_LABEL[st] || st}${stItems.length}条)`,
count: stItems.length,
children: grandchildren,
})
@@ -1,5 +1,5 @@
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 useAuthStore from '../../stores/authStore'
import knowledgeService from '../../services/knowledgeService'
@@ -32,7 +32,7 @@ export default function KnowledgeBase() {
const [sources, setSources] = useState([]) // 出处下拉选项
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
const [sourceDetailFilter, setSourceDetailFilter] = useState('')
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, secondaryField, secondaryValue }
const fetchItems = async () => {
setLoading(true)
@@ -110,20 +110,26 @@ export default function KnowledgeBase() {
}
// 树节点选中 → 联动过滤
// 新格式回调:{ key, type, secondaryField, secondaryValue }
const handleNodeSelect = (node) => {
setSelectedTreeNode(node)
}
// 根据树节点过滤列表
// 支持新旧两种 key 格式兼容(secondaryField 为 null 时走旧逻辑)
const displayedItems = (() => {
let result = items
// 树节点过滤优先(覆盖原有 Select 筛选)
if (selectedTreeNode) {
const { type, detail } = selectedTreeNode
const { type, secondaryField, secondaryValue } = selectedTreeNode
result = result.filter(i => {
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
})
} else if (sourceDetailFilter) {
@@ -134,6 +140,17 @@ export default function KnowledgeBase() {
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 = [
{
title: '标题',
@@ -221,22 +238,24 @@ export default function KnowledgeBase() {
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传系统自动解析 yaml frontmatter 并生成语义向量</span>
</div>
{/* 主体:左侧树 + 右侧列表 */}
<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>
{/* 双列对齐布局:左侧树 | 右侧筛选+列表 */}
<Row gutter={12} align="stretch">
{/* 左列:树 */}
<Col span={5} style={{ display: 'flex', flexDirection: 'column' }}>
<Card
style={{ borderRadius: 14, flex: 1 }}
bodyStyle={{ padding: 0, flex: 1, display: 'flex', flexDirection: 'column' }}
>
<KnowledgeTree
treeData={treeData}
onNodeSelect={handleNodeSelect}
selectedKey={buildSelectedKey()}
/>
</Card>
</Col>
{/* 右侧列表 + 筛选栏 */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* 右列:筛选栏 + 列表 */}
<Col span={19} style={{ display: 'flex', flexDirection: 'column' }}>
{/* 筛选栏 */}
<Card style={{ borderRadius: 14, marginBottom: 12 }}>
<Space size="middle" wrap>
@@ -278,8 +297,8 @@ export default function KnowledgeBase() {
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
/>
</Card>
</div>
</div>
</Col>
</Row>
</div>
)
}