Ver Fonte

兼容长文解构信息

luojunhui há 3 dias atrás
pai
commit
fbba026df8

+ 20 - 0
src/api/recall.ts

@@ -6,8 +6,10 @@ import type {
   DeconstructQueryParam,
   DeconstructResultVO,
   DeconstructSubmitParam,
+  MatchByMaterialIdParam,
   MatchByTextParam,
   MatchByVideoIdParam,
+  MaterialBasicVO,
   RecallResultVO,
   VideoBasicVO,
 } from './types'
@@ -61,6 +63,24 @@ export async function matchByVideoId(param: MatchByVideoIdParam): Promise<Recall
   return normalizeRecallResult(resp.data?.data)
 }
 
+/** Tab3: 获取素材基础信息 (图片URL等, 用于预览) */
+export async function getMaterialDetail(materialId: string): Promise<MaterialBasicVO | null> {
+  const resp = await client.get<CommonResponse<MaterialBasicVO | null>>(
+    '/recallTest/materialDetail',
+    { params: { materialId } },
+  )
+  return resp.data?.data ?? null
+}
+
+/** Tab3: 通过素材ID召回相似 */
+export async function matchByMaterialId(param: MatchByMaterialIdParam): Promise<RecallResultVO> {
+  const resp = await client.post<CommonResponse<RecallResultVO>>(
+    '/recallTest/matchByMaterialId',
+    param,
+  )
+  return normalizeRecallResult(resp.data?.data)
+}
+
 /** Tab1: 视频/素材的解构层级 */
 export async function getDeconstructPoints(videoId: string): Promise<DeconstructPointsVO | null> {
   const resp = await client.get<CommonResponse<DeconstructPointsVO | null>>(

+ 13 - 0
src/api/types.ts

@@ -17,6 +17,13 @@ export interface VideoBasicVO {
   playCount: string
 }
 
+/** 素材基础信息 (用于 Tab3 预览) */
+export interface MaterialBasicVO {
+  materialId: string
+  imageUrl: string | null
+  title: string | null
+}
+
 /**
  * 后端返回的视频运营/分发维度详情(嵌套对象)
  * key 为后端原样字段(中英文混合),全部可选 — 缺字段时前端用 "--" 占位
@@ -152,6 +159,12 @@ export interface MatchByVideoIdParam {
   topN?: number
 }
 
+export interface MatchByMaterialIdParam {
+  materialId: string
+  configCode?: string
+  topN?: number
+}
+
 export interface EssenceWord {
   word: string
   score: number

+ 263 - 0
src/components/MaterialRecallTab.tsx

@@ -0,0 +1,263 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+  Card,
+  Input,
+  InputNumber,
+  Select,
+  Button,
+  Space,
+  message,
+  Image,
+  Typography,
+  Empty,
+} from 'antd'
+import {
+  PictureOutlined,
+  SearchOutlined,
+} from '@ant-design/icons'
+import RecallResultList from './RecallResultList'
+import { getMaterialDetail, matchByMaterialId } from '../api/recall'
+import type { MaterialBasicVO, RecallResultVO } from '../api/types'
+import { toHttps } from '../utils/url'
+import {
+  ALL_CONFIG_CODE,
+  buildGroupedConfigOptions,
+  getConfigDisplayLabel,
+  listAllConfigCodes,
+  useConfigCodes,
+} from '../api/configCodes'
+
+const { Text } = Typography
+
+export default function MaterialRecallTab() {
+  const [materialId, setMaterialId] = useState('')
+  const [materialInfo, setMaterialInfo] = useState<MaterialBasicVO | null>(null)
+  const [loadingInfo, setLoadingInfo] = useState(false)
+  const [topN, setTopN] = useState<number>(10)
+  /** 多选: 空数组 = 全部维度 */
+  const [selectedCodes, setSelectedCodes] = useState<string[]>([])
+  const [result, setResult] = useState<RecallResultVO | null>(null)
+  const [loading, setLoading] = useState(false)
+  const configCodes = useConfigCodes()
+  const groupedOptions = buildGroupedConfigOptions(configCodes)
+  /** 召回结果标题展示的维度 meta — 锁定提交瞬间的值 */
+  const [resultMeta, setResultMeta] = useState<{
+    dimensionLabel: string
+    dimensionCode: string
+    description: string
+  } | null>(null)
+
+  const submitGenRef = useRef(0)
+  // 防抖: 用户停止输入 500ms 后自动查素材信息
+  const infoTimerRef = useRef<number | null>(null)
+
+  /** materialId 变化时, 自动查素材图片 URL */
+  useEffect(() => {
+    if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
+    const id = materialId.trim()
+    if (!id) {
+      setMaterialInfo(null)
+      return
+    }
+    infoTimerRef.current = window.setTimeout(() => {
+      setLoadingInfo(true)
+      getMaterialDetail(id)
+        .then((info) => setMaterialInfo(info))
+        .catch(() => setMaterialInfo(null))
+        .finally(() => setLoadingInfo(false))
+    }, 500)
+    return () => {
+      if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
+    }
+  }, [materialId])
+
+  const onSubmit = useCallback(async () => {
+    const id = materialId.trim()
+    if (!id) {
+      message.warning('请输入素材ID')
+      return
+    }
+    // 空选 = 全部维度; 否则用户显式勾选的子集
+    const codes =
+      selectedCodes.length === 0 ? listAllConfigCodes(configCodes) : selectedCodes
+    if (codes.length === 0) {
+      message.warning('维度字典尚未加载完成, 请稍后再试')
+      return
+    }
+
+    const myGen = ++submitGenRef.current
+    const isStale = () => myGen !== submitGenRef.current
+    setLoading(true)
+
+    let dimensionLabel: string
+    if (selectedCodes.length === 0) {
+      dimensionLabel = `全部 (${codes.length} 个维度)`
+    } else if (codes.length === 1) {
+      dimensionLabel = getConfigDisplayLabel(codes[0], configCodes)
+    } else {
+      dimensionLabel = `${codes.map((c) => getConfigDisplayLabel(c, configCodes)).join(' / ')} (${codes.length} 个维度)`
+    }
+    setResultMeta({
+      dimensionLabel,
+      dimensionCode: codes.length === 1 ? codes[0] : ALL_CONFIG_CODE,
+      description: `基于素材 "${id}"`,
+    })
+
+    try {
+      const settled = await Promise.allSettled(
+        codes.map((code) =>
+          matchByMaterialId({ materialId: id, configCode: code, topN }),
+        ),
+      )
+      if (isStale()) return
+      const failedDims: string[] = []
+      const merged: RecallResultVO = {
+        items: [],
+        videoCount: 0,
+        materialCount: 0,
+        articleCount: 0,
+        total: 0,
+      }
+      settled.forEach((s, i) => {
+        if (s.status === 'fulfilled') {
+          merged.items.push(...s.value.items)
+        } else {
+          failedDims.push(getConfigDisplayLabel(codes[i], configCodes))
+        }
+      })
+      merged.items.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity))
+      merged.videoCount = merged.items.filter((x) => x.modality === 'VIDEO').length
+      merged.materialCount = merged.items.filter((x) => x.modality === 'MATERIAL').length
+      merged.articleCount = merged.items.filter((x) => x.modality === 'ARTICLE').length
+      merged.total = merged.items.length
+      setResult(merged)
+      if (failedDims.length > 0) {
+        message.warning(`部分维度召回失败: ${failedDims.join(', ')} — 已展示其余维度结果`)
+      } else {
+        message.success(`召回完成, 共 ${merged.total} 条`)
+      }
+    } catch {
+      if (!isStale()) message.error('召回失败')
+    } finally {
+      if (!isStale()) setLoading(false)
+    }
+  }, [materialId, selectedCodes, topN, configCodes])
+
+  return (
+    <Space direction="vertical" size={16} style={{ width: '100%' }}>
+      <Card size="small" bodyStyle={{ padding: 16 }}>
+        <Space direction="vertical" size={12} style={{ width: '100%' }}>
+          {/* 素材ID + 召回维度 + TopN + 召回按钮 */}
+          <Space wrap size={[12, 8]}>
+            <Text strong>素材ID</Text>
+            <Input
+              placeholder="请输入素材ID (materialId)"
+              value={materialId}
+              onChange={(e) => setMaterialId(e.target.value)}
+              style={{ width: 320 }}
+              onPressEnter={onSubmit}
+              allowClear
+            />
+            <Text strong>召回维度</Text>
+            <Select
+              mode="multiple"
+              value={selectedCodes}
+              onChange={setSelectedCodes}
+              options={groupedOptions}
+              placeholder="不选 = 全部维度"
+              maxTagCount="responsive"
+              style={{ minWidth: 240 }}
+              allowClear
+            />
+            <Text strong>TopN</Text>
+            <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
+            <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
+              召回
+            </Button>
+          </Space>
+
+          {/* 素材图片预览 (materialId 变化时自动查询) */}
+          {materialId.trim() && (
+            <div>
+              <Text type="secondary" style={{ fontSize: 12 }}>
+                {loadingInfo ? '查询中...' : materialInfo?.imageUrl ? '素材预览:' : materialInfo ? '该素材无图片' : ''}
+              </Text>
+              {materialInfo?.imageUrl && (
+                <div style={{ marginTop: 4 }}>
+                  <Image
+                    src={toHttps(materialInfo.imageUrl)}
+                    alt="material preview"
+                    referrerPolicy="no-referrer"
+                    style={{ maxHeight: 240, borderRadius: 4 }}
+                    fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+                  />
+                  {materialInfo.title && (
+                    <div style={{ marginTop: 4 }}>
+                      <Text type="secondary" style={{ fontSize: 12 }}>{materialInfo.title}</Text>
+                    </div>
+                  )}
+                </div>
+              )}
+            </div>
+          )}
+        </Space>
+      </Card>
+
+      <Card
+        size="small"
+        title={<MaterialResultTitle meta={resultMeta} materialId={materialId} />}
+      >
+        {result && result.total === 0 ? (
+          <Empty description="无召回结果" />
+        ) : (
+          <RecallResultList result={result} loading={loading} defaultActiveKey="MATERIAL" />
+        )}
+      </Card>
+    </Space>
+  )
+}
+
+/** 素材召回结果卡片标题 — 展示召回维度标签, 与 Tab1/Tab2 的 RecallTitle 风格一致 */
+function MaterialResultTitle({
+  meta,
+  materialId,
+}: {
+  meta: { dimensionLabel: string; dimensionCode: string; description: string } | null
+  materialId: string
+}) {
+  if (!meta) {
+    return (
+      <Space size={6}>
+        <PictureOutlined style={{ color: '#722ed1' }} />
+        <span>召回结果</span>
+      </Space>
+    )
+  }
+  const palette = 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: 8, flexWrap: 'wrap' }}>
+      <PictureOutlined style={{ color: '#722ed1', fontSize: 16 }} />
+      <span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>素材相似度召回 · 维度:</span>
+      <span
+        style={{
+          background: palette.bg,
+          border: `1px solid ${palette.border}`,
+          color: palette.text,
+          fontSize: 16,
+          fontWeight: 700,
+          padding: '4px 12px',
+          borderRadius: 6,
+          letterSpacing: 0.5,
+        }}
+      >
+        {meta.dimensionLabel}
+      </span>
+      <span style={{ color: 'rgba(0,0,0,0.65)', fontSize: 13 }}>· {meta.description}</span>
+    </div>
+  )
+}

+ 4 - 2
src/components/RecallResultList.tsx

@@ -10,6 +10,8 @@ import { useConfigCodes } from '../api/configCodes'
 interface Props {
   result: RecallResultVO | null
   loading?: boolean
+  /** 默认激活的模态 Tab, 不传默认 'VIDEO' */
+  defaultActiveKey?: 'ALL' | Modality
 }
 
 const MODALITY_LABEL: Record<Modality | 'ALL', string> = {
@@ -24,8 +26,8 @@ const MODALITY_LABEL: Record<Modality | 'ALL', string> = {
  * Tabs 复用现有 ALL/VIDEO/MATERIAL/ARTICLE 计数,内容用 RecallResultTable 渲染
  * 右侧按钮: 综合排序 toggle + 排序参数 popover
  */
-export default function RecallResultList({ result, loading }: Props) {
-  const [active, setActive] = useState<'ALL' | Modality>('VIDEO')
+export default function RecallResultList({ result, loading, defaultActiveKey = 'VIDEO' }: Props) {
+  const [active, setActive] = useState<'ALL' | Modality>(defaultActiveKey)
   const [rankingParams, setRankingParams] = useRankingParams()
   /** null = 后端原始顺序; 'desc'/'asc' = 按综合分排序 */
   const [compositeSort, setCompositeSort] = useState<'descend' | 'ascend' | null>(null)

+ 3 - 3
src/pages/RecallTestPage.tsx

@@ -33,6 +33,7 @@ import DeconstructTree, {
   DISABLED_PARENT_BTN_STYLE,
 } from '../components/DeconstructTree'
 import AIUnderstandingPanel from '../components/AIUnderstandingPanel'
+import MaterialRecallTab from '../components/MaterialRecallTab'
 import { toHttps } from '../utils/url'
 import {
   getAiUnderstanding,
@@ -170,11 +171,10 @@ export default function RecallTestPage() {
           key: 'material',
           label: (
             <span>
-              <PictureOutlined /> 素材解构 <Tag color="default" style={{ marginLeft: 4 }}>开发中</Tag>
+              <PictureOutlined /> 素材相似度召回
             </span>
           ),
-          disabled: true,
-          children: null,
+          children: <MaterialRecallTab />,
         },
         {
           key: 'article',