|
|
@@ -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>
|
|
|
+ )
|
|
|
+}
|