Kaynağa Gözat

兼容长文解构信息

luojunhui 1 gün önce
ebeveyn
işleme
9a5efe1096

+ 1 - 1
.env.production

@@ -1 +1 @@
-VITE_API_BASE_URL=/manager/vectorRecall
+VITE_API_BASE_URL=/manager/vectorRecallProxy

+ 20 - 0
src/api/recall.ts

@@ -1,11 +1,13 @@
 import client from './client'
 import type {
   AIUnderstandingVO,
+  ArticleBasicVO,
   CommonResponse,
   DeconstructPointsVO,
   DeconstructQueryParam,
   DeconstructResultVO,
   DeconstructSubmitParam,
+  MatchByArticleIdParam,
   MatchByMaterialIdParam,
   MatchByTextParam,
   MatchByVideoIdParam,
@@ -81,6 +83,24 @@ export async function matchByMaterialId(param: MatchByMaterialIdParam): Promise<
   return normalizeRecallResult(resp.data?.data)
 }
 
+/** Tab4: 获取长文基础信息 */
+export async function getArticleDetail(articleId: string): Promise<ArticleBasicVO | null> {
+  const resp = await client.get<CommonResponse<ArticleBasicVO | null>>(
+    '/recallTest/articleDetail',
+    { params: { articleId } },
+  )
+  return resp.data?.data ?? null
+}
+
+/** Tab4: 通过长文ID召回相似 */
+export async function matchByArticleId(param: MatchByArticleIdParam): Promise<RecallResultVO> {
+  const resp = await client.post<CommonResponse<RecallResultVO>>(
+    '/recallTest/matchByArticleId',
+    param,
+  )
+  return normalizeRecallResult(resp.data?.data)
+}
+
 /** Tab1: 视频/素材的解构层级 */
 export async function getDeconstructPoints(videoId: string): Promise<DeconstructPointsVO | null> {
   const resp = await client.get<CommonResponse<DeconstructPointsVO | null>>(

+ 16 - 0
src/api/types.ts

@@ -24,6 +24,16 @@ export interface MaterialBasicVO {
   title: string | null
 }
 
+/** 长文基础信息 (用于 Tab4 预览) */
+export interface ArticleBasicVO {
+  articleId: string
+  title: string | null
+  summary: string | null
+  cover: string | null
+  url: string | null
+  source: string | null
+}
+
 /**
  * 后端返回的视频运营/分发维度详情(嵌套对象)
  * key 为后端原样字段(中英文混合),全部可选 — 缺字段时前端用 "--" 占位
@@ -165,6 +175,12 @@ export interface MatchByMaterialIdParam {
   topN?: number
 }
 
+export interface MatchByArticleIdParam {
+  articleId: string
+  configCode?: string
+  topN?: number
+}
+
 export interface EssenceWord {
   word: string
   score: number

+ 267 - 0
src/components/ArticleRecallTab.tsx

@@ -0,0 +1,267 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+  Card,
+  Input,
+  InputNumber,
+  Select,
+  Button,
+  Space,
+  message,
+  Image,
+  Typography,
+  Empty,
+} from 'antd'
+import {
+  ReadOutlined,
+  SearchOutlined,
+  LinkOutlined,
+} from '@ant-design/icons'
+import RecallResultList from './RecallResultList'
+import { getArticleDetail, matchByArticleId } from '../api/recall'
+import type { ArticleBasicVO, RecallResultVO } from '../api/types'
+import { toHttps } from '../utils/url'
+import {
+  ALL_CONFIG_CODE,
+  buildGroupedConfigOptions,
+  getConfigDisplayLabel,
+  listAllConfigCodes,
+  useConfigCodes,
+} from '../api/configCodes'
+
+const { Text, Paragraph } = Typography
+
+export default function ArticleRecallTab() {
+  const [articleId, setArticleId] = useState('')
+  const [articleInfo, setArticleInfo] = useState<ArticleBasicVO | 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)
+  const [resultMeta, setResultMeta] = useState<{
+    dimensionLabel: string
+    dimensionCode: string
+    description: string
+  } | null>(null)
+
+  const submitGenRef = useRef(0)
+  const infoTimerRef = useRef<number | null>(null)
+
+  /** articleId 变化时, 自动查长文详情 */
+  useEffect(() => {
+    if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
+    const id = articleId.trim()
+    if (!id) {
+      setArticleInfo(null)
+      return
+    }
+    infoTimerRef.current = window.setTimeout(() => {
+      setLoadingInfo(true)
+      getArticleDetail(id)
+        .then((info) => setArticleInfo(info))
+        .catch(() => setArticleInfo(null))
+        .finally(() => setLoadingInfo(false))
+    }, 500)
+    return () => {
+      if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
+    }
+  }, [articleId])
+
+  const onSubmit = useCallback(async () => {
+    const id = articleId.trim()
+    if (!id) {
+      message.warning('请输入长文ID')
+      return
+    }
+
+    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 matchByArticleId({ articleId: 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)
+    }
+  }, [articleId, 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 (articleId)"
+              value={articleId}
+              onChange={(e) => setArticleId(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>
+
+          {/* 长文详情预览 (articleId 变化时自动查询) */}
+          {articleId.trim() && (
+            <div style={{ padding: '8px 0' }}>
+              {loadingInfo ? (
+                <Text type="secondary">查询中...</Text>
+              ) : articleInfo ? (
+                <Card size="small" style={{ background: '#fafafa' }}>
+                  <Space direction="vertical" size={6} style={{ width: '100%' }}>
+                    {articleInfo.title && (
+                      <Text strong style={{ fontSize: 15 }}>{articleInfo.title}</Text>
+                    )}
+                    {articleInfo.summary && (
+                      <Paragraph
+                        type="secondary"
+                        ellipsis={{ rows: 3 }}
+                        style={{ fontSize: 13, marginBottom: 0 }}
+                      >
+                        {articleInfo.summary}
+                      </Paragraph>
+                    )}
+                    <Space size={16} wrap>
+                      {articleInfo.source && (
+                        <Text type="secondary" style={{ fontSize: 12 }}>来源: {articleInfo.source}</Text>
+                      )}
+                      {articleInfo.url && (
+                        <Button
+                          type="link"
+                          size="small"
+                          icon={<LinkOutlined />}
+                          href={toHttps(articleInfo.url)}
+                          target="_blank"
+                          style={{ padding: 0 }}
+                        >
+                          原文链接
+                        </Button>
+                      )}
+                    </Space>
+                    {articleInfo.cover && (
+                      <Image
+                        src={toHttps(articleInfo.cover)}
+                        alt="article cover"
+                        referrerPolicy="no-referrer"
+                        style={{ maxHeight: 200, borderRadius: 4 }}
+                        fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+                      />
+                    )}
+                  </Space>
+                </Card>
+              ) : (
+                <Text type="secondary">未查询到长文信息</Text>
+              )}
+            </div>
+          )}
+        </Space>
+      </Card>
+
+      <Card
+        size="small"
+        title={<ArticleResultTitle meta={resultMeta} articleId={articleId} />}
+      >
+        {result && result.total === 0 ? (
+          <Empty description="无召回结果" />
+        ) : (
+          <RecallResultList result={result} loading={loading} defaultActiveKey="ARTICLE" />
+        )}
+      </Card>
+    </Space>
+  )
+}
+
+/** 长文召回结果卡片标题 — 与 MaterialResultTitle 风格一致 */
+function ArticleResultTitle({
+  meta,
+  articleId,
+}: {
+  meta: { dimensionLabel: string; dimensionCode: string; description: string } | null
+  articleId: string
+}) {
+  if (!meta) {
+    return (
+      <Space size={6}>
+        <ReadOutlined 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' }}>
+      <ReadOutlined 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>
+  )
+}

+ 3 - 3
src/pages/RecallTestPage.tsx

@@ -34,6 +34,7 @@ import DeconstructTree, {
 } from '../components/DeconstructTree'
 import AIUnderstandingPanel from '../components/AIUnderstandingPanel'
 import MaterialRecallTab from '../components/MaterialRecallTab'
+import ArticleRecallTab from '../components/ArticleRecallTab'
 import { toHttps } from '../utils/url'
 import {
   getAiUnderstanding,
@@ -180,11 +181,10 @@ export default function RecallTestPage() {
           key: 'article',
           label: (
             <span>
-              <ReadOutlined /> 长文相似度召回 <Tag color="default" style={{ marginLeft: 4 }}>开发中</Tag>
+              <ReadOutlined /> 长文相似度召回
             </span>
           ),
-          disabled: true,
-          children: null,
+          children: <ArticleRecallTab />,
         },
       ]}
     />

+ 1 - 1
vite.config.ts

@@ -10,7 +10,7 @@ export default defineConfig({
     host: true,
     proxy: {
       '/videoVector': {
-        target: 'https://api-internal.piaoquantv.com',
+        target: 'http://localhost:8080',
         changeOrigin: true,
         secure: false,
         configure: (proxy) => {