|
@@ -0,0 +1,355 @@
|
|
|
|
|
+import { Table, Tag, Typography, Tooltip, Space, Empty } from 'antd'
|
|
|
|
|
+import { PlayCircleFilled } from '@ant-design/icons'
|
|
|
|
|
+import type { ColumnsType } from 'antd/es/table'
|
|
|
|
|
+import type { EssenceWord, VideoMatchEnrichedVO } from '../api/types'
|
|
|
|
|
+import { useConfigCodes } from '../api/configCodes'
|
|
|
|
|
+import { toHttps } from '../utils/url'
|
|
|
|
|
+import {
|
|
|
|
|
+ formatCount,
|
|
|
|
|
+ formatRatio,
|
|
|
|
|
+ getScoreStyle,
|
|
|
|
|
+} from '../utils/format'
|
|
|
|
|
+
|
|
|
|
|
+const { Text, Paragraph } = Typography
|
|
|
|
|
+
|
|
|
|
|
+/** 指标类列(分发曝光pv / 总回流 / ROV)用 amber 色调与解构/旧字段区分 */
|
|
|
|
|
+const METRIC_HEADER_STYLE: React.CSSProperties = {
|
|
|
|
|
+ background: '#fff7e6',
|
|
|
|
|
+ color: '#d48806',
|
|
|
|
|
+ fontWeight: 600,
|
|
|
|
|
+}
|
|
|
|
|
+const METRIC_CELL_STYLE: React.CSSProperties = {
|
|
|
|
|
+ background: '#fffbe6',
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Props {
|
|
|
|
|
+ items: VideoMatchEnrichedVO[]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 召回结果表格 — 14 列横向并列, 缺字段统一 "--", 长文案 ellipsis + hover tooltip
|
|
|
|
|
+ * 列顺序按业务约定: 标题/封面/召回维度/相似度/4个解构/3个旧AI/3个量级
|
|
|
|
|
+ */
|
|
|
|
|
+export default function RecallResultTable({ items }: Props) {
|
|
|
|
|
+ const configCodes = useConfigCodes()
|
|
|
|
|
+
|
|
|
|
|
+ if (!items || items.length === 0) {
|
|
|
|
|
+ return <Empty description="该模态下无召回结果" />
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const columns: ColumnsType<VideoMatchEnrichedVO> = [
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '标题',
|
|
|
|
|
+ key: 'title',
|
|
|
|
|
+ width: 240,
|
|
|
|
|
+ fixed: 'left',
|
|
|
|
|
+ render: (_v, item) => <TitleCell item={item} />,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '封面',
|
|
|
|
|
+ key: 'cover',
|
|
|
|
|
+ width: 100,
|
|
|
|
|
+ fixed: 'left',
|
|
|
|
|
+ render: (_v, item) => <CoverCell item={item} />,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '召回维度',
|
|
|
|
|
+ key: 'configCode',
|
|
|
|
|
+ width: 110,
|
|
|
|
|
+ fixed: 'left',
|
|
|
|
|
+ render: (_v, item) => {
|
|
|
|
|
+ if (!item.configCode) return <Text type="secondary">--</Text>
|
|
|
|
|
+ const label = configCodes[item.configCode] ?? item.configCode
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Tooltip title={item.configCode}>
|
|
|
|
|
+ <Tag color="purple" style={{ margin: 0 }}>
|
|
|
|
|
+ {label}
|
|
|
|
|
+ </Tag>
|
|
|
|
|
+ </Tooltip>
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '向量相似度',
|
|
|
|
|
+ key: 'score',
|
|
|
|
|
+ width: 100,
|
|
|
|
|
+ align: 'center',
|
|
|
|
|
+ fixed: 'left',
|
|
|
|
|
+ render: (_v, item) => <ScoreCell score={item.score} />,
|
|
|
|
|
+ },
|
|
|
|
|
+ deconstructTopicCol(280),
|
|
|
|
|
+ pointsCol('解构:灵感点', '灵感点', 240),
|
|
|
|
|
+ pointsCol('解构:关键点', '关键点', 240),
|
|
|
|
|
+ pointsCol('解构:目的点', '目的点', 240),
|
|
|
|
|
+ textCol('视频主题-旧', '视频主题', 180),
|
|
|
|
|
+ textCol('内容选题-旧', '内容选题', 220),
|
|
|
|
|
+ textCol('视频关键词-旧', '视频关键词', 200),
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '分发曝光pv',
|
|
|
|
|
+ key: '分发曝光pv',
|
|
|
|
|
+ width: 110,
|
|
|
|
|
+ align: 'right',
|
|
|
|
|
+ onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
|
|
|
|
|
+ onCell: () => ({ style: METRIC_CELL_STYLE }),
|
|
|
|
|
+ render: (_v, item) => formatCount(item.videoDetail?.['分发曝光pv']),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '总回流',
|
|
|
|
|
+ key: '总回流',
|
|
|
|
|
+ width: 100,
|
|
|
|
|
+ align: 'right',
|
|
|
|
|
+ onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
|
|
|
|
|
+ onCell: () => ({ style: METRIC_CELL_STYLE }),
|
|
|
|
|
+ render: (_v, item) => formatCount(item.videoDetail?.['总回流']),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: 'ROV',
|
|
|
|
|
+ key: 'rov',
|
|
|
|
|
+ width: 90,
|
|
|
|
|
+ align: 'right',
|
|
|
|
|
+ onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
|
|
|
|
|
+ onCell: () => ({ style: METRIC_CELL_STYLE }),
|
|
|
|
|
+ render: (_v, item) => (
|
|
|
|
|
+ <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
|
|
|
|
|
+ {formatRatio(item.videoDetail?.rov)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Table<VideoMatchEnrichedVO>
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ rowKey={(r) => `${r.modality}-${r.id}`}
|
|
|
|
|
+ dataSource={items}
|
|
|
|
|
+ columns={columns}
|
|
|
|
|
+ pagination={false}
|
|
|
|
|
+ scroll={{ x: 2400 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 长文本列: ellipsis 单行 + hover tooltip 显示完整 */
|
|
|
|
|
+function textCol(
|
|
|
|
|
+ title: string,
|
|
|
|
|
+ key: string,
|
|
|
|
|
+ width: number,
|
|
|
|
|
+): ColumnsType<VideoMatchEnrichedVO>[number] {
|
|
|
|
|
+ return {
|
|
|
|
|
+ title,
|
|
|
|
|
+ key,
|
|
|
|
|
+ width,
|
|
|
|
|
+ render: (_v, item) => {
|
|
|
|
|
+ const raw = item.videoDetail?.[key]
|
|
|
|
|
+ const text = typeof raw === 'string' ? raw : ''
|
|
|
|
|
+ if (!text) return <Text type="secondary">--</Text>
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Tooltip title={text} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
|
|
|
|
|
+ <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
|
|
|
|
|
+ {text}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ </Tooltip>
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 解构:选题 列 - 文本 ellipsis + tooltip,数据源 videoDetail.deconstruct.topic */
|
|
|
|
|
+function deconstructTopicCol(width: number): ColumnsType<VideoMatchEnrichedVO>[number] {
|
|
|
|
|
+ return {
|
|
|
|
|
+ title: '解构:选题',
|
|
|
|
|
+ key: 'deconstruct.topic',
|
|
|
|
|
+ width,
|
|
|
|
|
+ render: (_v, item) => {
|
|
|
|
|
+ const topic = item.videoDetail?.deconstruct?.topic
|
|
|
|
|
+ if (!topic) return <Text type="secondary">--</Text>
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Tooltip title={topic} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
|
|
|
|
|
+ <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
|
|
|
|
|
+ {topic}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ </Tooltip>
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 解构:灵感点 / 关键点 / 目的点 列
|
|
|
|
|
+ * 名称: 全展示 (Tag wrap, 不折叠)
|
|
|
|
|
+ * 实质: 前 3 个 + "+N", hover tooltip 看全 (含 score)
|
|
|
|
|
+ */
|
|
|
|
|
+function pointsCol(
|
|
|
|
|
+ title: string,
|
|
|
|
|
+ type: '灵感点' | '关键点' | '目的点',
|
|
|
|
|
+ width: number,
|
|
|
|
|
+): ColumnsType<VideoMatchEnrichedVO>[number] {
|
|
|
|
|
+ const essenceKey = `${type}-实质` as const
|
|
|
|
|
+ return {
|
|
|
|
|
+ title,
|
|
|
|
|
+ key: title,
|
|
|
|
|
+ width,
|
|
|
|
|
+ render: (_v, item) => {
|
|
|
|
|
+ const dec = item.videoDetail?.deconstruct
|
|
|
|
|
+ if (!dec) return <Text type="secondary">--</Text>
|
|
|
|
|
+ const names = (dec[type] ?? []) as string[]
|
|
|
|
|
+ const essences = (dec[essenceKey] ?? []) as EssenceWord[]
|
|
|
|
|
+ if (names.length === 0 && essences.length === 0) {
|
|
|
|
|
+ return <Text type="secondary">--</Text>
|
|
|
|
|
+ }
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
|
|
|
+ {names.length > 0 && (
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
|
|
|
|
|
+ <Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto', lineHeight: '20px' }}>
|
|
|
|
|
+ 名称:
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
|
|
|
|
|
+ {names.map((n, i) => (
|
|
|
|
|
+ <Tag key={`${n}-${i}`} color="blue" style={{ margin: 0, fontSize: 11 }}>
|
|
|
|
|
+ {n}
|
|
|
|
|
+ </Tag>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {essences.length > 0 && (
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
|
|
|
|
|
+ <Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto', lineHeight: '20px' }}>
|
|
|
|
|
+ 实质:
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ <EssenceTags essences={essences} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function EssenceTags({ essences }: { essences: EssenceWord[] }) {
|
|
|
|
|
+ const visible = essences.slice(0, 3)
|
|
|
|
|
+ const rest = essences.length - visible.length
|
|
|
|
|
+ const allText = essences
|
|
|
|
|
+ .map((e) => (e.score != null ? `${e.word} (${e.score.toFixed(2)})` : e.word))
|
|
|
|
|
+ .join('、')
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Tooltip title={allText} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
|
|
|
|
|
+ <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
|
|
|
|
|
+ {visible.map((e, i) => (
|
|
|
|
|
+ <Tag key={`${e.word}-${i}`} color="cyan" style={{ margin: 0, fontSize: 11 }}>
|
|
|
|
|
+ {e.word}
|
|
|
|
|
+ </Tag>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {rest > 0 && <Tag style={{ margin: 0, fontSize: 11 }}>+{rest}</Tag>}
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </Tooltip>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function TitleCell({ item }: { item: VideoMatchEnrichedVO }) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{ minWidth: 0 }}>
|
|
|
|
|
+ <Paragraph
|
|
|
|
|
+ ellipsis={{ rows: 2, tooltip: item.title ?? '' }}
|
|
|
|
|
+ style={{ marginBottom: 2, fontWeight: 500, fontSize: 13 }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {item.title || <Text type="secondary">(无标题)</Text>}
|
|
|
|
|
+ </Paragraph>
|
|
|
|
|
+ <Text type="secondary" style={{ fontSize: 11 }}>
|
|
|
|
|
+ ID: {item.id}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function CoverCell({ item }: { item: VideoMatchEnrichedVO }) {
|
|
|
|
|
+ let imgSrc: string | null = null
|
|
|
|
|
+ if (item.cover) imgSrc = toHttps(item.cover)
|
|
|
|
|
+ else if (item.imageList && item.imageList.length > 0) imgSrc = toHttps(item.imageList[0])
|
|
|
|
|
+
|
|
|
|
|
+ const playable = !!item.videoUrl
|
|
|
|
|
+ const onClick = () => {
|
|
|
|
|
+ if (playable) window.open(toHttps(item.videoUrl!), '_blank', 'noopener,noreferrer')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ onClick={onClick}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'relative',
|
|
|
|
|
+ width: 80,
|
|
|
|
|
+ height: 64,
|
|
|
|
|
+ background: '#fafafa',
|
|
|
|
|
+ borderRadius: 4,
|
|
|
|
|
+ overflow: 'hidden',
|
|
|
|
|
+ cursor: playable ? 'pointer' : 'default',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {imgSrc ? (
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={imgSrc}
|
|
|
|
|
+ alt="cover"
|
|
|
|
|
+ style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
|
|
|
+ onError={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: '100%',
|
|
|
|
|
+ height: '100%',
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ justifyContent: 'center',
|
|
|
|
|
+ color: '#bfbfbf',
|
|
|
|
|
+ fontSize: 11,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 无封面
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {playable && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ style={{
|
|
|
|
|
+ position: 'absolute',
|
|
|
|
|
+ inset: 0,
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ alignItems: 'center',
|
|
|
|
|
+ justifyContent: 'center',
|
|
|
|
|
+ pointerEvents: 'none',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <PlayCircleFilled
|
|
|
|
|
+ style={{
|
|
|
|
|
+ fontSize: 22,
|
|
|
|
|
+ color: '#fff',
|
|
|
|
|
+ filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.6))',
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function ScoreCell({ score }: { score: number | null }) {
|
|
|
|
|
+ const style = getScoreStyle(score)
|
|
|
|
|
+ const text = score != null ? score.toFixed(4) : '--'
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span
|
|
|
|
|
+ style={{
|
|
|
|
|
+ display: 'inline-block',
|
|
|
|
|
+ padding: '2px 8px',
|
|
|
|
|
+ background: style.bg,
|
|
|
|
|
+ border: `1px solid ${style.border}`,
|
|
|
|
|
+ color: style.text,
|
|
|
|
|
+ borderRadius: 4,
|
|
|
|
|
+ fontWeight: 600,
|
|
|
|
|
+ fontVariantNumeric: 'tabular-nums',
|
|
|
|
|
+ fontSize: 12,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {text}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|