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

feat: Tab3 素材解构 (URL 提交 + 2s 轮询 + 复用 DeconstructTree 渲染)

- 后端走 /videoSearch/getDeconstructResultMini 精简接口, 直接返回 topic + highValuePoints
- DeconstructTree 加 disableRecall 开关, Tab3 全部 "以此召回" 灰掉(本期不开放)
- Tab1 调用零改动, 行为不变

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 2 дней назад
Родитель
Сommit
ed17a3e58f

+ 24 - 0
src/api/recall.ts

@@ -3,6 +3,9 @@ import type {
   AIUnderstandingVO,
   CommonResponse,
   DeconstructPointsVO,
+  DeconstructQueryParam,
+  DeconstructResultVO,
+  DeconstructSubmitParam,
   MatchByTextParam,
   MatchByVideoIdParam,
   RecallResultVO,
@@ -69,3 +72,24 @@ export async function getAllConfigCodes(): Promise<Record<string, string>> {
   )
   return resp.data?.data ?? {}
 }
+
+/** Tab3: 提交素材解构任务 - 返回 taskId */
+export async function submitDeconstruct(param: DeconstructSubmitParam): Promise<string> {
+  const resp = await client.post<CommonResponse<string>>(
+    '/videoSearch/deconstruct',
+    param,
+  )
+  return resp.data?.data ?? ''
+}
+
+/** Tab3: 查询解构结果 (轮询用; 走精简接口, 后端直接返回 topic + highValuePoints) */
+export async function getDeconstructResult(
+  param: DeconstructQueryParam,
+): Promise<DeconstructResultVO | null> {
+  const resp = await client.post<CommonResponse<DeconstructResultVO | null>>(
+    '/videoSearch/getDeconstructResultMini',
+    param,
+  )
+  return resp.data?.data ?? null
+}
+

+ 49 - 0
src/api/types.ts

@@ -151,3 +151,52 @@ export interface AIUnderstandingVO {
   videoNarration: string | null
   dt: string | null
 }
+
+/**
+ * 提交解构任务参数 - 对应后端 DeconstructParam
+ * bizType: 0投流
+ * contentType: 1长文 / 2图文 / 3视频
+ */
+export interface DeconstructSubmitParam {
+  bizType: number
+  contentType: number
+  channelContentId: string
+  title?: string
+  bodyText?: string
+  videoUrl?: string
+  imageList?: string[]
+  channelAccountId?: string
+  channelAccountName?: string
+}
+
+/** 查询解构结果参数 - 对应后端 GetDeconstructParam */
+export interface DeconstructQueryParam {
+  taskId: string
+  bizType?: number
+  contentType?: number
+  channelContentId?: string
+  forceRefresh?: boolean
+}
+
+/**
+ * 解构结果(精简版): 后端已经抽取 topic + highValuePoints, 不再返回原始 result
+ * status: 0=PENDING / 1=RUNNING / 2=SUCCESS / 3=FAILED
+ * 未完成 / 失败时 topic 为 "", highValuePoints 为 []
+ */
+export interface DeconstructResultVO {
+  taskId: string
+  status: number
+  statusDesc: string
+  success: boolean
+  finished: boolean
+  fromCache: boolean
+  topic: string
+  highValuePoints: HighValuePoint[]
+  reason?: string
+  url?: {
+    pointUrl?: string
+    weightUrl?: string
+    patternUrl?: string
+  }
+}
+

+ 29 - 9
src/components/DeconstructTree.tsx

@@ -10,6 +10,10 @@ interface Props {
   loading?: boolean
   /** 第二个参数 configCode 优先于 dropdown 的选择 */
   onRecallByText: (text: string, configCodeOverride?: string) => void
+  /** true 时所有"以此召回"按钮统一灰掉 (素材解构页用) */
+  disableRecall?: boolean
+  /** disableRecall=true 时按钮 hover 提示文案 */
+  disabledRecallTip?: string
 }
 
 const TYPE_COLOR: Record<string, string> = {
@@ -59,7 +63,13 @@ const DISABLED_CHILD_BTN_STYLE: React.CSSProperties = {
  *      字典里有该 configCode → 生效(绿色按钮); 没有 → disabled(灰色按钮 + 提示)
  *  - 任何点下的实质词"召回"               → disabled 灰色 (暂不支持词级)
  */
-export default function DeconstructTree({ data, loading, onRecallByText }: Props) {
+export default function DeconstructTree({
+  data,
+  loading,
+  onRecallByText,
+  disableRecall,
+  disabledRecallTip,
+}: Props) {
   const configCodes = useConfigCodes()
   if (loading) return <div>加载中...</div>
   if (!data) {
@@ -67,10 +77,12 @@ export default function DeconstructTree({ data, loading, onRecallByText }: Props
   }
 
   const topicCode = TREE_NODE_TYPE_TO_CONFIG_CODE['选题']
-  const topicSupported = !!(topicCode && topicCode in configCodes)
-  const topicTip = topicSupported
-    ? ''
-    : `当前未启用"选题"维度向量召回 (${topicCode} 不在后端字典)`
+  const topicSupported = !disableRecall && !!(topicCode && topicCode in configCodes)
+  const topicTip = disableRecall
+    ? (disabledRecallTip ?? '当前页暂不支持以此召回')
+    : topicSupported
+      ? ''
+      : `当前未启用"选题"维度向量召回 (${topicCode} 不在后端字典)`
 
   return (
     <Space direction="vertical" size={12} style={{ width: '100%' }}>
@@ -136,6 +148,8 @@ export default function DeconstructTree({ data, loading, onRecallByText }: Props
               point={p}
               configCodes={configCodes}
               onRecallByText={onRecallByText}
+              disableRecall={disableRecall}
+              disabledRecallTip={disabledRecallTip}
             />
           ))}
         </Space>
@@ -150,19 +164,25 @@ function PointCard({
   point,
   configCodes,
   onRecallByText,
+  disableRecall,
+  disabledRecallTip,
 }: {
   point: HighValuePoint
   configCodes: Record<string, string>
   onRecallByText: (t: string, code?: string) => void
+  disableRecall?: boolean
+  disabledRecallTip?: string
 }) {
   const color = TYPE_COLOR[point.type] || 'default'
   const mappedCode = TREE_NODE_TYPE_TO_CONFIG_CODE[point.type]
-  const supported = !!(mappedCode && mappedCode in configCodes)
+  const supported = !disableRecall && !!(mappedCode && mappedCode in configCodes)
 
   // 整条召回按钮 (生效 vs 灰色) - 父级,默认中号
-  const unsupportedTip = mappedCode
-    ? `当前未启用"${point.type}"维度向量召回 (${mappedCode} 不在后端字典)`
-    : `节点类型"${point.type}"无法映射到任何 configCode`
+  const unsupportedTip = disableRecall
+    ? (disabledRecallTip ?? '当前页暂不支持以此召回')
+    : mappedCode
+      ? `当前未启用"${point.type}"维度向量召回 (${mappedCode} 不在后端字典)`
+      : `节点类型"${point.type}"无法映射到任何 configCode`
   const headerBtn = supported ? (
     <Button
       type="primary"

+ 300 - 0
src/components/MaterialDeconstructTab.tsx

@@ -0,0 +1,300 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+  Card,
+  Input,
+  Button,
+  Space,
+  message,
+  Image,
+  Tag,
+  Typography,
+  Steps,
+  Empty,
+  Tooltip,
+} from 'antd'
+import {
+  LinkOutlined,
+  SendOutlined,
+  PictureOutlined,
+  UploadOutlined,
+} from '@ant-design/icons'
+import {
+  submitDeconstruct,
+  getDeconstructResult,
+} from '../api/recall'
+import type { DeconstructPointsVO, DeconstructResultVO } from '../api/types'
+import DeconstructTree from './DeconstructTree'
+import { toHttps } from '../utils/url'
+
+const { Text } = Typography
+
+const POLL_INTERVAL_MS = 2000
+const POLL_TIMEOUT_MS = 5 * 60_000
+
+type StageKey = 'idle' | 'submitting' | 'polling' | 'done' | 'error'
+
+/** 状态码 -> 中文 + 颜色 */
+function statusMeta(status: number | undefined): { label: string; color: string } {
+  switch (status) {
+    case 0: return { label: 'PENDING', color: 'default' }
+    case 1: return { label: 'RUNNING', color: 'processing' }
+    case 2: return { label: 'SUCCESS', color: 'success' }
+    case 3: return { label: 'FAILED', color: 'error' }
+    default: return { label: '--', color: 'default' }
+  }
+}
+
+export default function MaterialDeconstructTab() {
+  const [imageUrl, setImageUrl] = useState<string>('')
+  const [title, setTitle] = useState<string>('')
+  const [stage, setStage] = useState<StageKey>('idle')
+  const [taskId, setTaskId] = useState<string>('')
+  const [pollAttempt, setPollAttempt] = useState<number>(0)
+  const [result, setResult] = useState<DeconstructResultVO | null>(null)
+  const pollTimerRef = useRef<number | null>(null)
+  const pollStartedAtRef = useRef<number>(0)
+
+  const stopPolling = useCallback(() => {
+    if (pollTimerRef.current) {
+      window.clearTimeout(pollTimerRef.current)
+      pollTimerRef.current = null
+    }
+  }, [])
+
+  useEffect(() => () => stopPolling(), [stopPolling])
+
+  /** 提交解构任务 → 拿 taskId → 启动轮询 */
+  const onSubmit = useCallback(async () => {
+    const url = imageUrl.trim()
+    if (!url) {
+      message.warning('请先填入图片URL')
+      return
+    }
+    stopPolling()
+    setResult(null)
+    setTaskId('')
+    setPollAttempt(0)
+    setStage('submitting')
+    try {
+      const cid = `material-${Date.now()}`
+      const trimmedTitle = title.trim()
+      const id = await submitDeconstruct({
+        bizType: 0,
+        contentType: 2,
+        channelContentId: cid,
+        imageList: [url],
+        ...(trimmedTitle ? { title: trimmedTitle } : {}),
+      })
+      if (!id) {
+        message.error('提交失败: 未返回 taskId')
+        setStage('error')
+        return
+      }
+      setTaskId(id)
+      setStage('polling')
+      pollStartedAtRef.current = Date.now()
+      pollOnce(id, cid)
+    } catch (e) {
+      message.error('提交解构失败')
+      setStage('error')
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [imageUrl, title, stopPolling])
+
+  const pollOnce = (id: string, cid: string) => {
+    setPollAttempt((n) => n + 1)
+    getDeconstructResult({
+      taskId: id,
+      bizType: 0,
+      contentType: 2,
+      channelContentId: cid,
+    })
+      .then((r) => {
+        if (!r) {
+          schedulePoll(id, cid)
+          return
+        }
+        setResult(r)
+        if (r.finished) {
+          setStage('done')
+          if (r.success) message.success('解构完成')
+          else message.error('解构失败: ' + (r.reason ?? '未知原因'))
+          return
+        }
+        schedulePoll(id, cid)
+      })
+      .catch(() => {
+        schedulePoll(id, cid)
+      })
+  }
+
+  const schedulePoll = (id: string, cid: string) => {
+    if (Date.now() - pollStartedAtRef.current > POLL_TIMEOUT_MS) {
+      message.warning('轮询超时, 请稍后再查')
+      setStage('error')
+      return
+    }
+    pollTimerRef.current = window.setTimeout(() => pollOnce(id, cid), POLL_INTERVAL_MS)
+  }
+
+  const sm = statusMeta(result?.status)
+
+  return (
+    <Space direction="vertical" size={16} style={{ width: '100%' }}>
+      <Card size="small" bodyStyle={{ padding: 16 }}>
+        <Space direction="vertical" size={12} style={{ width: '100%' }}>
+          <Space wrap size={8} style={{ width: '100%' }}>
+            <Text strong>图片URL</Text>
+            <Input
+              placeholder="粘贴公网可访问的图片 URL"
+              value={imageUrl}
+              onChange={(e) => setImageUrl(e.target.value)}
+              style={{ width: 480 }}
+              prefix={<LinkOutlined />}
+              allowClear
+            />
+            {/* 本地上传入口 - 暂未启用, 待 OSS 直传打通后开放 */}
+            <Tooltip title="本地上传暂未开放, 请使用图片 URL">
+              <Button icon={<UploadOutlined />} disabled>
+                本地上传
+              </Button>
+            </Tooltip>
+          </Space>
+
+          <Space wrap size={8} style={{ width: '100%' }}>
+            <Text strong>
+              标题 <Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>(可选)</Text>
+            </Text>
+            <Input
+              placeholder="可选: 输入素材标题, 辅助解构理解"
+              value={title}
+              onChange={(e) => setTitle(e.target.value)}
+              style={{ width: 480 }}
+              allowClear
+            />
+            <Button
+              type="primary"
+              icon={<SendOutlined />}
+              loading={stage === 'submitting' || stage === 'polling'}
+              onClick={onSubmit}
+              disabled={!imageUrl.trim()}
+            >
+              提交解构
+            </Button>
+          </Space>
+
+          {imageUrl && (
+            <div>
+              <Text type="secondary" style={{ fontSize: 12 }}>预览:</Text>
+              <div style={{ marginTop: 4 }}>
+                <Image
+                  src={toHttps(imageUrl)}
+                  alt="material"
+                  style={{ maxHeight: 200, borderRadius: 4 }}
+                  fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+                />
+              </div>
+            </div>
+          )}
+        </Space>
+      </Card>
+
+      {(stage !== 'idle' || result) && (
+        <Card size="small" title={
+          <Space>
+            <PictureOutlined style={{ color: '#722ed1' }} />
+            <span>解构进度 & 结果</span>
+            {taskId && <Tag style={{ marginLeft: 4 }}>taskId: {taskId}</Tag>}
+          </Space>
+        }>
+          <Space direction="vertical" size={12} style={{ width: '100%' }}>
+            <Steps
+              size="small"
+              current={
+                stage === 'submitting' ? 0 :
+                stage === 'polling' ? 1 :
+                stage === 'done' ? 2 :
+                0
+              }
+              status={stage === 'error' ? 'error' : undefined}
+              items={[
+                { title: '提交解构' },
+                { title: '等待结果', description: stage === 'polling' ? `第 ${pollAttempt} 次轮询` : undefined },
+                { title: '完成', description: result ? sm.label : undefined },
+              ]}
+            />
+
+            {result && <ResultPanel result={result} />}
+
+            {!result && (stage === 'submitting' || stage === 'polling') && (
+              <Text type="secondary">解构进行中, 每 2 秒自动刷新…</Text>
+            )}
+          </Space>
+        </Card>
+      )}
+    </Space>
+  )
+}
+
+/** 结果展示: 状态 Tag + URL 三件套 + 复用 DeconstructTree 渲染选题/灵感点/关键点/目的点 */
+function ResultPanel({ result }: { result: DeconstructResultVO }) {
+  const sm = statusMeta(result.status)
+
+  // 适配 DeconstructTree 输入: 复用同一组件保持 Tab1/Tab3 视觉一致
+  const treeData: DeconstructPointsVO = {
+    vid: 0,
+    title: null,
+    videoUrl: null,
+    htmlUrl: null,
+    topic: result.topic ?? null,
+    highValuePoints: result.highValuePoints ?? [],
+  }
+
+  // 素材解构页暂不开放"以此召回", 全部按钮灰掉(noop)
+  const onRecall = () => {}
+
+  const hasContent = !!result.topic || (result.highValuePoints && result.highValuePoints.length > 0)
+
+  return (
+    <div>
+      <Space wrap size={8} style={{ marginBottom: 12 }}>
+        <Tag color={sm.color}>{sm.label}</Tag>
+        {result.fromCache && <Tag color="cyan">缓存命中</Tag>}
+        {result.url?.pointUrl && (
+          <Button size="small" type="link" href={result.url.pointUrl} target="_blank">
+            pointUrl
+          </Button>
+        )}
+        {result.url?.weightUrl && (
+          <Button size="small" type="link" href={result.url.weightUrl} target="_blank">
+            weightUrl
+          </Button>
+        )}
+        {result.url?.patternUrl && (
+          <Button size="small" type="link" href={result.url.patternUrl} target="_blank">
+            patternUrl
+          </Button>
+        )}
+      </Space>
+
+      {result.reason && (
+        <Typography.Paragraph type="warning" style={{ marginBottom: 12 }}>
+          原因: {result.reason}
+        </Typography.Paragraph>
+      )}
+
+      {result.success && hasContent ? (
+        <DeconstructTree
+          data={treeData}
+          onRecallByText={onRecall}
+          disableRecall
+          disabledRecallTip="素材解构页暂未开放「以此召回」"
+        />
+      ) : !result.finished ? (
+        <Text type="secondary">解构尚未完成…</Text>
+      ) : (
+        <Empty description="无解构结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
+      )}
+    </div>
+  )
+}

+ 3 - 3
src/pages/RecallTestPage.tsx

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