RecallTestPage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import { useCallback, useEffect, useRef, useState } from 'react'
  2. import {
  3. Tabs,
  4. Card,
  5. Input,
  6. InputNumber,
  7. Select,
  8. Button,
  9. Space,
  10. message,
  11. Empty,
  12. Typography,
  13. Row,
  14. Col,
  15. Tag,
  16. } from 'antd'
  17. import {
  18. PlayCircleOutlined,
  19. FileTextOutlined,
  20. PictureOutlined,
  21. ReadOutlined,
  22. SearchOutlined,
  23. ApartmentOutlined,
  24. BulbOutlined,
  25. VideoCameraOutlined,
  26. } from '@ant-design/icons'
  27. import RecallResultList from '../components/RecallResultList'
  28. import VideoPlayer from '../components/VideoPlayer'
  29. import DeconstructTree from '../components/DeconstructTree'
  30. import AIUnderstandingPanel from '../components/AIUnderstandingPanel'
  31. import MaterialDeconstructTab from '../components/MaterialDeconstructTab'
  32. import { toHttps } from '../utils/url'
  33. import {
  34. getAiUnderstanding,
  35. getDeconstructPoints,
  36. getVideoDetail,
  37. matchByText,
  38. } from '../api/recall'
  39. import { buildGroupedConfigOptions, useConfigCodes } from '../api/configCodes'
  40. import type {
  41. AIUnderstandingVO,
  42. DeconstructPointsVO,
  43. RecallResultVO,
  44. VideoBasicVO,
  45. } from '../api/types'
  46. const { TextArea } = Input
  47. const { Text, Title } = Typography
  48. /** 从 URL ?videoId=xxx 读取并校验,无效返回 null */
  49. function readUrlVideoId(): number | null {
  50. const raw = new URLSearchParams(window.location.search).get('videoId')
  51. if (!raw) return null
  52. const n = Number(raw)
  53. return Number.isFinite(n) && n > 0 ? n : null
  54. }
  55. /** 从 URL ?queryText=xxx 读取,空值返 null */
  56. function readUrlQueryText(): string | null {
  57. const raw = new URLSearchParams(window.location.search).get('queryText')
  58. if (!raw) return null
  59. const trimmed = raw.trim()
  60. return trimmed.length > 0 ? trimmed : null
  61. }
  62. /** 从 URL ?configCode=xxx 读取,后端字典动态加载,这里只做形态校验(非空+大写) */
  63. function readUrlConfigCode(): string | null {
  64. const raw = new URLSearchParams(window.location.search).get('configCode')
  65. if (!raw) return null
  66. const upper = raw.trim().toUpperCase()
  67. return upper.length > 0 ? upper : null
  68. }
  69. /** 从召回结果里剔除指定视频ID, 同时刷新 modality 计数 */
  70. function filterOutSelf(data: RecallResultVO, excludeId: number | null): RecallResultVO {
  71. if (!excludeId) return data
  72. const items = data.items.filter((x) => x.id !== excludeId)
  73. return {
  74. items,
  75. videoCount: items.filter((x) => x.modality === 'VIDEO').length,
  76. materialCount: items.filter((x) => x.modality === 'MATERIAL').length,
  77. articleCount: items.filter((x) => x.modality === 'ARTICLE').length,
  78. total: items.length,
  79. }
  80. }
  81. export default function RecallTestPage() {
  82. // URL 上有 queryText → 默认进入 Tab2 (优先级高于 videoId,与 Grafana 跳转用例一致)
  83. // 用 defaultActiveKey 而非受控 activeKey,保留用户后续手动切 Tab 的自由
  84. const initialTabKey = readUrlQueryText() ? 'text' : 'video'
  85. return (
  86. <Tabs
  87. className="recall-main-tabs"
  88. defaultActiveKey={initialTabKey}
  89. size="large"
  90. items={[
  91. {
  92. key: 'video',
  93. label: (
  94. <span>
  95. <PlayCircleOutlined /> 视频相似度召回
  96. </span>
  97. ),
  98. children: <VideoIdTab />,
  99. },
  100. {
  101. key: 'text',
  102. label: (
  103. <span>
  104. <FileTextOutlined /> 自定义文本相似度召回
  105. </span>
  106. ),
  107. children: <TextRecallTab />,
  108. },
  109. {
  110. key: 'material',
  111. label: (
  112. <span>
  113. <PictureOutlined /> 素材解构
  114. </span>
  115. ),
  116. children: <MaterialDeconstructTab />,
  117. },
  118. {
  119. key: 'article',
  120. label: (
  121. <span>
  122. <ReadOutlined /> 长文相似度召回 <Tag color="default" style={{ marginLeft: 4 }}>开发中</Tag>
  123. </span>
  124. ),
  125. disabled: true,
  126. children: null,
  127. },
  128. ]}
  129. />
  130. )
  131. }
  132. // ============================================================================
  133. // Tab1: 票圈视频ID — 三栏并列布局(视频详情 / AI理解 / 解构层级 都是召回维度)
  134. // ============================================================================
  135. /** 进入页面默认查询的视频ID, 避免空白态 */
  136. const DEFAULT_VIDEO_ID = 64632804
  137. function VideoIdTab() {
  138. /** URL 上 ?videoId=xxx 优先于默认 ID;有则首次查询后自动按选题召回 */
  139. const urlVideoId = readUrlVideoId()
  140. const [videoId, setVideoId] = useState<number | null>(urlVideoId ?? DEFAULT_VIDEO_ID)
  141. const pendingAutoRecallRef = useRef(urlVideoId !== null)
  142. const [detail, setDetail] = useState<VideoBasicVO | null>(null)
  143. const [ai, setAi] = useState<AIUnderstandingVO | null>(null)
  144. const [points, setPoints] = useState<DeconstructPointsVO | null>(null)
  145. const [loadingMain, setLoadingMain] = useState(false)
  146. const [queried, setQueried] = useState(false)
  147. const configCodes = useConfigCodes()
  148. /** 视频Tab 不再有顶部维度选择, 维度由解构层级里每条点的"以此召回"按钮直接传入 */
  149. const [topN, setTopN] = useState<number>(10)
  150. const [recall, setRecall] = useState<RecallResultVO | null>(null)
  151. const [loadingRecall, setLoadingRecall] = useState(false)
  152. /** 召回标题: 拆成"维度Tag(高亮)" + "文本描述" */
  153. const [recallMeta, setRecallMeta] = useState<{
  154. dimensionLabel: string
  155. dimensionCode: string
  156. description: string
  157. } | null>(null)
  158. const runQuery = useCallback(async (id: number) => {
  159. setLoadingMain(true)
  160. setQueried(true)
  161. setRecall(null)
  162. try {
  163. const [d, a, p] = await Promise.all([
  164. getVideoDetail(id).catch(() => null),
  165. getAiUnderstanding(id).catch(() => null),
  166. getDeconstructPoints(id).catch(() => null),
  167. ])
  168. setDetail(d)
  169. setAi(a)
  170. setPoints(p)
  171. if (!d) message.warning('未查询到视频详情')
  172. } finally {
  173. setLoadingMain(false)
  174. }
  175. }, [])
  176. const onQuery = () => {
  177. if (!videoId || videoId <= 0) {
  178. message.warning('请输入有效的视频ID')
  179. return
  180. }
  181. runQuery(videoId)
  182. }
  183. /** 进入页面自动用默认ID查一次, 避免空白态 */
  184. const autoQueriedRef = useRef(false)
  185. useEffect(() => {
  186. if (autoQueriedRef.current) return
  187. autoQueriedRef.current = true
  188. if (videoId && videoId > 0) {
  189. runQuery(videoId)
  190. }
  191. }, [runQuery, videoId])
  192. const onRecallByText = useCallback(
  193. async (text: string, configCodeOverride?: string) => {
  194. const trimmed = text.trim()
  195. if (!trimmed) return
  196. const finalConfigCode = configCodeOverride || 'VIDEO_TOPIC'
  197. setLoadingRecall(true)
  198. const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
  199. const codeLabel = configCodes[finalConfigCode] ?? finalConfigCode
  200. setRecallMeta({
  201. dimensionLabel: codeLabel,
  202. dimensionCode: finalConfigCode,
  203. description: `基于文本 "${preview}"`,
  204. })
  205. try {
  206. const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, topN })
  207. setRecall(filterOutSelf(data, videoId))
  208. message.success(`召回 ${data.total} 条`)
  209. } catch {
  210. message.error('召回失败')
  211. } finally {
  212. setLoadingRecall(false)
  213. }
  214. },
  215. [topN, videoId, configCodes],
  216. )
  217. /** URL 传入 videoId 时, 首次查询结束后自动按"选题"维度召回 (0 点击) */
  218. useEffect(() => {
  219. if (!pendingAutoRecallRef.current) return
  220. if (!autoQueriedRef.current) return
  221. if (loadingMain) return
  222. pendingAutoRecallRef.current = false
  223. if (points?.topic && points.topic.trim()) {
  224. onRecallByText(points.topic, 'VIDEO_TOPIC')
  225. }
  226. }, [loadingMain, points, onRecallByText])
  227. return (
  228. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  229. {/* 查询条 */}
  230. <Card size="small" bodyStyle={{ padding: '12px 16px' }}>
  231. <Space wrap size={[12, 8]}>
  232. <Text strong>视频ID</Text>
  233. <InputNumber
  234. placeholder="请输入"
  235. min={1}
  236. value={videoId}
  237. onChange={(v) => setVideoId(v)}
  238. style={{ width: 200 }}
  239. onPressEnter={onQuery}
  240. controls={false}
  241. />
  242. <Button type="primary" icon={<SearchOutlined />} loading={loadingMain} onClick={onQuery}>
  243. 查询
  244. </Button>
  245. <span style={{ color: '#d9d9d9' }}>|</span>
  246. <Text strong>TopN</Text>
  247. <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
  248. <Text type="secondary" style={{ fontSize: 12 }}>
  249. 召回维度由下方解构层级各点的"以此召回"按钮直接选择
  250. </Text>
  251. </Space>
  252. </Card>
  253. {queried && (
  254. <>
  255. {/* 三栏并列(顺序: 选题解构 / AI识别(老) / 视频详情) */}
  256. <Row gutter={[16, 16]}>
  257. {/* 选题解构 (放第一位) */}
  258. <Col xs={24} md={8}>
  259. <Card
  260. size="small"
  261. title={
  262. <Space size={8}>
  263. <ApartmentOutlined style={{ color: '#52c41a', fontSize: 18 }} />
  264. <span style={{ fontSize: 17, fontWeight: 700 }}>选题解构</span>
  265. <Tag color="green-inverse" style={{ marginLeft: 4 }}>召回维度</Tag>
  266. </Space>
  267. }
  268. loading={loadingMain}
  269. style={{ height: '100%' }}
  270. bodyStyle={{ maxHeight: 560, overflowY: 'auto' }}
  271. >
  272. <DeconstructTree data={points} loading={loadingMain} onRecallByText={onRecallByText} />
  273. </Card>
  274. </Col>
  275. {/* 视频内容理解 - 放第二位 */}
  276. <Col xs={24} md={8}>
  277. <Card
  278. size="small"
  279. title={
  280. <Space size={8}>
  281. <BulbOutlined style={{ color: '#fa8c16', fontSize: 18 }} />
  282. <span style={{ fontSize: 17, fontWeight: 700 }}>视频内容理解</span>
  283. <Tag color="orange-inverse" style={{ marginLeft: 4 }}>召回维度</Tag>
  284. </Space>
  285. }
  286. loading={loadingMain}
  287. style={{ height: '100%' }}
  288. >
  289. <AIUnderstandingPanel data={ai} loading={loadingMain} />
  290. </Card>
  291. </Col>
  292. {/* 视频详情 + 播放器 - 放第三位 */}
  293. <Col xs={24} md={8}>
  294. <Card
  295. size="small"
  296. title={
  297. <Space size={8}>
  298. <VideoCameraOutlined style={{ color: '#1677ff', fontSize: 18 }} />
  299. <span style={{ fontSize: 17, fontWeight: 700 }}>视频详情</span>
  300. </Space>
  301. }
  302. loading={loadingMain}
  303. style={{ height: '100%' }}
  304. >
  305. {detail ? (
  306. <Space direction="vertical" size={10} style={{ width: '100%' }}>
  307. <DetailRow label="ID" value={String(detail.videoId)} />
  308. <DetailRow label="标题" value={detail.title ?? '-'} />
  309. <DetailRow
  310. label="播放量"
  311. value={
  312. <Text type={detail.playCount === '--' ? 'secondary' : undefined}>
  313. {detail.playCount}
  314. </Text>
  315. }
  316. hint={detail.playCount === '--' ? '字段缺失,占位' : undefined}
  317. />
  318. <div style={{ marginTop: 4 }}>
  319. {detail.videoUrl ? (
  320. <VideoPlayer src={toHttps(detail.videoUrl)} poster={toHttps(detail.cover) || undefined} />
  321. ) : (
  322. <Empty description="无视频地址" image={Empty.PRESENTED_IMAGE_SIMPLE} />
  323. )}
  324. </div>
  325. </Space>
  326. ) : (
  327. <Empty description="未查询到视频" image={Empty.PRESENTED_IMAGE_SIMPLE} />
  328. )}
  329. </Card>
  330. </Col>
  331. </Row>
  332. {/* 召回结果 */}
  333. <Card
  334. size="small"
  335. title={<RecallTitle meta={recallMeta} />}
  336. >
  337. <RecallResultList result={recall} loading={loadingRecall} />
  338. </Card>
  339. </>
  340. )}
  341. </Space>
  342. )
  343. }
  344. function DetailRow({
  345. label,
  346. value,
  347. hint,
  348. }: {
  349. label: string
  350. value: React.ReactNode
  351. hint?: string
  352. }) {
  353. return (
  354. <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
  355. <Text type="secondary" style={{ minWidth: 56, fontSize: 12 }}>
  356. {label}
  357. </Text>
  358. <div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
  359. {value}
  360. {hint && (
  361. <Text type="secondary" style={{ marginLeft: 6, fontSize: 11 }}>
  362. ({hint})
  363. </Text>
  364. )}
  365. </div>
  366. </div>
  367. )
  368. }
  369. /** 召回结果卡片标题: "召回维度:[中文标签]" + 描述 */
  370. function RecallTitle({
  371. meta,
  372. }: {
  373. meta: { dimensionLabel: string; dimensionCode: string; description: string } | null
  374. }) {
  375. if (!meta) {
  376. return (
  377. <Space size={6}>
  378. <SearchOutlined style={{ color: '#722ed1' }} />
  379. <span>召回结果</span>
  380. </Space>
  381. )
  382. }
  383. // 按 configCode 前缀走两套配色
  384. const palette = meta.dimensionCode.startsWith('RESULT_LOG_')
  385. ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
  386. : meta.dimensionCode === 'VIDEO_TOPIC'
  387. ? { bg: '#f9f0ff', border: '#d3adf7', text: '#531dab' }
  388. : meta.dimensionCode === 'VIDEO_INSPIRATION'
  389. ? { bg: '#e6f4ff', border: '#91caff', text: '#0958d9' }
  390. : { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
  391. return (
  392. <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
  393. <SearchOutlined style={{ color: '#722ed1', fontSize: 16 }} />
  394. <span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>召回维度:</span>
  395. <span
  396. style={{
  397. background: palette.bg,
  398. border: `1px solid ${palette.border}`,
  399. color: palette.text,
  400. fontSize: 16,
  401. fontWeight: 700,
  402. padding: '4px 12px',
  403. borderRadius: 6,
  404. letterSpacing: 0.5,
  405. }}
  406. >
  407. {meta.dimensionLabel}
  408. </span>
  409. <span style={{ color: 'rgba(0,0,0,0.65)', fontSize: 13 }}>· {meta.description}</span>
  410. </div>
  411. )
  412. }
  413. // ============================================================================
  414. // Tab2: 文本输入 → 文本召回
  415. // ============================================================================
  416. function TextRecallTab() {
  417. /** URL ?queryText=xxx&configCode=yyy 落地参数,缺省走原有空白态 */
  418. const urlQueryText = readUrlQueryText()
  419. const urlConfigCode = readUrlConfigCode()
  420. const [queryText, setQueryText] = useState(urlQueryText ?? '')
  421. const [configCode, setConfigCode] = useState(urlConfigCode ?? 'VIDEO_TOPIC')
  422. const [topN, setTopN] = useState<number>(10)
  423. const [result, setResult] = useState<RecallResultVO | null>(null)
  424. const [loading, setLoading] = useState(false)
  425. const configCodes = useConfigCodes()
  426. const groupedOptions = buildGroupedConfigOptions(configCodes)
  427. /** 召回结果标题展示的维度 meta — 锁定提交瞬间的值, 避免提交后改下拉影响展示 */
  428. const [resultMeta, setResultMeta] = useState<{
  429. dimensionLabel: string
  430. dimensionCode: string
  431. description: string
  432. } | null>(null)
  433. const onSubmit = useCallback(async () => {
  434. const trimmed = queryText.trim()
  435. if (!trimmed) {
  436. message.warning('请输入查询文本')
  437. return
  438. }
  439. setLoading(true)
  440. const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
  441. setResultMeta({
  442. dimensionLabel: configCodes[configCode] ?? configCode,
  443. dimensionCode: configCode,
  444. description: `基于文本 "${preview}"`,
  445. })
  446. try {
  447. const data = await matchByText({ queryText: trimmed, configCode, topN })
  448. setResult(data)
  449. message.success(`召回 ${data.total} 条`)
  450. } catch {
  451. message.error('召回失败')
  452. } finally {
  453. setLoading(false)
  454. }
  455. }, [queryText, configCode, topN, configCodes])
  456. /** URL 上有 queryText 时, 挂载后自动触发一次召回 (Grafana 跳转 0 点击) */
  457. const autoSearchedRef = useRef(false)
  458. useEffect(() => {
  459. if (autoSearchedRef.current) return
  460. if (!urlQueryText) return
  461. autoSearchedRef.current = true
  462. onSubmit()
  463. }, [urlQueryText, onSubmit])
  464. return (
  465. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  466. <Card size="small" bodyStyle={{ padding: 16 }}>
  467. <Space direction="vertical" size={12} style={{ width: '100%' }}>
  468. <TextArea
  469. placeholder="请输入查询文本(选题、灵感点描述等)"
  470. value={queryText}
  471. onChange={(e) => setQueryText(e.target.value)}
  472. rows={4}
  473. allowClear
  474. />
  475. <Space wrap>
  476. <Text strong>召回维度</Text>
  477. <Select value={configCode} onChange={setConfigCode} options={groupedOptions} style={{ width: 240 }} />
  478. <Text strong>TopN</Text>
  479. <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
  480. <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
  481. 召回
  482. </Button>
  483. </Space>
  484. </Space>
  485. </Card>
  486. <Card size="small" title={<RecallTitle meta={resultMeta} />}>
  487. <RecallResultList result={result} loading={loading} />
  488. </Card>
  489. </Space>
  490. )
  491. }