diff --git a/backend/app/services/knowledge_service.py b/backend/app/services/knowledge_service.py index 11058c1..fffd5c1 100644 --- a/backend/app/services/knowledge_service.py +++ b/backend/app/services/knowledge_service.py @@ -27,6 +27,30 @@ class KnowledgeService: "报题单": "baoti", } + # 来源大类固定显示顺序(制片人 Obsidian 习惯) + SOURCE_TYPE_ORDER = [ + "manuscript", # 节目文稿 + "military_report", # 杂志文章 + "baoti", # 报题单 + "manual", # 其他 + ] + + # 二级分组维度映射(与前端 useKnowledgeGrouping.js 的 SECONDARY_GROUP_FIELD 保持一致) + # key = source_type, value = 用来做二级分组的字段名,None = 不建二级节点 + SECONDARY_GROUP_FIELD = { + "manuscript": "author", # 节目文稿 → 按作者(编导) + "military_report": "source_detail", # 杂志文章 → 按出处 + "baoti": None, # 报题单 → 不分组 + "manual": None, # 其他 → 不分组 + } + + SOURCE_TYPE_LABEL = { + "military_report": "杂志文章", + "manuscript": "节目文稿", + "baoti": "报题单", + "manual": "其他", + } + def __init__(self): self.embedder = EmbeddingService() @@ -243,46 +267,22 @@ class KnowledgeService: def get_grouped_items(self) -> list[dict]: """ - 按 source_type → source_detail 两层聚合,返回树形结构数据。 + 按 source_type → 二级字段(author / source_detail)两层聚合,返回树形结构数据。 + 按 SOURCE_TYPE_ORDER 固定顺序排列。 - 返回格式: - [ - { - "key": "全部", - "label": "全部(N条)", - "count": 总条目数, - "children": [ - { - "key": "manuscript", - "label": "节目文稿(n条)", - "count": n, - "children": [ # source_detail 为 null 时直接为空数组,不造空节点 - {"key": "...", "label": "具体出处", "count": n} - ] - }, - ... - ] - } - ] + 二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}` + 例:manuscript|author|左鑫 + military_report|source_detail|航空知识 2026年第1期 - 注意:source_detail 为 null 的条目(如节目文稿、报题单), - 归入对应 source_type 大类下,但不单独成子节点(大类的 children 直接为其条目列表, - 但前端 Tree 组件渲染时,若 children 为空则不展示二级节点,大类直接可点击)。 + 二级字段值为 null / 空字串 → 归入对应大类,不造空节点。 """ - SOURCE_TYPE_LABEL = { - "military_report": "杂志文章", - "manuscript": "节目文稿", - "baoti": "报题单", - "manual": "其他", - } - with Session(engine) as session: items = session.exec(select(KnowledgeItem)).all() total_count = len(items) - # 按 source_type 分组 - type_groups: dict = {} + # 按 source_type 分组,初始化所有已知类别(保证空类别也按顺序出现) + type_groups: dict = {st: [] for st in self.SOURCE_TYPE_ORDER} for item in items: st = item.source_type or "manual" if st not in type_groups: @@ -290,30 +290,48 @@ class KnowledgeService: type_groups[st].append(item) children = [] - for st, st_items in type_groups.items(): - # 按 source_detail 细分 - detail_groups: dict = {} - for item in st_items: - tags = item.tags or {} - sd = tags.get("source_detail") if isinstance(tags, dict) else None - if sd not in detail_groups: - detail_groups[sd] = [] - detail_groups[sd].append(item) + for st in self.SOURCE_TYPE_ORDER: + st_items = type_groups.get(st, []) + if not st_items: + # 空类别也按顺序出现(0条) + children.append({ + "key": st, + "label": f"{self.SOURCE_TYPE_LABEL.get(st, st)}(0条)", + "count": 0, + "children": [], + }) + continue - # 构建二级节点:仅 source_detail 非 null 的才建子节点 + secondary_field = self.SECONDARY_GROUP_FIELD.get(st) grandchildren = [] - for sd, sd_items in detail_groups.items(): - if sd is not None: - grandchildren.append({ - "key": f"{st}|{sd}", - "label": f"{sd}({len(sd_items)}条)", - "count": len(sd_items), - }) - label = SOURCE_TYPE_LABEL.get(st, st) + if secondary_field is not None: + # 按二级字段分组 + detail_groups: dict = {} + for item in st_items: + # 取二级字段值:author 直接取,source_detail 从 tags(JSONB) 取 + if secondary_field == "source_detail": + tags = item.tags or {} + sd = tags.get("source_detail") if isinstance(tags, dict) else None + field_val = sd + else: + field_val = (getattr(item, secondary_field, None) or "").strip() or None + + if field_val not in detail_groups: + detail_groups[field_val] = [] + detail_groups[field_val].append(item) + + for sd, sd_items in detail_groups.items(): + if sd is not None: + grandchildren.append({ + "key": f"{st}|{secondary_field}|{sd}", + "label": f"{sd}({len(sd_items)}条)", + "count": len(sd_items), + }) + children.append({ "key": st, - "label": f"{label}({len(st_items)}条)", + "label": f"{self.SOURCE_TYPE_LABEL.get(st, st)}({len(st_items)}条)", "count": len(st_items), "children": grandchildren, }) @@ -323,4 +341,4 @@ class KnowledgeService: "label": f"全部({total_count}条)", "count": total_count, "children": children, - }] + }] \ No newline at end of file diff --git a/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx b/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx index 7226212..48a9f6d 100644 --- a/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx +++ b/frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx @@ -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, secondaryField, secondaryValue } + const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { key, type, secondaryField, secondaryValue } const fetchItems = async () => { setLoading(true) @@ -110,13 +110,15 @@ export default function KnowledgeBase() { } // 树节点选中 → 联动过滤 - // 新格式回调:{ key, type, secondaryField, secondaryValue } + // 回调参数:{ key, type, secondaryField, secondaryValue } | null const handleNodeSelect = (node) => { setSelectedTreeNode(node) } // 根据树节点过滤列表 - // 支持新旧两种 key 格式兼容(secondaryField 为 null 时走旧逻辑) + // 二级节点 key 格式:type|fieldName|fieldValue + // secondaryField === 'source_detail' → 用 item.source_detail 过滤 + // secondaryField === 'author' → 用 item.author 过滤 const displayedItems = (() => { let result = items @@ -124,16 +126,19 @@ export default function KnowledgeBase() { const { type, secondaryField, secondaryValue } = selectedTreeNode result = result.filter(i => { if (i.source_type !== type) return false - // 二级过滤:新格式(type|field|value) if (secondaryField !== null && secondaryValue !== null) { - // 按指定二级字段过滤 - const itemFieldVal = (i[secondaryField] || '').trim() || null - if (itemFieldVal !== secondaryValue) return false + if (secondaryField === 'source_detail') { + if (i.source_detail !== secondaryValue) return false + } else { + // 其他二级字段(如 author)直接用字段名匹配 + const itemFieldVal = (i[secondaryField] || '').trim() || null + if (itemFieldVal !== secondaryValue) return false + } } return true }) } else if (sourceDetailFilter) { - // 保留原有的 source_detail 筛选逻辑 + // 保留原有的 source_detail 筛选逻辑(来自右侧 Select 下拉) result = result.filter(i => i.source_detail === sourceDetailFilter) } @@ -141,7 +146,7 @@ export default function KnowledgeBase() { })() // 构建 selectedKey 供 KnowledgeTree 高亮 - // 格式:type|field|value(与 useKnowledgeGrouping 输出的 key 格式一致) + // 格式:type|field|value(三段,与后端 get_grouped_items 输出的 key 一致) const buildSelectedKey = () => { if (!selectedTreeNode) return 'all' const { type, secondaryField, secondaryValue } = selectedTreeNode @@ -239,12 +244,13 @@ export default function KnowledgeBase() { {/* 双列对齐布局:左侧树 | 右侧筛选+列表 */} - - {/* 左列:树 */} - + {/* 左列固定 280px,右列撑满,ant-row gutter=12 保持两列间距 */} + + {/* 左列:树(固定宽度,min-width 防止收缩) */} + - {/* 右列:筛选栏 + 列表 */} - - {/* 筛选栏 */} - + {/* 右列:筛选栏 + 列表(两栏独立等高,顶部对齐) */} + + {/* 筛选栏(顶部对齐,与左列卡片顶线平齐) */} + 筛选: