From a00bcd6c739772c344fb6524fb520dc7136d2ea6 Mon Sep 17 00:00:00 2001 From: simonkoson <28867558@qq.com> Date: Wed, 27 May 2026 13:54:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=BA=93=E6=A0=91?= =?UTF-8?q?=E5=BD=A2=E8=A7=86=E5=9B=BE=E8=B0=83=E6=95=B4=EF=BC=9A=E5=A4=A7?= =?UTF-8?q?=E7=B1=BB=E5=9B=BA=E5=AE=9A=E6=8E=92=E5=BA=8F=E3=80=81=E8=8A=82?= =?UTF-8?q?=E7=9B=AE=E6=96=87=E7=A8=BF=E6=8C=89=E4=BD=9C=E8=80=85=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E5=88=86=E7=BB=84=E3=80=81=E5=B7=A6=E5=8F=B3Grid?= =?UTF-8?q?=E5=8F=8C=E5=88=97=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KnowledgeTree/KnowledgeTree.jsx | 31 +++- frontend/src/hooks/useKnowledgeGrouping.js | 137 +++++++++++++----- .../src/pages/KnowledgeBase/KnowledgeBase.jsx | 63 +++++--- 3 files changed, 167 insertions(+), 64 deletions(-) diff --git a/frontend/src/components/KnowledgeTree/KnowledgeTree.jsx b/frontend/src/components/KnowledgeTree/KnowledgeTree.jsx index 94b4459..42dd926 100644 --- a/frontend/src/components/KnowledgeTree/KnowledgeTree.jsx +++ b/frontend/src/components/KnowledgeTree/KnowledgeTree.jsx @@ -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]) // 渲染节点 diff --git a/frontend/src/hooks/useKnowledgeGrouping.js b/frontend/src/hooks/useKnowledgeGrouping.js index e2c9ecd..3c4b5ad 100644 --- a/frontend/src/hooks/useKnowledgeGrouping.js +++ b/frontend/src/hooks/useKnowledgeGrouping.js @@ -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, }) diff --git a/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx b/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx index 3884814..7226212 100644 --- a/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx +++ b/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx @@ -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() { 支持批量上传,系统自动解析 yaml frontmatter 并生成语义向量 - {/* 主体:左侧树 + 右侧列表 */} -