feat: 知识库树形视图调整:大类固定排序、节目文稿按作者二级分组、左右Grid双列对齐
This commit is contained in:
@@ -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])
|
||||||
|
|
||||||
// 渲染节点
|
// 渲染节点
|
||||||
|
|||||||
@@ -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_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 组件渲染
|
* @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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user