| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- 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
- }
- // matchByMaterialId 为单次调用 (spec 约束), configCode 选填:
- // 空选 → 不传, 后端走全部维度; 单选 → 传具体维度; 多选 → 传 ALL 走全部
- 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 matchByMaterialId({ materialId: 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)
- }
- }, [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>
- )
- }
|