ArticleRecallTab.tsx 12 KB

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