| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- import { useCallback, useEffect, useRef, useState } from 'react'
- import {
- Tabs,
- Card,
- Input,
- InputNumber,
- Select,
- Button,
- Space,
- message,
- Empty,
- Typography,
- Row,
- Col,
- Tag,
- } from 'antd'
- import {
- PlayCircleOutlined,
- FileTextOutlined,
- PictureOutlined,
- ReadOutlined,
- SearchOutlined,
- ApartmentOutlined,
- BulbOutlined,
- VideoCameraOutlined,
- } from '@ant-design/icons'
- import RecallResultList from '../components/RecallResultList'
- import VideoPlayer from '../components/VideoPlayer'
- import DeconstructTree from '../components/DeconstructTree'
- import AIUnderstandingPanel from '../components/AIUnderstandingPanel'
- import MaterialDeconstructTab from '../components/MaterialDeconstructTab'
- import { toHttps } from '../utils/url'
- import {
- getAiUnderstanding,
- getDeconstructPoints,
- getVideoDetail,
- matchByText,
- } from '../api/recall'
- import { buildGroupedConfigOptions, useConfigCodes } from '../api/configCodes'
- import type {
- AIUnderstandingVO,
- DeconstructPointsVO,
- RecallResultVO,
- VideoBasicVO,
- } from '../api/types'
- const { TextArea } = Input
- const { Text, Title } = Typography
- /** 从 URL ?videoId=xxx 读取并校验,无效返回 null */
- function readUrlVideoId(): number | null {
- const raw = new URLSearchParams(window.location.search).get('videoId')
- if (!raw) return null
- const n = Number(raw)
- return Number.isFinite(n) && n > 0 ? n : null
- }
- /** 从 URL ?queryText=xxx 读取,空值返 null */
- function readUrlQueryText(): string | null {
- const raw = new URLSearchParams(window.location.search).get('queryText')
- if (!raw) return null
- const trimmed = raw.trim()
- return trimmed.length > 0 ? trimmed : null
- }
- /** 从 URL ?configCode=xxx 读取,后端字典动态加载,这里只做形态校验(非空+大写) */
- function readUrlConfigCode(): string | null {
- const raw = new URLSearchParams(window.location.search).get('configCode')
- if (!raw) return null
- const upper = raw.trim().toUpperCase()
- return upper.length > 0 ? upper : null
- }
- /** 从召回结果里剔除指定视频ID, 同时刷新 modality 计数 */
- function filterOutSelf(data: RecallResultVO, excludeId: number | null): RecallResultVO {
- if (!excludeId) return data
- const items = data.items.filter((x) => x.id !== excludeId)
- return {
- items,
- videoCount: items.filter((x) => x.modality === 'VIDEO').length,
- materialCount: items.filter((x) => x.modality === 'MATERIAL').length,
- articleCount: items.filter((x) => x.modality === 'ARTICLE').length,
- total: items.length,
- }
- }
- export default function RecallTestPage() {
- // URL 上有 queryText → 默认进入 Tab2 (优先级高于 videoId,与 Grafana 跳转用例一致)
- // 用 defaultActiveKey 而非受控 activeKey,保留用户后续手动切 Tab 的自由
- const initialTabKey = readUrlQueryText() ? 'text' : 'video'
- return (
- <Tabs
- className="recall-main-tabs"
- defaultActiveKey={initialTabKey}
- size="large"
- items={[
- {
- key: 'video',
- label: (
- <span>
- <PlayCircleOutlined /> 视频相似度召回
- </span>
- ),
- children: <VideoIdTab />,
- },
- {
- key: 'text',
- label: (
- <span>
- <FileTextOutlined /> 自定义文本相似度召回
- </span>
- ),
- children: <TextRecallTab />,
- },
- {
- key: 'material',
- label: (
- <span>
- <PictureOutlined /> 素材解构
- </span>
- ),
- children: <MaterialDeconstructTab />,
- },
- {
- key: 'article',
- label: (
- <span>
- <ReadOutlined /> 长文相似度召回 <Tag color="default" style={{ marginLeft: 4 }}>开发中</Tag>
- </span>
- ),
- disabled: true,
- children: null,
- },
- ]}
- />
- )
- }
- // ============================================================================
- // Tab1: 票圈视频ID — 三栏并列布局(视频详情 / AI理解 / 解构层级 都是召回维度)
- // ============================================================================
- /** 进入页面默认查询的视频ID, 避免空白态 */
- const DEFAULT_VIDEO_ID = 64632804
- function VideoIdTab() {
- /** URL 上 ?videoId=xxx 优先于默认 ID;有则首次查询后自动按选题召回 */
- const urlVideoId = readUrlVideoId()
- const [videoId, setVideoId] = useState<number | null>(urlVideoId ?? DEFAULT_VIDEO_ID)
- const pendingAutoRecallRef = useRef(urlVideoId !== null)
- const [detail, setDetail] = useState<VideoBasicVO | null>(null)
- const [ai, setAi] = useState<AIUnderstandingVO | null>(null)
- const [points, setPoints] = useState<DeconstructPointsVO | null>(null)
- const [loadingMain, setLoadingMain] = useState(false)
- const [queried, setQueried] = useState(false)
- const configCodes = useConfigCodes()
- /** 视频Tab 不再有顶部维度选择, 维度由解构层级里每条点的"以此召回"按钮直接传入 */
- const [topN, setTopN] = useState<number>(10)
- const [recall, setRecall] = useState<RecallResultVO | null>(null)
- const [loadingRecall, setLoadingRecall] = useState(false)
- /** 召回标题: 拆成"维度Tag(高亮)" + "文本描述" */
- const [recallMeta, setRecallMeta] = useState<{
- dimensionLabel: string
- dimensionCode: string
- description: string
- } | null>(null)
- const runQuery = useCallback(async (id: number) => {
- setLoadingMain(true)
- setQueried(true)
- setRecall(null)
- try {
- const [d, a, p] = await Promise.all([
- getVideoDetail(id).catch(() => null),
- getAiUnderstanding(id).catch(() => null),
- getDeconstructPoints(id).catch(() => null),
- ])
- setDetail(d)
- setAi(a)
- setPoints(p)
- if (!d) message.warning('未查询到视频详情')
- } finally {
- setLoadingMain(false)
- }
- }, [])
- const onQuery = () => {
- if (!videoId || videoId <= 0) {
- message.warning('请输入有效的视频ID')
- return
- }
- runQuery(videoId)
- }
- /** 进入页面自动用默认ID查一次, 避免空白态 */
- const autoQueriedRef = useRef(false)
- useEffect(() => {
- if (autoQueriedRef.current) return
- autoQueriedRef.current = true
- if (videoId && videoId > 0) {
- runQuery(videoId)
- }
- }, [runQuery, videoId])
- const onRecallByText = useCallback(
- async (text: string, configCodeOverride?: string) => {
- const trimmed = text.trim()
- if (!trimmed) return
- const finalConfigCode = configCodeOverride || 'VIDEO_TOPIC'
- setLoadingRecall(true)
- const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
- const codeLabel = configCodes[finalConfigCode] ?? finalConfigCode
- setRecallMeta({
- dimensionLabel: codeLabel,
- dimensionCode: finalConfigCode,
- description: `基于文本 "${preview}"`,
- })
- try {
- const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, topN })
- setRecall(filterOutSelf(data, videoId))
- message.success(`召回 ${data.total} 条`)
- } catch {
- message.error('召回失败')
- } finally {
- setLoadingRecall(false)
- }
- },
- [topN, videoId, configCodes],
- )
- /** URL 传入 videoId 时, 首次查询结束后自动按"选题"维度召回 (0 点击) */
- useEffect(() => {
- if (!pendingAutoRecallRef.current) return
- if (!autoQueriedRef.current) return
- if (loadingMain) return
- pendingAutoRecallRef.current = false
- if (points?.topic && points.topic.trim()) {
- onRecallByText(points.topic, 'VIDEO_TOPIC')
- }
- }, [loadingMain, points, onRecallByText])
- return (
- <Space direction="vertical" size={16} style={{ width: '100%' }}>
- {/* 查询条 */}
- <Card size="small" bodyStyle={{ padding: '12px 16px' }}>
- <Space wrap size={[12, 8]}>
- <Text strong>视频ID</Text>
- <InputNumber
- placeholder="请输入"
- min={1}
- value={videoId}
- onChange={(v) => setVideoId(v)}
- style={{ width: 200 }}
- onPressEnter={onQuery}
- controls={false}
- />
- <Button type="primary" icon={<SearchOutlined />} loading={loadingMain} onClick={onQuery}>
- 查询
- </Button>
- <span style={{ color: '#d9d9d9' }}>|</span>
- <Text strong>TopN</Text>
- <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
- <Text type="secondary" style={{ fontSize: 12 }}>
- 召回维度由下方解构层级各点的"以此召回"按钮直接选择
- </Text>
- </Space>
- </Card>
- {queried && (
- <>
- {/* 三栏并列(顺序: 选题解构 / AI识别(老) / 视频详情) */}
- <Row gutter={[16, 16]}>
- {/* 选题解构 (放第一位) */}
- <Col xs={24} md={8}>
- <Card
- size="small"
- title={
- <Space size={8}>
- <ApartmentOutlined style={{ color: '#52c41a', fontSize: 18 }} />
- <span style={{ fontSize: 17, fontWeight: 700 }}>选题解构</span>
- <Tag color="green-inverse" style={{ marginLeft: 4 }}>召回维度</Tag>
- </Space>
- }
- loading={loadingMain}
- style={{ height: '100%' }}
- bodyStyle={{ maxHeight: 560, overflowY: 'auto' }}
- >
- <DeconstructTree data={points} loading={loadingMain} onRecallByText={onRecallByText} />
- </Card>
- </Col>
- {/* 视频内容理解 - 放第二位 */}
- <Col xs={24} md={8}>
- <Card
- size="small"
- title={
- <Space size={8}>
- <BulbOutlined style={{ color: '#fa8c16', fontSize: 18 }} />
- <span style={{ fontSize: 17, fontWeight: 700 }}>视频内容理解</span>
- <Tag color="orange-inverse" style={{ marginLeft: 4 }}>召回维度</Tag>
- </Space>
- }
- loading={loadingMain}
- style={{ height: '100%' }}
- >
- <AIUnderstandingPanel data={ai} loading={loadingMain} />
- </Card>
- </Col>
- {/* 视频详情 + 播放器 - 放第三位 */}
- <Col xs={24} md={8}>
- <Card
- size="small"
- title={
- <Space size={8}>
- <VideoCameraOutlined style={{ color: '#1677ff', fontSize: 18 }} />
- <span style={{ fontSize: 17, fontWeight: 700 }}>视频详情</span>
- </Space>
- }
- loading={loadingMain}
- style={{ height: '100%' }}
- >
- {detail ? (
- <Space direction="vertical" size={10} style={{ width: '100%' }}>
- <DetailRow label="ID" value={String(detail.videoId)} />
- <DetailRow label="标题" value={detail.title ?? '-'} />
- <DetailRow
- label="播放量"
- value={
- <Text type={detail.playCount === '--' ? 'secondary' : undefined}>
- {detail.playCount}
- </Text>
- }
- hint={detail.playCount === '--' ? '字段缺失,占位' : undefined}
- />
- <div style={{ marginTop: 4 }}>
- {detail.videoUrl ? (
- <VideoPlayer src={toHttps(detail.videoUrl)} poster={toHttps(detail.cover) || undefined} />
- ) : (
- <Empty description="无视频地址" image={Empty.PRESENTED_IMAGE_SIMPLE} />
- )}
- </div>
- </Space>
- ) : (
- <Empty description="未查询到视频" image={Empty.PRESENTED_IMAGE_SIMPLE} />
- )}
- </Card>
- </Col>
- </Row>
- {/* 召回结果 */}
- <Card
- size="small"
- title={<RecallTitle meta={recallMeta} />}
- >
- <RecallResultList result={recall} loading={loadingRecall} />
- </Card>
- </>
- )}
- </Space>
- )
- }
- function DetailRow({
- label,
- value,
- hint,
- }: {
- label: string
- value: React.ReactNode
- hint?: string
- }) {
- return (
- <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
- <Text type="secondary" style={{ minWidth: 56, fontSize: 12 }}>
- {label}
- </Text>
- <div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
- {value}
- {hint && (
- <Text type="secondary" style={{ marginLeft: 6, fontSize: 11 }}>
- ({hint})
- </Text>
- )}
- </div>
- </div>
- )
- }
- /** 召回结果卡片标题: "召回维度:[中文标签]" + 描述 */
- function RecallTitle({
- meta,
- }: {
- meta: { dimensionLabel: string; dimensionCode: string; description: string } | null
- }) {
- if (!meta) {
- return (
- <Space size={6}>
- <SearchOutlined style={{ color: '#722ed1' }} />
- <span>召回结果</span>
- </Space>
- )
- }
- // 按 configCode 前缀走两套配色
- const palette = meta.dimensionCode.startsWith('RESULT_LOG_')
- ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
- : 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' }}>
- <SearchOutlined 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>
- )
- }
- // ============================================================================
- // Tab2: 文本输入 → 文本召回
- // ============================================================================
- function TextRecallTab() {
- /** URL ?queryText=xxx&configCode=yyy 落地参数,缺省走原有空白态 */
- const urlQueryText = readUrlQueryText()
- const urlConfigCode = readUrlConfigCode()
- const [queryText, setQueryText] = useState(urlQueryText ?? '')
- const [configCode, setConfigCode] = useState(urlConfigCode ?? 'VIDEO_TOPIC')
- const [topN, setTopN] = useState<number>(10)
- 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 onSubmit = useCallback(async () => {
- const trimmed = queryText.trim()
- if (!trimmed) {
- message.warning('请输入查询文本')
- return
- }
- setLoading(true)
- const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
- setResultMeta({
- dimensionLabel: configCodes[configCode] ?? configCode,
- dimensionCode: configCode,
- description: `基于文本 "${preview}"`,
- })
- try {
- const data = await matchByText({ queryText: trimmed, configCode, topN })
- setResult(data)
- message.success(`召回 ${data.total} 条`)
- } catch {
- message.error('召回失败')
- } finally {
- setLoading(false)
- }
- }, [queryText, configCode, topN, configCodes])
- /** URL 上有 queryText 时, 挂载后自动触发一次召回 (Grafana 跳转 0 点击) */
- const autoSearchedRef = useRef(false)
- useEffect(() => {
- if (autoSearchedRef.current) return
- if (!urlQueryText) return
- autoSearchedRef.current = true
- onSubmit()
- }, [urlQueryText, onSubmit])
- return (
- <Space direction="vertical" size={16} style={{ width: '100%' }}>
- <Card size="small" bodyStyle={{ padding: 16 }}>
- <Space direction="vertical" size={12} style={{ width: '100%' }}>
- <TextArea
- placeholder="请输入查询文本(选题、灵感点描述等)"
- value={queryText}
- onChange={(e) => setQueryText(e.target.value)}
- rows={4}
- allowClear
- />
- <Space wrap>
- <Text strong>召回维度</Text>
- <Select value={configCode} onChange={setConfigCode} options={groupedOptions} style={{ width: 240 }} />
- <Text strong>TopN</Text>
- <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
- <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
- 召回
- </Button>
- </Space>
- </Space>
- </Card>
- <Card size="small" title={<RecallTitle meta={resultMeta} />}>
- <RecallResultList result={result} loading={loading} />
- </Card>
- </Space>
- )
- }
|