feat: 知识库树形视图调整:大类固定排序、节目文稿按作者二级分组、左右Grid双列对齐
This commit is contained in:
@@ -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])
|
||||
|
||||
// 渲染节点
|
||||
|
||||
@@ -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_type,value = 用来做二级分组的字段名(取 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user