Просмотр исходного кода

feat: 召回维度动态加载 - 去除前端硬编码

启动时调 GET /videoSearch/getAllConfigCodes 拉取 configCode→中文标签
字典, 进程缓存, 全部消费方共享.

- 新增 src/api/configCodes.ts: useConfigCodes hook + 分组工具 +
  TREE_NODE_TYPE_TO_CONFIG_CODE 映射
- RecallTestPage Tab2 维度下拉改分组选项(视频解构维度 / AI识别维度)
- 召回结果标题改 "召回维度: [中文标签]" 格式
- DeconstructTree 删 SUPPORTED_TYPES 白名单, 选题/灵感点/关键点/目的点
  按后端字典动态启停, 失败 tooltip 给出具体 configCode 缺失原因

后端字典里现在已有 VIDEO_KEYPOINT / VIDEO_PURPOSE, 关键点和目的点
的"以此召回"按钮会自动激活, 无需再改前端.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 2 дней назад
Родитель
Сommit
14bb67302a
4 измененных файлов с 148 добавлено и 38 удалено
  1. 81 0
      src/api/configCodes.ts
  2. 8 0
      src/api/recall.ts
  3. 43 19
      src/components/DeconstructTree.tsx
  4. 16 19
      src/pages/RecallTestPage.tsx

+ 81 - 0
src/api/configCodes.ts

@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react'
+import { getAllConfigCodes } from './recall'
+
+/**
+ * 召回维度配置 (configCode -> 中文标签)
+ *
+ * 数据源: video-vector 后端 GET /videoSearch/getAllConfigCodes
+ * 启动时拉一次, 进程内缓存, 全部消费方共享.
+ *
+ * 在请求未返回前先吐出 SAFE_FALLBACK 避免 UI 闪烁; 拉到后切换到真实数据.
+ * 命名约定:
+ *   VIDEO_*       视频解构维度 (选题/灵感点/关键点/目的点)
+ *   RESULT_LOG_*  AI 识别维度  (选题/主题/关键词/口播)
+ */
+
+const SAFE_FALLBACK: Record<string, string> = {
+  VIDEO_TOPIC: '选题',
+  VIDEO_INSPIRATION: '灵感点',
+}
+
+let cache: Record<string, string> | null = null
+let pending: Promise<Record<string, string>> | null = null
+
+function fetchOnce(): Promise<Record<string, string>> {
+  if (cache) return Promise.resolve(cache)
+  if (!pending) {
+    pending = getAllConfigCodes()
+      .then((d) => {
+        cache = d
+        return d
+      })
+      .catch((e) => {
+        pending = null
+        throw e
+      })
+  }
+  return pending
+}
+
+export function useConfigCodes(): Record<string, string> {
+  const [data, setData] = useState<Record<string, string>>(cache ?? SAFE_FALLBACK)
+  useEffect(() => {
+    if (cache) {
+      setData(cache)
+      return
+    }
+    fetchOnce().then(setData).catch(() => {
+      // 失败保持 fallback 不阻塞 UI
+    })
+  }, [])
+  return data
+}
+
+/** Tab2 文本召回 dropdown: 按前缀分组 */
+export function buildGroupedConfigOptions(codes: Record<string, string>) {
+  const video: { label: string; value: string }[] = []
+  const result: { label: string; value: string }[] = []
+  const other: { label: string; value: string }[] = []
+  for (const [code, label] of Object.entries(codes)) {
+    if (code.startsWith('VIDEO_')) video.push({ label, value: code })
+    else if (code.startsWith('RESULT_LOG_')) result.push({ label, value: code })
+    else other.push({ label, value: code })
+  }
+  const groups: { label: string; options: { label: string; value: string }[] }[] = []
+  if (video.length) groups.push({ label: '视频解构维度', options: video })
+  if (result.length) groups.push({ label: 'AI识别维度', options: result })
+  if (other.length) groups.push({ label: '其他', options: other })
+  return groups
+}
+
+/**
+ * DeconstructTree 节点中文类型 → configCode 映射
+ * 用于判断"以此召回"按钮该传哪个 configCode, 以及该按钮是否生效
+ * (生效条件: 映射到的 configCode 在后端字典里存在)
+ */
+export const TREE_NODE_TYPE_TO_CONFIG_CODE: Record<string, string> = {
+  选题: 'VIDEO_TOPIC',
+  灵感点: 'VIDEO_INSPIRATION',
+  关键点: 'VIDEO_KEYPOINT',
+  目的点: 'VIDEO_PURPOSE',
+}

+ 8 - 0
src/api/recall.ts

@@ -61,3 +61,11 @@ export async function getAiUnderstanding(videoId: number): Promise<AIUnderstandi
   )
   return resp.data?.data ?? null
 }
+
+/** 召回维度字典: configCode -> 中文标签 */
+export async function getAllConfigCodes(): Promise<Record<string, string>> {
+  const resp = await client.get<CommonResponse<Record<string, string>>>(
+    '/videoSearch/getAllConfigCodes',
+  )
+  return resp.data?.data ?? {}
+}

+ 43 - 19
src/components/DeconstructTree.tsx

@@ -1,6 +1,7 @@
 import { Button, Empty, Tag, Typography, Space, Alert, Tooltip } from 'antd'
 import { LinkOutlined, ThunderboltOutlined } from '@ant-design/icons'
 import type { DeconstructPointsVO, HighValuePoint } from '../api/types'
+import { TREE_NODE_TYPE_TO_CONFIG_CODE, useConfigCodes } from '../api/configCodes'
 
 const { Text, Paragraph } = Typography
 
@@ -17,9 +18,7 @@ const TYPE_COLOR: Record<string, string> = {
   关键点: 'green',
 }
 
-/** 当前后端只支持 VIDEO_TOPIC 和 VIDEO_INSPIRATION 两个维度 */
-const SUPPORTED_TYPES = new Set(['灵感点'])
-const UNSUPPORTED_TIP = '暂仅支持 灵感点 整条召回; 关键点/目的点 暂未配置向量维度'
+/** 节点类型 → configCode 映射在 src/api/configCodes.ts; 是否启用动态由后端字典决定 */
 const UNSUPPORTED_WORD_TIP = '暂不支持词级召回(尚未配置词级向量维度)'
 
 /**
@@ -56,17 +55,23 @@ const DISABLED_CHILD_BTN_STYLE: React.CSSProperties = {
  * 展示后端 getDeconstructPoints 返回的"实质≥0.8 高价值点"
  *
  * 召回策略:
- *  - 选题"以此选题召回"        → VIDEO_TOPIC (生效)
- *  - 灵感点行"以此召回"         → VIDEO_INSPIRATION (生效, 卡片绿色背景)
- *  - 关键点 / 目的点 整条       → disabled 灰色, 灰色背景
- *  - 任何点下的实质词"召回"     → disabled 灰色 (暂不支持词级)
+ *  - 选题/灵感点/关键点/目的点 整条召回   → 动态查后端 getAllConfigCodes 字典
+ *      字典里有该 configCode → 生效(绿色按钮); 没有 → disabled(灰色按钮 + 提示)
+ *  - 任何点下的实质词"召回"               → disabled 灰色 (暂不支持词级)
  */
 export default function DeconstructTree({ data, loading, onRecallByText }: Props) {
+  const configCodes = useConfigCodes()
   if (loading) return <div>加载中...</div>
   if (!data) {
     return <Empty description="未找到该视频的解构记录" image={Empty.PRESENTED_IMAGE_SIMPLE} />
   }
 
+  const topicCode = TREE_NODE_TYPE_TO_CONFIG_CODE['选题']
+  const topicSupported = !!(topicCode && topicCode in configCodes)
+  const topicTip = topicSupported
+    ? ''
+    : `当前未启用"选题"维度向量召回 (${topicCode} 不在后端字典)`
+
   return (
     <Space direction="vertical" size={12} style={{ width: '100%' }}>
       {/* 选题 */}
@@ -82,14 +87,22 @@ export default function DeconstructTree({ data, loading, onRecallByText }: Props
           <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
             <Tag color="purple" style={{ margin: 0 }}>选题</Tag>
             <div style={{ flex: 1 }} />
-            <Button
-              type="primary"
-              icon={<ThunderboltOutlined />}
-              onClick={() => onRecallByText(data.topic!, 'VIDEO_TOPIC')}
-              style={ACTIVE_PARENT_BTN_STYLE}
-            >
-              以此召回
-            </Button>
+            {topicSupported ? (
+              <Button
+                type="primary"
+                icon={<ThunderboltOutlined />}
+                onClick={() => onRecallByText(data.topic!, topicCode)}
+                style={ACTIVE_PARENT_BTN_STYLE}
+              >
+                以此召回
+              </Button>
+            ) : (
+              <Tooltip title={topicTip}>
+                <Button disabled icon={<ThunderboltOutlined />} style={DISABLED_PARENT_BTN_STYLE}>
+                  以此召回
+                </Button>
+              </Tooltip>
+            )}
           </div>
           <Paragraph
             style={{ marginBottom: 0, whiteSpace: 'pre-wrap', fontSize: 13, color: '#555' }}
@@ -118,7 +131,12 @@ export default function DeconstructTree({ data, loading, onRecallByText }: Props
       {data.highValuePoints && data.highValuePoints.length > 0 ? (
         <Space direction="vertical" size={10} style={{ width: '100%' }}>
           {data.highValuePoints.map((p) => (
-            <PointCard key={`${p.type}-${p.id}`} point={p} onRecallByText={onRecallByText} />
+            <PointCard
+              key={`${p.type}-${p.id}`}
+              point={p}
+              configCodes={configCodes}
+              onRecallByText={onRecallByText}
+            />
           ))}
         </Space>
       ) : (
@@ -130,26 +148,32 @@ export default function DeconstructTree({ data, loading, onRecallByText }: Props
 
 function PointCard({
   point,
+  configCodes,
   onRecallByText,
 }: {
   point: HighValuePoint
+  configCodes: Record<string, string>
   onRecallByText: (t: string, code?: string) => void
 }) {
   const color = TYPE_COLOR[point.type] || 'default'
-  const supported = SUPPORTED_TYPES.has(point.type)
+  const mappedCode = TREE_NODE_TYPE_TO_CONFIG_CODE[point.type]
+  const supported = !!(mappedCode && mappedCode in configCodes)
 
   // 整条召回按钮 (生效 vs 灰色) - 父级,默认中号
+  const unsupportedTip = mappedCode
+    ? `当前未启用"${point.type}"维度向量召回 (${mappedCode} 不在后端字典)`
+    : `节点类型"${point.type}"无法映射到任何 configCode`
   const headerBtn = supported ? (
     <Button
       type="primary"
       icon={<ThunderboltOutlined />}
-      onClick={() => onRecallByText(point.name, 'VIDEO_INSPIRATION')}
+      onClick={() => onRecallByText(point.name, mappedCode)}
       style={ACTIVE_PARENT_BTN_STYLE}
     >
       以此召回
     </Button>
   ) : (
-    <Tooltip title={UNSUPPORTED_TIP}>
+    <Tooltip title={unsupportedTip}>
       <Button disabled icon={<ThunderboltOutlined />} style={DISABLED_PARENT_BTN_STYLE}>
         以此召回
       </Button>

+ 16 - 19
src/pages/RecallTestPage.tsx

@@ -35,6 +35,7 @@ import {
   getVideoDetail,
   matchByText,
 } from '../api/recall'
+import { buildGroupedConfigOptions, useConfigCodes } from '../api/configCodes'
 import type {
   AIUnderstandingVO,
   DeconstructPointsVO,
@@ -61,12 +62,12 @@ function readUrlQueryText(): string | null {
   return trimmed.length > 0 ? trimmed : null
 }
 
-/** 从 URL ?configCode=xxx 读取并校验,只接受 CONFIG_OPTIONS 内的值,无效或缺省返 null */
+/** 从 URL ?configCode=xxx 读取,后端字典动态加载,这里只做形态校验(非空+大写) */
 function readUrlConfigCode(): string | null {
   const raw = new URLSearchParams(window.location.search).get('configCode')
   if (!raw) return null
   const upper = raw.trim().toUpperCase()
-  return CONFIG_OPTIONS.some((o) => o.value === upper) ? upper : null
+  return upper.length > 0 ? upper : null
 }
 
 /** 从召回结果里剔除指定视频ID, 同时刷新 modality 计数 */
@@ -82,11 +83,6 @@ function filterOutSelf(data: RecallResultVO, excludeId: number | null): RecallRe
   }
 }
 
-const CONFIG_OPTIONS = [
-  { label: '选题', value: 'VIDEO_TOPIC' },
-  { label: '灵感点', value: 'VIDEO_INSPIRATION' },
-]
-
 export default function RecallTestPage() {
   // URL 上有 queryText → 默认进入 Tab2 (优先级高于 videoId,与 Grafana 跳转用例一致)
   // 用 defaultActiveKey 而非受控 activeKey,保留用户后续手动切 Tab 的自由
@@ -156,6 +152,7 @@ function VideoIdTab() {
   const [points, setPoints] = useState<DeconstructPointsVO | null>(null)
   const [loadingMain, setLoadingMain] = useState(false)
   const [queried, setQueried] = useState(false)
+  const configCodes = useConfigCodes()
 
   /** 视频Tab 不再有顶部维度选择, 维度由解构层级里每条点的"以此召回"按钮直接传入 */
   const [topN, setTopN] = useState<number>(10)
@@ -213,10 +210,7 @@ function VideoIdTab() {
       const finalConfigCode = configCodeOverride || 'VIDEO_TOPIC'
       setLoadingRecall(true)
       const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
-      const codeLabel =
-        finalConfigCode === 'VIDEO_TOPIC' ? '选题'
-          : finalConfigCode === 'VIDEO_INSPIRATION' ? '灵感点'
-          : finalConfigCode
+      const codeLabel = configCodes[finalConfigCode] ?? finalConfigCode
       setRecallMeta({
         dimensionLabel: codeLabel,
         dimensionCode: finalConfigCode,
@@ -232,7 +226,7 @@ function VideoIdTab() {
         setLoadingRecall(false)
       }
     },
-    [topN, videoId],
+    [topN, videoId, configCodes],
   )
 
   /** URL 传入 videoId 时, 首次查询结束后自动按"选题"维度召回 (0 点击) */
@@ -394,7 +388,7 @@ function DetailRow({
   )
 }
 
-/** 召回结果卡片标题: 维度Tag突出在前, 描述文字跟在后 */
+/** 召回结果卡片标题: "召回维度:[中文标签]" + 描述 */
 function RecallTitle({
   meta,
 }: {
@@ -408,17 +402,19 @@ function RecallTitle({
       </Space>
     )
   }
-  // 不同维度配色
-  const palette =
-    meta.dimensionCode === 'VIDEO_TOPIC'
+  // 按 configCode 前缀走两套配色
+  const palette = meta.dimensionCode.startsWith('RESULT_LOG_')
+    ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
+    : meta.dimensionCode === 'VIDEO_TOPIC'
       ? { bg: '#f9f0ff', border: '#d3adf7', text: '#531dab' }
       : meta.dimensionCode === 'VIDEO_INSPIRATION'
         ? { bg: '#e6f4ff', border: '#91caff', text: '#0958d9' }
         : { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
 
   return (
-    <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
+    <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
       <SearchOutlined style={{ color: '#722ed1', fontSize: 16 }} />
+      <span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>召回维度:</span>
       <span
         style={{
           background: palette.bg,
@@ -433,7 +429,6 @@ function RecallTitle({
       >
         {meta.dimensionLabel}
       </span>
-      <span style={{ color: 'rgba(0,0,0,0.45)', fontSize: 13 }}>维度召回</span>
       <span style={{ color: 'rgba(0,0,0,0.65)', fontSize: 13 }}>· {meta.description}</span>
     </div>
   )
@@ -452,6 +447,8 @@ function TextRecallTab() {
   const [topN, setTopN] = useState<number>(10)
   const [result, setResult] = useState<RecallResultVO | null>(null)
   const [loading, setLoading] = useState(false)
+  const configCodes = useConfigCodes()
+  const groupedOptions = buildGroupedConfigOptions(configCodes)
 
   const onSubmit = useCallback(async () => {
     if (!queryText.trim()) {
@@ -492,7 +489,7 @@ function TextRecallTab() {
           />
           <Space wrap>
             <Text strong>召回维度</Text>
-            <Select value={configCode} onChange={setConfigCode} options={CONFIG_OPTIONS} style={{ width: 220 }} />
+            <Select value={configCode} onChange={setConfigCode} options={groupedOptions} style={{ width: 240 }} />
             <Text strong>TopN</Text>
             <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
             <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>