MaterialRecallTab.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. import { useCallback, useEffect, useMemo, 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, Modality } from '../api/types'
  21. import { useRankingParams, rankingForRequest, DEFAULT_RANKING_PARAMS } from '../utils/scoring'
  22. import { toHttps } from '../utils/url'
  23. import {
  24. ALL_CONFIG_CODE,
  25. buildGroupedConfigOptions,
  26. getConfigDisplayLabel,
  27. listAllConfigCodes,
  28. useConfigCodes,
  29. } from '../api/configCodes'
  30. import { useRecallFilters, RecallFilterBar } from './RecallFilterBar'
  31. import RankingWeightsPanel from './RankingWeightsPanel'
  32. import RecallFormSection from './RecallFormSection'
  33. const { Text } = Typography
  34. export default function MaterialRecallTab() {
  35. const [materialId, setMaterialId] = useState('')
  36. const [materialInfo, setMaterialInfo] = useState<MaterialBasicVO | null>(null)
  37. const [loadingInfo, setLoadingInfo] = useState(false)
  38. const [topN, setTopN] = useState<number>(10)
  39. /** 多选: 空数组 = 全部维度 */
  40. const [selectedCodes, setSelectedCodes] = useState<string[]>([])
  41. const [result, setResult] = useState<RecallResultVO | null>(null)
  42. const [loading, setLoading] = useState(false)
  43. const [rankingParams, setRankingParams] = useRankingParams()
  44. const recallFilters = useRecallFilters()
  45. const configCodes = useConfigCodes()
  46. const groupedOptions = buildGroupedConfigOptions(configCodes)
  47. /** 可用于 boost 配置的维度列表 */
  48. const boostCodes = useMemo(() =>
  49. selectedCodes.length > 0 ? selectedCodes : listAllConfigCodes(configCodes),
  50. [selectedCodes, configCodes])
  51. /** 召回结果标题展示的维度 meta — 锁定提交瞬间的值 */
  52. const [resultMeta, setResultMeta] = useState<{
  53. dimensionLabel: string
  54. dimensionCode: string
  55. description: string
  56. } | null>(null)
  57. const submitGenRef = useRef(0)
  58. // 防抖: 用户停止输入 500ms 后自动查素材信息
  59. const infoTimerRef = useRef<number | null>(null)
  60. /** materialId 变化时, 自动查素材图片 URL */
  61. useEffect(() => {
  62. if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
  63. const id = materialId.trim()
  64. if (!id) {
  65. setMaterialInfo(null)
  66. return
  67. }
  68. infoTimerRef.current = window.setTimeout(() => {
  69. setLoadingInfo(true)
  70. getMaterialDetail(id)
  71. .then((info) => setMaterialInfo(info))
  72. .catch(() => setMaterialInfo(null))
  73. .finally(() => setLoadingInfo(false))
  74. }, 500)
  75. return () => {
  76. if (infoTimerRef.current) clearTimeout(infoTimerRef.current)
  77. }
  78. }, [materialId])
  79. const onSubmit = useCallback(async () => {
  80. const id = materialId.trim()
  81. if (!id) {
  82. message.warning('请输入素材ID')
  83. return
  84. }
  85. // matchByMaterialId 为单次调用 (spec 约束), configCode 选填:
  86. // 空选 → 不传, 后端走全部维度; 单选 → 传具体维度; 多选 → 传 ALL 走全部
  87. const codes =
  88. selectedCodes.length === 0 ? listAllConfigCodes(configCodes) : selectedCodes
  89. const configCodeParam =
  90. selectedCodes.length === 1
  91. ? selectedCodes[0]
  92. : selectedCodes.length > 1
  93. ? ALL_CONFIG_CODE
  94. : undefined
  95. let dimensionLabel: string
  96. if (selectedCodes.length === 0) {
  97. const allCodes = listAllConfigCodes(configCodes)
  98. dimensionLabel = `全部 (${allCodes.length} 个维度)`
  99. } else if (selectedCodes.length === 1) {
  100. dimensionLabel = getConfigDisplayLabel(selectedCodes[0], configCodes)
  101. } else {
  102. dimensionLabel = `${selectedCodes.map((c) => getConfigDisplayLabel(c, configCodes)).join(' / ')} (${selectedCodes.length} 个维度)`
  103. }
  104. setResultMeta({
  105. dimensionLabel,
  106. dimensionCode: selectedCodes.length === 1 ? selectedCodes[0] : ALL_CONFIG_CODE,
  107. description: `基于素材 "${id}"`,
  108. })
  109. const myGen = ++submitGenRef.current
  110. const isStale = () => myGen !== submitGenRef.current
  111. setLoading(true)
  112. try {
  113. const boostCodesForRanking =
  114. selectedCodes.length > 0 ? selectedCodes : listAllConfigCodes(configCodes)
  115. const data = await matchByMaterialId({
  116. materialId: id,
  117. configCode: configCodeParam,
  118. topN,
  119. displayK: topN,
  120. ...recallFilters.toParams(),
  121. ranking: rankingForRequest(rankingParams, boostCodesForRanking),
  122. })
  123. if (isStale()) return
  124. setResult(data)
  125. if (data.total === 0) {
  126. message.info('无召回结果')
  127. } else {
  128. message.success(`召回完成, 共 ${data.total} 条`)
  129. }
  130. } catch {
  131. if (!isStale()) message.error('召回失败')
  132. } finally {
  133. if (!isStale()) setLoading(false)
  134. }
  135. }, [materialId, selectedCodes, topN, configCodes, rankingParams, recallFilters])
  136. /** 过滤区只选一种模态时,精排区展示对应权重 */
  137. const rankingPreviewModality = useMemo((): 'ALL' | Modality => {
  138. const m = recallFilters.filters.modalities
  139. if (m.length === 1) return m[0]
  140. return 'ALL'
  141. }, [recallFilters.filters.modalities])
  142. return (
  143. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  144. <Card size="small" bodyStyle={{ padding: 16 }}>
  145. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  146. {/* 素材ID + 召回维度 + TopN + 召回按钮 */}
  147. <RecallFormSection title="素材查询" noBorder>
  148. <Space wrap size={[12, 8]}>
  149. <Text strong>素材ID</Text>
  150. <Input
  151. placeholder="请输入素材ID (materialId)"
  152. value={materialId}
  153. onChange={(e) => setMaterialId(e.target.value)}
  154. style={{ width: 320 }}
  155. onPressEnter={onSubmit}
  156. allowClear
  157. />
  158. <Text strong>召回维度</Text>
  159. <Select
  160. mode="multiple"
  161. value={selectedCodes}
  162. onChange={setSelectedCodes}
  163. options={groupedOptions}
  164. placeholder="不选 = 全部维度"
  165. maxTagCount="responsive"
  166. style={{ minWidth: 240 }}
  167. allowClear
  168. />
  169. <Text strong>TopN</Text>
  170. <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
  171. <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
  172. 召回
  173. </Button>
  174. </Space>
  175. {/* 维度 Boost */}
  176. {boostCodes.length > 0 && (
  177. <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: 10 }}>
  178. <Text style={{ fontSize: 12, color: '#666', whiteSpace: 'nowrap' }}>维度 Boost</Text>
  179. {boostCodes.map((code) => {
  180. const val = rankingParams.boostsByCode?.[code] ?? rankingParams.deconstructBoost
  181. const isCustom = code in (rankingParams.boostsByCode ?? {})
  182. return (
  183. <div key={code} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
  184. <Text style={{ fontSize: 11, color: isCustom ? '#1677ff' : undefined, whiteSpace: 'nowrap' }}>
  185. {getConfigDisplayLabel(code, configCodes)}
  186. </Text>
  187. <InputNumber
  188. size="small"
  189. min={0.5}
  190. max={3}
  191. step={0.05}
  192. value={val}
  193. onChange={(v) => {
  194. const next = { ...rankingParams.boostsByCode }
  195. if (typeof v === 'number' && v !== rankingParams.deconstructBoost) {
  196. next[code] = v
  197. } else {
  198. delete next[code]
  199. }
  200. setRankingParams({ ...rankingParams, boostsByCode: next })
  201. }}
  202. style={{ width: 68 }}
  203. />
  204. </div>
  205. )
  206. })}
  207. </div>
  208. )}
  209. {/* 素材图片预览 */}
  210. {materialId.trim() && (
  211. <div>
  212. <Text type="secondary" style={{ fontSize: 12 }}>
  213. {loadingInfo ? '查询中...' : materialInfo?.imageUrl ? '素材预览:' : materialInfo ? '该素材无图片' : ''}
  214. </Text>
  215. {materialInfo?.imageUrl && (
  216. <div style={{ marginTop: 4 }}>
  217. <Image
  218. src={toHttps(materialInfo.imageUrl)}
  219. alt="material preview"
  220. referrerPolicy="no-referrer"
  221. style={{ maxHeight: 240, borderRadius: 4 }}
  222. fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
  223. />
  224. {materialInfo.title && (
  225. <div style={{ marginTop: 4 }}>
  226. <Text type="secondary" style={{ fontSize: 12 }}>{materialInfo.title}</Text>
  227. </div>
  228. )}
  229. </div>
  230. )}
  231. </div>
  232. )}
  233. </RecallFormSection>
  234. {/* 召回过滤 */}
  235. <RecallFormSection title="过滤">
  236. <RecallFilterBar
  237. filters={recallFilters.filters}
  238. onToggleModality={recallFilters.toggleModality}
  239. onToggleSource={recallFilters.toggleSource}
  240. />
  241. </RecallFormSection>
  242. {/* 精排参数 */}
  243. <RecallFormSection
  244. title="精排"
  245. extra={
  246. <Button size="small" onClick={() => setRankingParams(DEFAULT_RANKING_PARAMS)}>
  247. 重置默认
  248. </Button>
  249. }
  250. >
  251. <RankingWeightsPanel
  252. params={rankingParams}
  253. onChange={setRankingParams}
  254. activeModality={rankingPreviewModality}
  255. />
  256. </RecallFormSection>
  257. </Space>
  258. </Card>
  259. <Card
  260. size="small"
  261. title={<MaterialResultTitle meta={resultMeta} materialId={materialId} />}
  262. >
  263. {result && result.total === 0 ? (
  264. <Empty description="无召回结果" />
  265. ) : (
  266. <RecallResultList
  267. result={result}
  268. loading={loading}
  269. rankingParams={rankingParams}
  270. onRankingParamsChange={setRankingParams}
  271. hideInlineWeights
  272. defaultActiveKey="MATERIAL"
  273. />
  274. )}
  275. </Card>
  276. </Space>
  277. )
  278. }
  279. /** 素材召回结果卡片标题 — 展示召回维度标签, 与 Tab1/Tab2 的 RecallTitle 风格一致 */
  280. function MaterialResultTitle({
  281. meta,
  282. materialId,
  283. }: {
  284. meta: { dimensionLabel: string; dimensionCode: string; description: string } | null
  285. materialId: string
  286. }) {
  287. if (!meta) {
  288. return (
  289. <Space size={6}>
  290. <PictureOutlined style={{ color: '#722ed1' }} />
  291. <span>召回结果</span>
  292. </Space>
  293. )
  294. }
  295. const palette = meta.dimensionCode === 'VIDEO_TOPIC'
  296. ? { bg: '#f9f0ff', border: '#d3adf7', text: '#531dab' }
  297. : meta.dimensionCode === 'VIDEO_INSPIRATION'
  298. ? { bg: '#e6f4ff', border: '#91caff', text: '#0958d9' }
  299. : { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
  300. return (
  301. <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
  302. <PictureOutlined style={{ color: '#722ed1', fontSize: 16 }} />
  303. <span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>素材相似度召回 · 维度:</span>
  304. <span
  305. style={{
  306. background: palette.bg,
  307. border: `1px solid ${palette.border}`,
  308. color: palette.text,
  309. fontSize: 16,
  310. fontWeight: 700,
  311. padding: '4px 12px',
  312. borderRadius: 6,
  313. letterSpacing: 0.5,
  314. }}
  315. >
  316. {meta.dimensionLabel}
  317. </span>
  318. <span style={{ color: 'rgba(0,0,0,0.65)', fontSize: 13 }}>· {meta.description}</span>
  319. </div>
  320. )
  321. }