|
|
@@ -0,0 +1,263 @@
|
|
|
+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
|
|
|
+ }
|
|
|
+ // 空选 = 全部维度; 否则用户显式勾选的子集
|
|
|
+ const codes =
|
|
|
+ selectedCodes.length === 0 ? listAllConfigCodes(configCodes) : selectedCodes
|
|
|
+ if (codes.length === 0) {
|
|
|
+ message.warning('维度字典尚未加载完成, 请稍后再试')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const myGen = ++submitGenRef.current
|
|
|
+ const isStale = () => myGen !== submitGenRef.current
|
|
|
+ setLoading(true)
|
|
|
+
|
|
|
+ let dimensionLabel: string
|
|
|
+ if (selectedCodes.length === 0) {
|
|
|
+ dimensionLabel = `全部 (${codes.length} 个维度)`
|
|
|
+ } else if (codes.length === 1) {
|
|
|
+ dimensionLabel = getConfigDisplayLabel(codes[0], configCodes)
|
|
|
+ } else {
|
|
|
+ dimensionLabel = `${codes.map((c) => getConfigDisplayLabel(c, configCodes)).join(' / ')} (${codes.length} 个维度)`
|
|
|
+ }
|
|
|
+ setResultMeta({
|
|
|
+ dimensionLabel,
|
|
|
+ dimensionCode: codes.length === 1 ? codes[0] : ALL_CONFIG_CODE,
|
|
|
+ description: `基于素材 "${id}"`,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const settled = await Promise.allSettled(
|
|
|
+ codes.map((code) =>
|
|
|
+ matchByMaterialId({ materialId: id, configCode: code, topN }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ if (isStale()) return
|
|
|
+ const failedDims: string[] = []
|
|
|
+ const merged: RecallResultVO = {
|
|
|
+ items: [],
|
|
|
+ videoCount: 0,
|
|
|
+ materialCount: 0,
|
|
|
+ articleCount: 0,
|
|
|
+ total: 0,
|
|
|
+ }
|
|
|
+ settled.forEach((s, i) => {
|
|
|
+ if (s.status === 'fulfilled') {
|
|
|
+ merged.items.push(...s.value.items)
|
|
|
+ } else {
|
|
|
+ failedDims.push(getConfigDisplayLabel(codes[i], configCodes))
|
|
|
+ }
|
|
|
+ })
|
|
|
+ merged.items.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity))
|
|
|
+ merged.videoCount = merged.items.filter((x) => x.modality === 'VIDEO').length
|
|
|
+ merged.materialCount = merged.items.filter((x) => x.modality === 'MATERIAL').length
|
|
|
+ merged.articleCount = merged.items.filter((x) => x.modality === 'ARTICLE').length
|
|
|
+ merged.total = merged.items.length
|
|
|
+ setResult(merged)
|
|
|
+ if (failedDims.length > 0) {
|
|
|
+ message.warning(`部分维度召回失败: ${failedDims.join(', ')} — 已展示其余维度结果`)
|
|
|
+ } else {
|
|
|
+ message.success(`召回完成, 共 ${merged.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>
|
|
|
+ )
|
|
|
+}
|