Sfoglia il codice sorgente

兼容素材解构信息

luojunhui 1 giorno fa
parent
commit
c571c2b39f

+ 17 - 5
src/api/recall.ts

@@ -20,8 +20,20 @@ const EMPTY_RESULT: RecallResultVO = {
   total: 0,
 }
 
+/**
+ * 后端 MATERIAL 模态下 id 为 null,materialId(图片 md5)才是实际主键。
+ * 前端组件统一用 item.id 做 rowKey/筛选/展示,这里在数据入口处把 materialId 回填到 id。
+ */
+function normalizeRecallResult(data: RecallResultVO | null | undefined): RecallResultVO {
+  if (!data) return EMPTY_RESULT
+  const items = data.items.map((it) =>
+    it.id == null && it.materialId ? { ...it, id: it.materialId } : it,
+  )
+  return { ...data, items }
+}
+
 /** Tab1: 获取视频基础详情 */
-export async function getVideoDetail(videoId: number): Promise<VideoBasicVO | null> {
+export async function getVideoDetail(videoId: string): Promise<VideoBasicVO | null> {
   const resp = await client.get<CommonResponse<VideoBasicVO | null>>(
     '/recallTest/videoDetail',
     { params: { videoId } },
@@ -35,7 +47,7 @@ export async function matchByText(param: MatchByTextParam): Promise<RecallResult
     '/recallTest/matchByText',
     param,
   )
-  return resp.data?.data ?? EMPTY_RESULT
+  return normalizeRecallResult(resp.data?.data)
 }
 
 /** Tab1: 通过视频ID召回相似 */
@@ -44,11 +56,11 @@ export async function matchByVideoId(param: MatchByVideoIdParam): Promise<Recall
     '/recallTest/matchByVideoId',
     param,
   )
-  return resp.data?.data ?? EMPTY_RESULT
+  return normalizeRecallResult(resp.data?.data)
 }
 
 /** Tab1: 视频/素材的解构层级 */
-export async function getDeconstructPoints(videoId: number): Promise<DeconstructPointsVO | null> {
+export async function getDeconstructPoints(videoId: string): Promise<DeconstructPointsVO | null> {
   const resp = await client.get<CommonResponse<DeconstructPointsVO | null>>(
     '/recallTest/deconstructPoints',
     { params: { videoId } },
@@ -57,7 +69,7 @@ export async function getDeconstructPoints(videoId: number): Promise<Deconstruct
 }
 
 /** Tab1: AI理解结果 (未就绪时返回 null) */
-export async function getAiUnderstanding(videoId: number): Promise<AIUnderstandingVO | null> {
+export async function getAiUnderstanding(videoId: string): Promise<AIUnderstandingVO | null> {
   const resp = await client.get<CommonResponse<AIUnderstandingVO | null>>(
     '/recallTest/aiUnderstanding',
     { params: { videoId } },

+ 40 - 5
src/api/types.ts

@@ -9,7 +9,7 @@ export interface CommonResponse<T> {
 }
 
 export interface VideoBasicVO {
-  videoId: number
+  videoId: string
   title: string | null
   videoUrl: string | null
   cover: string | null
@@ -79,7 +79,11 @@ export interface VideoDetailDeconstruct {
 }
 
 export interface VideoMatchEnrichedVO {
-  id: number
+  /** 内容 ID — 字符串型(可能为长整型/md5 截取等,前端不参与数字运算)
+   *  modality=MATERIAL 时后端 id 为 null,前端在 API 层用 materialId 回填 */
+  id: string
+  /** 素材 md5 — modality=MATERIAL 时由后端下发,其余 modality 为 null */
+  materialId?: string | null
   modality: Modality
   configCode: string | null
   score: number | null
@@ -95,6 +99,37 @@ export interface VideoMatchEnrichedVO {
   rov: string
   /** 运营/分发指标详情 (后端新增,旧后端无此字段时为 null/undefined) */
   videoDetail?: VideoDetail | null
+  /** 素材详情, modality=MATERIAL 时下发,其余为 null */
+  materialDetail?: MaterialDetail | null
+  /** 长文详情, modality=ARTICLE 时下发,其余为 null */
+  articleDetail?: ArticleDetail | null
+}
+
+/** 素材详情 - modality=MATERIAL 专用 */
+export interface MaterialDetail {
+  title?: string
+  imageCount?: number
+  source?: string
+  uploadTime?: string
+  usageCount?: string
+  tags?: string[]
+  deconstruct?: VideoDetailDeconstruct
+}
+
+/** 长文详情 - modality=ARTICLE 专用 */
+export interface ArticleDetail {
+  title?: string
+  summary?: string
+  wordCount?: number
+  channelName?: string
+  channelAccountId?: string
+  channelAccountName?: string
+  readCount?: string
+  likeCount?: string
+  lookingCount?: string
+  htmlUrl?: string
+  publishTime?: string
+  deconstruct?: VideoDetailDeconstruct
 }
 
 export interface RecallResultVO {
@@ -112,7 +147,7 @@ export interface MatchByTextParam {
 }
 
 export interface MatchByVideoIdParam {
-  videoId: number
+  videoId: string
   configCode?: string
   topN?: number
 }
@@ -133,7 +168,7 @@ export interface HighValuePoint {
 }
 
 export interface DeconstructPointsVO {
-  vid: number
+  vid: string
   title: string | null
   videoUrl: string | null
   htmlUrl: string | null
@@ -144,7 +179,7 @@ export interface DeconstructPointsVO {
 }
 
 export interface AIUnderstandingVO {
-  videoId: number
+  videoId: string
   contentTopic: string | null
   videoTheme: string | null
   videoKeywords: string | null

+ 2 - 1
src/components/MaterialDeconstructTab.tsx

@@ -190,6 +190,7 @@ export default function MaterialDeconstructTab() {
                 <Image
                   src={toHttps(imageUrl)}
                   alt="material"
+                  referrerPolicy="no-referrer"
                   style={{ maxHeight: 200, borderRadius: 4 }}
                   fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
                 />
@@ -242,7 +243,7 @@ function ResultPanel({ result }: { result: DeconstructResultVO }) {
 
   // 适配 DeconstructTree 输入: 复用同一组件保持 Tab1/Tab3 视觉一致
   const treeData: DeconstructPointsVO = {
-    vid: 0,
+    vid: '',
     title: null,
     videoUrl: null,
     htmlUrl: null,

+ 2 - 0
src/components/RecallResultList.tsx

@@ -101,7 +101,9 @@ export default function RecallResultList({ result, loading }: Props) {
         tabBarExtraContent={tabBarExtra}
       />
       <RecallResultTable
+        key={active}
         items={filtered}
+        activeModality={active}
         rankingParams={rankingParams}
         compositeSort={compositeSort}
         onCompositeSortChange={setCompositeSort}

+ 343 - 115
src/components/RecallResultTable.tsx

@@ -16,7 +16,12 @@ import {
   QuestionCircleOutlined,
 } from '@ant-design/icons'
 import type { ColumnsType, SorterResult } from 'antd/es/table/interface'
-import type { EssenceWord, VideoMatchEnrichedVO } from '../api/types'
+import type {
+  EssenceWord,
+  Modality,
+  VideoDetailDeconstruct,
+  VideoMatchEnrichedVO,
+} from '../api/types'
 import { useConfigCodes, getConfigDisplayLabel } from '../api/configCodes'
 import { toHttps } from '../utils/url'
 import {
@@ -65,12 +70,23 @@ const METRIC_CELL_STYLE: React.CSSProperties = {
 
 interface Props {
   items: VideoMatchEnrichedVO[]
+  /** 'ALL' 走视频列布局 */
+  activeModality: 'ALL' | Modality
   rankingParams: RankingParams
   /** 受控的"综合得分"列 sort 状态 — 由外部按钮 + 列头联动 */
   compositeSort: 'descend' | 'ascend' | null
   onCompositeSortChange: (next: 'descend' | 'ascend' | null) => void
 }
 
+/**
+ * 按 modality 取解构对象 - 视频取 videoDetail.deconstruct, 素材/长文取各自 detail
+ */
+function getDeconstruct(item: VideoMatchEnrichedVO): VideoDetailDeconstruct | undefined {
+  if (item.modality === 'MATERIAL') return item.materialDetail?.deconstruct
+  if (item.modality === 'ARTICLE') return item.articleDetail?.deconstruct
+  return item.videoDetail?.deconstruct
+}
+
 /**
  * 召回结果表格
  * 列顺序: 标题/封面/召回维度/综合得分/向量相似度/解构/旧AI/指标
@@ -79,6 +95,7 @@ interface Props {
  */
 export default function RecallResultTable({
   items,
+  activeModality,
   rankingParams,
   compositeSort,
   onCompositeSortChange,
@@ -188,122 +205,124 @@ export default function RecallResultTable({
       )
     }
 
-  const columns: ColumnsType<RowItem> = [
-    {
-      title: '标题',
-      key: 'title',
-      width: 240,
-      fixed: 'left',
-      render: (_v, item) => <TitleCell item={item} />,
-    },
-    {
-      title: '封面',
-      key: 'cover',
-      width: 100,
-      fixed: 'left',
-      render: (_v, item) => <CoverCell item={item} />,
-    },
-    {
-      title: '召回维度',
-      key: 'configCode',
-      width: 130,
-      fixed: 'left',
-      filterIcon: () => (
-        <FilterFilled
+  const titleCol: ColumnsType<RowItem>[number] = {
+    title: '标题',
+    key: 'title',
+    width: 240,
+    fixed: 'left',
+    render: (_v, item) => <TitleCell item={item} />,
+  }
+  const coverCol: ColumnsType<RowItem>[number] = {
+    title: '封面',
+    key: 'cover',
+    width: 100,
+    fixed: 'left',
+    render: (_v, item) => <CoverCell item={item} />,
+  }
+  const configCodeCol: ColumnsType<RowItem>[number] = {
+    title: '召回维度',
+    key: 'configCode',
+    width: 130,
+    fixed: 'left',
+    filterIcon: () => (
+      <FilterFilled
+        style={{
+          color: filters.configCodes.length > 0 ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR,
+        }}
+      />
+    ),
+    filterDropdown: ({ confirm }) => (
+      <div style={{ padding: 8, minWidth: 180 }}>
+        {codeOptions.length === 0 ? (
+          <Text type="secondary" style={{ fontSize: 12 }}>
+            无可选维度
+          </Text>
+        ) : (
+          <Checkbox.Group
+            value={filters.configCodes}
+            onChange={(vals) => setFilters((f) => ({ ...f, configCodes: vals as string[] }))}
+            style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 280, overflowY: 'auto' }}
+          >
+            {codeOptions.map((opt) => (
+              <Checkbox key={opt.value} value={opt.value}>
+                {opt.label}
+              </Checkbox>
+            ))}
+          </Checkbox.Group>
+        )}
+        <div
           style={{
-            color: filters.configCodes.length > 0 ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR,
+            marginTop: 8,
+            paddingTop: 8,
+            borderTop: '1px solid #f0f0f0',
+            display: 'flex',
+            justifyContent: 'space-between',
           }}
-        />
-      ),
-      filterDropdown: ({ confirm }) => (
-        <div style={{ padding: 8, minWidth: 180 }}>
-          {codeOptions.length === 0 ? (
-            <Text type="secondary" style={{ fontSize: 12 }}>
-              无可选维度
-            </Text>
-          ) : (
-            <Checkbox.Group
-              value={filters.configCodes}
-              onChange={(vals) => setFilters((f) => ({ ...f, configCodes: vals as string[] }))}
-              style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 280, overflowY: 'auto' }}
-            >
-              {codeOptions.map((opt) => (
-                <Checkbox key={opt.value} value={opt.value}>
-                  {opt.label}
-                </Checkbox>
-              ))}
-            </Checkbox.Group>
-          )}
-          <div
-            style={{
-              marginTop: 8,
-              paddingTop: 8,
-              borderTop: '1px solid #f0f0f0',
-              display: 'flex',
-              justifyContent: 'space-between',
+        >
+          <Button
+            size="small"
+            onClick={() => {
+              setFilters((f) => ({ ...f, configCodes: [] }))
+              confirm({ closeDropdown: true })
             }}
           >
-            <Button
-              size="small"
-              onClick={() => {
-                setFilters((f) => ({ ...f, configCodes: [] }))
-                confirm({ closeDropdown: true })
-              }}
-            >
-              清除
-            </Button>
-            <Button size="small" type="primary" onClick={() => confirm({ closeDropdown: true })}>
-              确定
-            </Button>
-          </div>
+            清除
+          </Button>
+          <Button size="small" type="primary" onClick={() => confirm({ closeDropdown: true })}>
+            确定
+          </Button>
         </div>
-      ),
-      render: (_v, item) => {
-        if (!item.configCode) return <Text type="secondary">--</Text>
-        const label = getConfigDisplayLabel(item.configCode, configCodes)
-        return (
-          <Tooltip title={item.configCode}>
-            <Tag color="purple" style={{ margin: 0 }}>
-              {label}
-            </Tag>
-          </Tooltip>
-        )
-      },
-    },
-    {
-      title: (
-        <Tooltip
-          overlayStyle={{ maxWidth: 360 }}
-          title={
-            <div style={{ fontSize: 12 }}>
-              点击列头按综合得分倒排,公式见右上角"排序参数"。
-            </div>
-          }
-        >
-          <span>
-            综合得分{' '}
-            <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
-          </span>
+      </div>
+    ),
+    render: (_v, item) => {
+      if (!item.configCode) return <Text type="secondary">--</Text>
+      const label = getConfigDisplayLabel(item.configCode, configCodes)
+      return (
+        <Tooltip title={item.configCode}>
+          <Tag color="purple" style={{ margin: 0 }}>
+            {label}
+          </Tag>
         </Tooltip>
-      ),
-      key: 'composite',
-      width: 130,
-      align: 'center',
-      fixed: 'left',
-      sorter: (a, b) =>
-        (a._breakdown?.composite ?? -Infinity) - (b._breakdown?.composite ?? -Infinity),
-      sortDirections: ['descend', 'ascend'],
-      sortOrder: compositeSort ?? null,
-      render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} />,
-    },
-    {
-      title: '向量相似度',
-      key: 'score',
-      width: 130,
-      align: 'center',
-      fixed: 'left',
-      render: (_v, item) => <ScoreCell score={item.score} />,
+      )
     },
+  }
+  const compositeCol: ColumnsType<RowItem>[number] = {
+    title: (
+      <Tooltip
+        overlayStyle={{ maxWidth: 360 }}
+        title={
+          <div style={{ fontSize: 12 }}>
+            点击列头按综合得分倒排,公式见右上角"排序参数"。
+          </div>
+        }
+      >
+        <span>
+          综合得分{' '}
+          <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
+        </span>
+      </Tooltip>
+    ),
+    key: 'composite',
+    width: 130,
+    align: 'center',
+    fixed: 'left',
+    sorter: (a, b) =>
+      (a._breakdown?.composite ?? -Infinity) - (b._breakdown?.composite ?? -Infinity),
+    sortDirections: ['descend', 'ascend'],
+    sortOrder: compositeSort ?? null,
+    render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} />,
+  }
+  const scoreColumn: ColumnsType<RowItem>[number] = {
+    title: '向量相似度',
+    key: 'score',
+    width: 130,
+    align: 'center',
+    fixed: 'left',
+    render: (_v, item) => <ScoreCell score={item.score} />,
+  }
+
+  /** 视频专属列(推荐状态 + 解构 + 旧 AI + 运营指标) */
+  const videoOnlyCols: ColumnsType<RowItem> = [
     {
       title: '推荐状态',
       key: 'recommendStatus',
@@ -366,7 +385,6 @@ export default function RecallResultTable({
       sorter: (a, b) => {
         const av = parseNum(a.videoDetail?.rov)
         const bv = parseNum(b.videoDetail?.rov)
-        // 缺失值统一沉到最末: 不论升降序都被排到尾巴
         if (av == null && bv == null) return 0
         if (av == null) return 1
         if (bv == null) return -1
@@ -387,6 +405,189 @@ export default function RecallResultTable({
     },
   ]
 
+  /** 素材专属列 */
+  const materialOnlyCols: ColumnsType<RowItem> = [
+    {
+      title: '图片张数',
+      key: 'imageCount',
+      width: 90,
+      align: 'right',
+      render: (_v, item) => {
+        const n = item.materialDetail?.imageCount ?? item.imageList?.length
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{n != null ? n : '--'}</span>
+      },
+    },
+    {
+      title: '来源',
+      key: 'material.source',
+      width: 100,
+      render: (_v, item) => textOrDash(item.materialDetail?.source),
+    },
+    {
+      title: '上传时间',
+      key: 'material.uploadTime',
+      width: 140,
+      render: (_v, item) => textOrDash(item.materialDetail?.uploadTime),
+    },
+    {
+      title: '使用次数',
+      key: 'material.usageCount',
+      width: 100,
+      align: 'right',
+      onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
+      onCell: () => ({ style: METRIC_CELL_STYLE }),
+      sorter: (a, b) => {
+        const av = parseNum(a.materialDetail?.usageCount)
+        const bv = parseNum(b.materialDetail?.usageCount)
+        if (av == null && bv == null) return 0
+        if (av == null) return 1
+        if (bv == null) return -1
+        return av - bv
+      },
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => (
+        <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
+          {formatNumber(item.materialDetail?.usageCount)}
+        </span>
+      ),
+    },
+    {
+      title: '标签',
+      key: 'material.tags',
+      width: 240,
+      render: (_v, item) => <TagsCell tags={item.materialDetail?.tags} />,
+    },
+    deconstructTopicCol(280),
+    pointsCol('解构:灵感点', '灵感点', 240),
+    pointsCol('解构:关键点', '关键点', 240),
+    pointsCol('解构:目的点', '目的点', 240),
+  ]
+
+  /** 长文专属列 */
+  const articleOnlyCols: ColumnsType<RowItem> = [
+    {
+      title: '来源',
+      key: 'article.channelName',
+      width: 140,
+      render: (_v, item) => textOrDash(item.articleDetail?.channelName),
+    },
+    {
+      title: '作者',
+      key: 'article.channelAccountName',
+      width: 140,
+      render: (_v, item) => textOrDash(item.articleDetail?.channelAccountName),
+    },
+    {
+      title: '阅读量',
+      key: 'article.readCount',
+      width: 110,
+      align: 'right',
+      onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
+      onCell: () => ({ style: METRIC_CELL_STYLE }),
+      sorter: (a, b) => {
+        const av = parseNum(a.articleDetail?.readCount)
+        const bv = parseNum(b.articleDetail?.readCount)
+        if (av == null && bv == null) return 0
+        if (av == null) return 1
+        if (bv == null) return -1
+        return av - bv
+      },
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => (
+        <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
+          {formatNumber(item.articleDetail?.readCount)}
+        </span>
+      ),
+    },
+    {
+      title: '点赞',
+      key: 'article.likeCount',
+      width: 100,
+      align: 'right',
+      render: (_v, item) => (
+        <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
+          {formatNumber(item.articleDetail?.likeCount)}
+        </span>
+      ),
+    },
+    {
+      title: '在看',
+      key: 'article.lookingCount',
+      width: 100,
+      align: 'right',
+      render: (_v, item) => (
+        <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
+          {formatNumber(item.articleDetail?.lookingCount)}
+        </span>
+      ),
+    },
+    {
+      title: '字数',
+      key: 'article.wordCount',
+      width: 90,
+      align: 'right',
+      render: (_v, item) => {
+        const n = item.articleDetail?.wordCount
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{n ?? '--'}</span>
+      },
+    },
+    {
+      title: '摘要',
+      key: 'article.summary',
+      width: 320,
+      render: (_v, item) => {
+        const s = item.articleDetail?.summary
+        if (!s) return <Text type="secondary">--</Text>
+        return (
+          <Paragraph
+            style={{ marginBottom: 0, fontSize: 12, lineHeight: 1.45, whiteSpace: 'normal', wordBreak: 'break-word' }}
+            ellipsis={{ rows: 3, tooltip: s }}
+          >
+            {s}
+          </Paragraph>
+        )
+      },
+    },
+    {
+      title: '发布时间',
+      key: 'article.publishTime',
+      width: 150,
+      render: (_v, item) => textOrDash(item.articleDetail?.publishTime),
+    },
+    {
+      title: '原文',
+      key: 'article.htmlUrl',
+      width: 80,
+      align: 'center',
+      render: (_v, item) => {
+        const url = item.articleDetail?.htmlUrl
+        if (!url) return <Text type="secondary">--</Text>
+        return (
+          <a href={toHttps(url)} target="_blank" rel="noopener noreferrer">
+            打开
+          </a>
+        )
+      },
+    },
+    deconstructTopicCol(280),
+    pointsCol('解构:灵感点', '灵感点', 240),
+    pointsCol('解构:关键点', '关键点', 240),
+    pointsCol('解构:目的点', '目的点', 240),
+  ]
+
+  /** 按 modality 拼装最终列 + 计算 scroll.x */
+  let columns: ColumnsType<RowItem>
+  if (activeModality === 'MATERIAL') {
+    // 素材 Tab: 不展示综合得分列(rov 不适用)
+    columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...materialOnlyCols]
+  } else if (activeModality === 'ARTICLE') {
+    columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...articleOnlyCols]
+  } else {
+    // VIDEO + ALL 走视频列布局
+    columns = [titleCol, coverCol, configCodeCol, compositeCol, scoreColumn, ...videoOnlyCols]
+  }
+  const scrollX = columns.reduce((s, c) => s + (Number(c.width) || 0), 0)
+
   return (
     <div>
       {thresholdRejected > 0 && (
@@ -440,7 +641,7 @@ export default function RecallResultTable({
         dataSource={filteredItems}
         columns={columns}
         pagination={false}
-        scroll={{ x: 2530 }}
+        scroll={{ x: scrollX }}
         onChange={(_pagination, _filters, sorter) => {
           const s = sorter as SorterResult<RowItem>
           if (s && s.columnKey === 'composite') {
@@ -523,14 +724,14 @@ function textCol(
   }
 }
 
-/** 解构:选题 列 - 文本 ellipsis + tooltip,数据源 videoDetail.deconstruct.topic */
+/** 解构:选题 列 - 文本 ellipsis + tooltip,按 modality 从对应 detail.deconstruct 取 */
 function deconstructTopicCol(width: number): ColumnsType<RowItem>[number] {
   return {
     title: '解构:选题',
     key: 'deconstruct.topic',
     width,
     render: (_v, item) => {
-      const topic = item.videoDetail?.deconstruct?.topic
+      const topic = getDeconstruct(item)?.topic
       if (!topic) return <Text type="secondary">--</Text>
       return (
         <Tooltip title={topic} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
@@ -559,7 +760,7 @@ function pointsCol(
     key: title,
     width,
     render: (_v, item) => {
-      const dec = item.videoDetail?.deconstruct
+      const dec = getDeconstruct(item)
       if (!dec) return <Text type="secondary">--</Text>
       const names = (dec[type] ?? []) as string[]
       const essences = (dec[essenceKey] ?? []) as EssenceWord[]
@@ -659,6 +860,7 @@ function CoverCell({ item }: { item: VideoMatchEnrichedVO }) {
         <img
           src={imgSrc}
           alt="cover"
+          referrerPolicy="no-referrer"
           style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
           onError={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
         />
@@ -784,3 +986,29 @@ function CompositeScoreCell({ breakdown }: { breakdown: ScoreBreakdown | null })
     </Tooltip>
   )
 }
+
+/** 简单文本/占位 */
+function textOrDash(s: string | undefined) {
+  if (!s) return <Text type="secondary">--</Text>
+  return (
+    <Tooltip title={s} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
+      <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
+        {s}
+      </Text>
+    </Tooltip>
+  )
+}
+
+/** 标签列单元格 - 多个 Tag wrap 展示 */
+function TagsCell({ tags }: { tags: string[] | undefined }) {
+  if (!tags || tags.length === 0) return <Text type="secondary">--</Text>
+  return (
+    <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
+      {tags.map((t, i) => (
+        <Tag key={`${t}-${i}`} color="geekblue" style={{ margin: 0, fontSize: 11 }}>
+          {t}
+        </Tag>
+      ))}
+    </Space>
+  )
+}

+ 20 - 19
src/pages/RecallTestPage.tsx

@@ -59,12 +59,12 @@ import type {
 const { TextArea } = Input
 const { Text, Title } = Typography
 
-/** 从 URL ?videoId=xxx 读取并校验,无效返回 null */
-function readUrlVideoId(): number | null {
+/** 从 URL ?videoId=xxx 读取,空白返回 null;不再校验是否为数字 */
+function readUrlVideoId(): string | null {
   const raw = new URLSearchParams(window.location.search).get('videoId')
   if (!raw) return null
-  const n = Number(raw)
-  return Number.isFinite(n) && n > 0 ? n : null
+  const trimmed = raw.trim()
+  return trimmed.length > 0 ? trimmed : null
 }
 
 /** 从 URL ?queryText=xxx 读取,空值返 null */
@@ -126,7 +126,7 @@ function textsForConfigCode(
 }
 
 /** 从召回结果里剔除指定视频ID, 同时刷新 modality 计数 */
-function filterOutSelf(data: RecallResultVO, excludeId: number | null): RecallResultVO {
+function filterOutSelf(data: RecallResultVO, excludeId: string | null): RecallResultVO {
   if (!excludeId) return data
   const items = data.items.filter((x) => x.id !== excludeId)
   return {
@@ -195,12 +195,12 @@ export default function RecallTestPage() {
 // Tab1: 票圈视频ID — 三栏并列布局(视频详情 / AI理解 / 解构层级 都是召回维度)
 // ============================================================================
 /** 进入页面默认查询的视频ID, 避免空白态 */
-const DEFAULT_VIDEO_ID = 60308273
+const DEFAULT_VIDEO_ID = '60308273'
 
 function VideoIdTab() {
   /** URL 上 ?videoId=xxx 优先于默认 ID;有则首次查询后自动按选题召回 */
   const urlVideoId = readUrlVideoId()
-  const [videoId, setVideoId] = useState<number | null>(urlVideoId ?? DEFAULT_VIDEO_ID)
+  const [videoId, setVideoId] = useState<string | null>(urlVideoId ?? DEFAULT_VIDEO_ID)
   const pendingAutoRecallRef = useRef(urlVideoId !== null)
   const [detail, setDetail] = useState<VideoBasicVO | null>(null)
   const [ai, setAi] = useState<AIUnderstandingVO | null>(null)
@@ -221,7 +221,7 @@ function VideoIdTab() {
     description: string
   } | null>(null)
 
-  const runQuery = useCallback(async (id: number) => {
+  const runQuery = useCallback(async (id: string) => {
     setLoadingMain(true)
     setQueried(true)
     setRecall(null)
@@ -241,11 +241,12 @@ function VideoIdTab() {
   }, [])
 
   const onQuery = () => {
-    if (!videoId || videoId <= 0) {
+    const id = videoId?.trim()
+    if (!id) {
       message.warning('请输入有效的视频ID')
       return
     }
-    runQuery(videoId)
+    runQuery(id)
   }
 
   /** 进入页面自动用默认ID查一次, 避免空白态 */
@@ -253,8 +254,9 @@ function VideoIdTab() {
   useEffect(() => {
     if (autoQueriedRef.current) return
     autoQueriedRef.current = true
-    if (videoId && videoId > 0) {
-      runQuery(videoId)
+    const id = videoId?.trim()
+    if (id) {
+      runQuery(id)
     }
   }, [runQuery, videoId])
 
@@ -384,21 +386,20 @@ function VideoIdTab() {
       <Card size="small" bodyStyle={{ padding: '12px 16px' }}>
         <Space wrap size={[12, 8]}>
           <Text strong>视频ID</Text>
-          <InputNumber
+          <Input
             placeholder="请输入"
-            min={1}
-            value={videoId}
-            onChange={(v) => setVideoId(v)}
+            value={videoId ?? ''}
+            onChange={(e) => setVideoId(e.target.value)}
             style={{ width: 200 }}
             onPressEnter={onQuery}
-            controls={false}
+            allowClear
           />
           <Button type="primary" icon={<SearchOutlined />} loading={loadingMain} onClick={onQuery}>
             查询
           </Button>
           <span style={{ color: '#d9d9d9' }}>|</span>
           <Text strong>TopN</Text>
-          <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
+          <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
           <Tooltip title="遍历所有维度, 各自从解构/AI 取文本, 合并去重">
             <Button
               type="primary"
@@ -769,7 +770,7 @@ function TextRecallTab() {
               allowClear
             />
             <Text strong>TopN</Text>
-            <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
+            <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>