MaterialRecallTab.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { useCallback, useEffect, useRef, useState } from 'react'
  2. import {
  3. Card,
  4. Input,
  5. InputNumber,
  6. Select,
  7. Button,
  8. Space,
  9. message,
  10. Image,
  11. Typography,
  12. Empty,
  13. } from 'antd'
  14. import {
  15. PictureOutlined,
  16. SearchOutlined,
  17. } from '@ant-design/icons'
  18. import RecallResultList from './RecallResultList'
  19. import { getMaterialDetail, matchByMaterialId } from '../api/recall'
  20. import type { MaterialBasicVO, RecallResultVO } from '../api/types'
  21. import { toHttps } from '../utils/url'
  22. import {
  23. ALL_CONFIG_CODE,
  24. buildGroupedConfigOptions,
  25. getConfigDisplayLabel,
  26. listAllConfigCodes,
  27. useConfigCodes,
  28. } from '../api/configCodes'
  29. const { Text } = Typography
  30. export default function MaterialRecallTab() {
  31. const [materialId, setMaterialId] = useState('')
  32. const [materialInfo, setMaterialInfo] = useState<MaterialBasicVO | null>(null)
  33. const [loadingInfo, setLoadingInfo] = useState(false)
  34. const [topN, setTopN] = useState<number>(10)
  35. /** 多选: 空数组 = 全部维度 */
  36. const [selectedCodes, setSelectedCodes] = useState<string[]>([])
  37. const [result, setResult] = useState<RecallResultVO | null>(null)
  38. const [loading, setLoading] = useState(false)
  39. const configCodes = useConfigCodes()
  40. const groupedOptions = buildGroupedConfigOptions(configCodes)
  41. /** 召回结果标题展示的维度 meta — 锁定提交瞬间的值 */
  42. const [resultMeta, setResultMeta] = useState<{
  43. dimensionLabel: string
  44. dimensionCode: string
  45. description: string
  46. } | null>(null)
  47. const submitGenRef = useRef(0)
  48. // 防抖: 用户停止输入 500ms 后自动查素材信息
  49. const infoTimerRef = useRef<number | null>(null)
  50. /** materialId 变化时, 自动查素材图片 URL */
  51. useEffect(() => {
  52. if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
  53. const id = materialId.trim()
  54. if (!id) {
  55. setMaterialInfo(null)
  56. return
  57. }
  58. infoTimerRef.current = window.setTimeout(() => {
  59. setLoadingInfo(true)
  60. getMaterialDetail(id)
  61. .then((info) => setMaterialInfo(info))
  62. .catch(() => setMaterialInfo(null))
  63. .finally(() => setLoadingInfo(false))
  64. }, 500)
  65. return () => {
  66. if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
  67. }
  68. }, [materialId])
  69. const onSubmit = useCallback(async () => {
  70. const id = materialId.trim()
  71. if (!id) {
  72. message.warning('请输入素材ID')
  73. return
  74. }
  75. // matchByMaterialId 为单次调用 (spec 约束), configCode 选填:
  76. // 空选 → 不传, 后端走全部维度; 单选 → 传具体维度; 多选 → 传 ALL 走全部
  77. const configCodeParam =
  78. selectedCodes.length === 1
  79. ? selectedCodes[0]
  80. : selectedCodes.length > 1
  81. ? ALL_CONFIG_CODE
  82. : undefined
  83. let dimensionLabel: string
  84. if (selectedCodes.length === 0) {
  85. const allCodes = listAllConfigCodes(configCodes)
  86. dimensionLabel = `全部 (${allCodes.length} 个维度)`
  87. } else if (selectedCodes.length === 1) {
  88. dimensionLabel = getConfigDisplayLabel(selectedCodes[0], configCodes)
  89. } else {
  90. dimensionLabel = `${selectedCodes.map((c) => getConfigDisplayLabel(c, configCodes)).join(' / ')} (${selectedCodes.length} 个维度)`
  91. }
  92. setResultMeta({
  93. dimensionLabel,
  94. dimensionCode: selectedCodes.length === 1 ? selectedCodes[0] : ALL_CONFIG_CODE,
  95. description: `基于素材 "${id}"`,
  96. })
  97. const myGen = ++submitGenRef.current
  98. const isStale = () => myGen !== submitGenRef.current
  99. setLoading(true)
  100. try {
  101. const data = await matchByMaterialId({ materialId: id, configCode: configCodeParam, topN })
  102. if (isStale()) return
  103. setResult(data)
  104. if (data.total === 0) {
  105. message.info('无召回结果')
  106. } else {
  107. message.success(`召回完成, 共 ${data.total} 条`)
  108. }
  109. } catch {
  110. if (!isStale()) message.error('召回失败')
  111. } finally {
  112. if (!isStale()) setLoading(false)
  113. }
  114. }, [materialId, selectedCodes, topN, configCodes])
  115. return (
  116. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  117. <Card size="small" bodyStyle={{ padding: 16 }}>
  118. <Space direction="vertical" size={12} style={{ width: '100%' }}>
  119. {/* 素材ID + 召回维度 + TopN + 召回按钮 */}
  120. <Space wrap size={[12, 8]}>
  121. <Text strong>素材ID</Text>
  122. <Input
  123. placeholder="请输入素材ID (materialId)"
  124. value={materialId}
  125. onChange={(e) => setMaterialId(e.target.value)}
  126. style={{ width: 320 }}
  127. onPressEnter={onSubmit}
  128. allowClear
  129. />
  130. <Text strong>召回维度</Text>
  131. <Select
  132. mode="multiple"
  133. value={selectedCodes}
  134. onChange={setSelectedCodes}
  135. options={groupedOptions}
  136. placeholder="不选 = 全部维度"
  137. maxTagCount="responsive"
  138. style={{ minWidth: 240 }}
  139. allowClear
  140. />
  141. <Text strong>TopN</Text>
  142. <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
  143. <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
  144. 召回
  145. </Button>
  146. </Space>
  147. {/* 素材图片预览 (materialId 变化时自动查询) */}
  148. {materialId.trim() && (
  149. <div>
  150. <Text type="secondary" style={{ fontSize: 12 }}>
  151. {loadingInfo ? '查询中...' : materialInfo?.imageUrl ? '素材预览:' : materialInfo ? '该素材无图片' : ''}
  152. </Text>
  153. {materialInfo?.imageUrl && (
  154. <div style={{ marginTop: 4 }}>
  155. <Image
  156. src={toHttps(materialInfo.imageUrl)}
  157. alt="material preview"
  158. referrerPolicy="no-referrer"
  159. style={{ maxHeight: 240, borderRadius: 4 }}
  160. fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
  161. />
  162. {materialInfo.title && (
  163. <div style={{ marginTop: 4 }}>
  164. <Text type="secondary" style={{ fontSize: 12 }}>{materialInfo.title}</Text>
  165. </div>
  166. )}
  167. </div>
  168. )}
  169. </div>
  170. )}
  171. </Space>
  172. </Card>
  173. <Card
  174. size="small"
  175. title={<MaterialResultTitle meta={resultMeta} materialId={materialId} />}
  176. >
  177. {result && result.total === 0 ? (
  178. <Empty description="无召回结果" />
  179. ) : (
  180. <RecallResultList result={result} loading={loading} defaultActiveKey="MATERIAL" />
  181. )}
  182. </Card>
  183. </Space>
  184. )
  185. }
  186. /** 素材召回结果卡片标题 — 展示召回维度标签, 与 Tab1/Tab2 的 RecallTitle 风格一致 */
  187. function MaterialResultTitle({
  188. meta,
  189. materialId,
  190. }: {
  191. meta: { dimensionLabel: string; dimensionCode: string; description: string } | null
  192. materialId: string
  193. }) {
  194. if (!meta) {
  195. return (
  196. <Space size={6}>
  197. <PictureOutlined style={{ color: '#722ed1' }} />
  198. <span>召回结果</span>
  199. </Space>
  200. )
  201. }
  202. const palette = meta.dimensionCode === 'VIDEO_TOPIC'
  203. ? { bg: '#f9f0ff', border: '#d3adf7', text: '#531dab' }
  204. : meta.dimensionCode === 'VIDEO_INSPIRATION'
  205. ? { bg: '#e6f4ff', border: '#91caff', text: '#0958d9' }
  206. : { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
  207. return (
  208. <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
  209. <PictureOutlined style={{ color: '#722ed1', fontSize: 16 }} />
  210. <span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>素材相似度召回 · 维度:</span>
  211. <span
  212. style={{
  213. background: palette.bg,
  214. border: `1px solid ${palette.border}`,
  215. color: palette.text,
  216. fontSize: 16,
  217. fontWeight: 700,
  218. padding: '4px 12px',
  219. borderRadius: 6,
  220. letterSpacing: 0.5,
  221. }}
  222. >
  223. {meta.dimensionLabel}
  224. </span>
  225. <span style={{ color: 'rgba(0,0,0,0.65)', fontSize: 13 }}>· {meta.description}</span>
  226. </div>
  227. )
  228. }