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

Merge branch 'feature/luojunhui/20260526-add-article-match' of Web/content_vector_recall into master

luojunhui 3 дней назад
Родитель
Сommit
f919451248

+ 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

+ 242 - 0
src/components/MaterialRecallTab.tsx

@@ -0,0 +1,242 @@
+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
+    }
+
+    // matchByMaterialId 为单次调用 (spec 约束), configCode 选填:
+    // 空选 → 不传, 后端走全部维度; 单选 → 传具体维度; 多选 → 传 ALL 走全部
+    const configCodeParam =
+      selectedCodes.length === 1
+        ? selectedCodes[0]
+        : selectedCodes.length > 1
+          ? ALL_CONFIG_CODE
+          : undefined
+
+    let dimensionLabel: string
+    if (selectedCodes.length === 0) {
+      const allCodes = listAllConfigCodes(configCodes)
+      dimensionLabel = `全部 (${allCodes.length} 个维度)`
+    } else if (selectedCodes.length === 1) {
+      dimensionLabel = getConfigDisplayLabel(selectedCodes[0], configCodes)
+    } else {
+      dimensionLabel = `${selectedCodes.map((c) => getConfigDisplayLabel(c, configCodes)).join(' / ')} (${selectedCodes.length} 个维度)`
+    }
+    setResultMeta({
+      dimensionLabel,
+      dimensionCode: selectedCodes.length === 1 ? selectedCodes[0] : ALL_CONFIG_CODE,
+      description: `基于素材 "${id}"`,
+    })
+
+    const myGen = ++submitGenRef.current
+    const isStale = () => myGen !== submitGenRef.current
+    setLoading(true)
+
+    try {
+      const data = await matchByMaterialId({ materialId: id, configCode: configCodeParam, topN })
+      if (isStale()) return
+      setResult(data)
+      if (data.total === 0) {
+        message.info('无召回结果')
+      } else {
+        message.success(`召回完成, 共 ${data.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',