RecallResultTable.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014
  1. import { useMemo, useState } from 'react'
  2. import {
  3. Table,
  4. Tag,
  5. Typography,
  6. Tooltip,
  7. Space,
  8. Empty,
  9. InputNumber,
  10. Button,
  11. Checkbox,
  12. } from 'antd'
  13. import {
  14. PlayCircleFilled,
  15. FilterFilled,
  16. QuestionCircleOutlined,
  17. } from '@ant-design/icons'
  18. import type { ColumnsType, SorterResult } from 'antd/es/table/interface'
  19. import type {
  20. EssenceWord,
  21. Modality,
  22. VideoDetailDeconstruct,
  23. VideoMatchEnrichedVO,
  24. } from '../api/types'
  25. import { useConfigCodes, getConfigDisplayLabel } from '../api/configCodes'
  26. import { toHttps } from '../utils/url'
  27. import {
  28. formatNumber,
  29. formatRatio,
  30. getScoreStyle,
  31. parseNum,
  32. } from '../utils/format'
  33. import {
  34. computeCompositeScore,
  35. type RankingParams,
  36. type ScoreBreakdown,
  37. } from '../utils/scoring'
  38. const { Text, Paragraph } = Typography
  39. interface Filters {
  40. configCodes: string[]
  41. pv: number | null
  42. hl: number | null
  43. rov: number | null
  44. }
  45. const EMPTY_FILTERS: Filters = {
  46. configCodes: [],
  47. pv: null,
  48. hl: null,
  49. rov: null,
  50. }
  51. /** 派生:item + 综合得分分解, 渲染时统一用这个壳 */
  52. type RowItem = VideoMatchEnrichedVO & { _breakdown: ScoreBreakdown | null }
  53. const ACTIVE_ICON_COLOR = '#1677ff'
  54. const IDLE_ICON_COLOR = '#bfbfbf'
  55. /** 指标类列(分发曝光pv / 总回流 / ROV)用 amber 色调与解构/旧字段区分 */
  56. const METRIC_HEADER_STYLE: React.CSSProperties = {
  57. background: '#fff7e6',
  58. color: '#d48806',
  59. fontWeight: 600,
  60. }
  61. const METRIC_CELL_STYLE: React.CSSProperties = {
  62. background: '#fffbe6',
  63. }
  64. interface Props {
  65. items: VideoMatchEnrichedVO[]
  66. /** 'ALL' 走视频列布局 */
  67. activeModality: 'ALL' | Modality
  68. rankingParams: RankingParams
  69. /** 受控的"综合得分"列 sort 状态 — 由外部按钮 + 列头联动 */
  70. compositeSort: 'descend' | 'ascend' | null
  71. onCompositeSortChange: (next: 'descend' | 'ascend' | null) => void
  72. }
  73. /**
  74. * 按 modality 取解构对象 - 视频取 videoDetail.deconstruct, 素材/长文取各自 detail
  75. */
  76. function getDeconstruct(item: VideoMatchEnrichedVO): VideoDetailDeconstruct | undefined {
  77. if (item.modality === 'MATERIAL') return item.materialDetail?.deconstruct
  78. if (item.modality === 'ARTICLE') return item.articleDetail?.deconstruct
  79. return item.videoDetail?.deconstruct
  80. }
  81. /**
  82. * 召回结果表格
  83. * 列顺序: 标题/封面/召回维度/综合得分/向量相似度/解构/旧AI/指标
  84. * 综合得分 = c × (α × sim_norm + (1-α) × rov_norm), 参数走 rankingParams (浮层可改)
  85. * sim < simThreshold 的 item 直接剔除
  86. */
  87. export default function RecallResultTable({
  88. items,
  89. activeModality,
  90. rankingParams,
  91. compositeSort,
  92. onCompositeSortChange,
  93. }: Props) {
  94. const configCodes = useConfigCodes()
  95. const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
  96. /** 给每条 item 挂上综合得分分解 */
  97. const rowItems: RowItem[] = useMemo(
  98. () => items.map((it) => ({ ...it, _breakdown: computeCompositeScore(it, rankingParams) })),
  99. [items, rankingParams],
  100. )
  101. /** 阈值剔除 — sim 缺失或 < simThreshold 的项不进入展示 */
  102. const thresholdFiltered = useMemo(
  103. () => rowItems.filter((it) => it._breakdown != null && it._breakdown.passesThreshold),
  104. [rowItems],
  105. )
  106. const thresholdRejected = rowItems.length - thresholdFiltered.length
  107. const codeOptions = useMemo(() => {
  108. const set = new Set<string>()
  109. thresholdFiltered.forEach((it) => {
  110. if (it.configCode) set.add(it.configCode)
  111. })
  112. return Array.from(set).map((code) => ({
  113. label: getConfigDisplayLabel(code, configCodes),
  114. value: code,
  115. }))
  116. }, [thresholdFiltered, configCodes])
  117. const filteredItems = useMemo(() => {
  118. return thresholdFiltered.filter((it) => {
  119. if (filters.configCodes.length > 0) {
  120. if (!it.configCode || !filters.configCodes.includes(it.configCode)) return false
  121. }
  122. if (filters.pv != null) {
  123. const v = parseNum(it.videoDetail?.['分发曝光pv'])
  124. if (v == null || v <= filters.pv) return false
  125. }
  126. if (filters.hl != null) {
  127. const v = parseNum(it.videoDetail?.['总回流'])
  128. if (v == null || v <= filters.hl) return false
  129. }
  130. if (filters.rov != null) {
  131. const v = parseNum(it.videoDetail?.rov)
  132. if (v == null || v <= filters.rov) return false
  133. }
  134. return true
  135. })
  136. }, [thresholdFiltered, filters])
  137. const hasFilter =
  138. filters.configCodes.length > 0 ||
  139. filters.pv != null ||
  140. filters.hl != null ||
  141. filters.rov != null
  142. if (!items || items.length === 0) {
  143. return <Empty description="该模态下无召回结果" />
  144. }
  145. const thresholdDropdown = (
  146. field: 'pv' | 'hl' | 'rov',
  147. placeholder: string,
  148. step?: number,
  149. ) =>
  150. function ThresholdDropdown({ confirm }: { confirm: (p?: { closeDropdown: boolean }) => void }) {
  151. return (
  152. <div style={{ padding: 8, minWidth: 180 }} onKeyDown={(e) => e.stopPropagation()}>
  153. <InputNumber
  154. autoFocus
  155. placeholder={placeholder}
  156. value={filters[field] ?? undefined}
  157. step={step}
  158. onChange={(v) =>
  159. setFilters((f) => ({ ...f, [field]: typeof v === 'number' ? v : null }))
  160. }
  161. onPressEnter={() => confirm({ closeDropdown: true })}
  162. style={{ width: '100%' }}
  163. />
  164. <div
  165. style={{
  166. marginTop: 8,
  167. display: 'flex',
  168. justifyContent: 'space-between',
  169. }}
  170. >
  171. <Button
  172. size="small"
  173. onClick={() => {
  174. setFilters((f) => ({ ...f, [field]: null }))
  175. confirm({ closeDropdown: true })
  176. }}
  177. >
  178. 清除
  179. </Button>
  180. <Button
  181. size="small"
  182. type="primary"
  183. onClick={() => confirm({ closeDropdown: true })}
  184. >
  185. 确定
  186. </Button>
  187. </div>
  188. </div>
  189. )
  190. }
  191. const titleCol: ColumnsType<RowItem>[number] = {
  192. title: '标题',
  193. key: 'title',
  194. width: 240,
  195. fixed: 'left',
  196. render: (_v, item) => <TitleCell item={item} />,
  197. }
  198. const coverCol: ColumnsType<RowItem>[number] = {
  199. title: '封面',
  200. key: 'cover',
  201. width: 100,
  202. fixed: 'left',
  203. render: (_v, item) => <CoverCell item={item} />,
  204. }
  205. const configCodeCol: ColumnsType<RowItem>[number] = {
  206. title: '召回维度',
  207. key: 'configCode',
  208. width: 130,
  209. fixed: 'left',
  210. filterIcon: () => (
  211. <FilterFilled
  212. style={{
  213. color: filters.configCodes.length > 0 ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR,
  214. }}
  215. />
  216. ),
  217. filterDropdown: ({ confirm }) => (
  218. <div style={{ padding: 8, minWidth: 180 }}>
  219. {codeOptions.length === 0 ? (
  220. <Text type="secondary" style={{ fontSize: 12 }}>
  221. 无可选维度
  222. </Text>
  223. ) : (
  224. <Checkbox.Group
  225. value={filters.configCodes}
  226. onChange={(vals) => setFilters((f) => ({ ...f, configCodes: vals as string[] }))}
  227. style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 280, overflowY: 'auto' }}
  228. >
  229. {codeOptions.map((opt) => (
  230. <Checkbox key={opt.value} value={opt.value}>
  231. {opt.label}
  232. </Checkbox>
  233. ))}
  234. </Checkbox.Group>
  235. )}
  236. <div
  237. style={{
  238. marginTop: 8,
  239. paddingTop: 8,
  240. borderTop: '1px solid #f0f0f0',
  241. display: 'flex',
  242. justifyContent: 'space-between',
  243. }}
  244. >
  245. <Button
  246. size="small"
  247. onClick={() => {
  248. setFilters((f) => ({ ...f, configCodes: [] }))
  249. confirm({ closeDropdown: true })
  250. }}
  251. >
  252. 清除
  253. </Button>
  254. <Button size="small" type="primary" onClick={() => confirm({ closeDropdown: true })}>
  255. 确定
  256. </Button>
  257. </div>
  258. </div>
  259. ),
  260. render: (_v, item) => {
  261. if (!item.configCode) return <Text type="secondary">--</Text>
  262. const label = getConfigDisplayLabel(item.configCode, configCodes)
  263. return (
  264. <Tooltip title={item.configCode}>
  265. <Tag color="purple" style={{ margin: 0 }}>
  266. {label}
  267. </Tag>
  268. </Tooltip>
  269. )
  270. },
  271. }
  272. const compositeCol: ColumnsType<RowItem>[number] = {
  273. title: (
  274. <Tooltip
  275. overlayStyle={{ maxWidth: 360 }}
  276. title={
  277. <div style={{ fontSize: 12 }}>
  278. 点击列头按综合得分倒排,公式见右上角"排序参数"。
  279. </div>
  280. }
  281. >
  282. <span>
  283. 综合得分{' '}
  284. <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
  285. </span>
  286. </Tooltip>
  287. ),
  288. key: 'composite',
  289. width: 130,
  290. align: 'center',
  291. fixed: 'left',
  292. sorter: (a, b) =>
  293. (a._breakdown?.composite ?? -Infinity) - (b._breakdown?.composite ?? -Infinity),
  294. sortDirections: ['descend', 'ascend'],
  295. sortOrder: compositeSort ?? null,
  296. render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} />,
  297. }
  298. const scoreColumn: ColumnsType<RowItem>[number] = {
  299. title: '向量相似度',
  300. key: 'score',
  301. width: 130,
  302. align: 'center',
  303. fixed: 'left',
  304. render: (_v, item) => <ScoreCell score={item.score} />,
  305. }
  306. /** 视频专属列(推荐状态 + 解构 + 旧 AI + 运营指标) */
  307. const videoOnlyCols: ColumnsType<RowItem> = [
  308. {
  309. title: '推荐状态',
  310. key: 'recommendStatus',
  311. width: 100,
  312. align: 'center',
  313. render: (_v, item) => <RecommendStatusCell value={item.videoDetail?.['推荐状态']} />,
  314. },
  315. deconstructTopicCol(280),
  316. pointsCol('解构:灵感点', '灵感点', 240),
  317. pointsCol('解构:关键点', '关键点', 240),
  318. pointsCol('解构:目的点', '目的点', 240),
  319. textCol('视频主题-旧', '视频主题', 110, true),
  320. textCol('内容选题-旧', '内容选题', 130, true),
  321. textCol('视频关键词-旧', '视频关键词', 120, true),
  322. {
  323. title: '分发曝光pv',
  324. key: '分发曝光pv',
  325. width: 140,
  326. align: 'right',
  327. onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
  328. onCell: () => ({ style: METRIC_CELL_STYLE }),
  329. filterIcon: () => (
  330. <FilterFilled
  331. style={{ color: filters.pv != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
  332. />
  333. ),
  334. filterDropdown: thresholdDropdown('pv', '分发曝光pv大于', 1000),
  335. render: (_v, item) => (
  336. <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
  337. {formatNumber(item.videoDetail?.['分发曝光pv'])}
  338. </span>
  339. ),
  340. },
  341. {
  342. title: '总回流',
  343. key: '总回流',
  344. width: 130,
  345. align: 'right',
  346. onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
  347. onCell: () => ({ style: METRIC_CELL_STYLE }),
  348. filterIcon: () => (
  349. <FilterFilled
  350. style={{ color: filters.hl != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
  351. />
  352. ),
  353. filterDropdown: thresholdDropdown('hl', '总回流大于', 1000),
  354. render: (_v, item) => (
  355. <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
  356. {formatNumber(item.videoDetail?.['总回流'])}
  357. </span>
  358. ),
  359. },
  360. {
  361. title: 'ROV',
  362. key: 'rov',
  363. width: 110,
  364. align: 'right',
  365. onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
  366. onCell: () => ({ style: METRIC_CELL_STYLE }),
  367. sorter: (a, b) => {
  368. const av = parseNum(a.videoDetail?.rov)
  369. const bv = parseNum(b.videoDetail?.rov)
  370. if (av == null && bv == null) return 0
  371. if (av == null) return 1
  372. if (bv == null) return -1
  373. return av - bv
  374. },
  375. sortDirections: ['descend', 'ascend'],
  376. filterIcon: () => (
  377. <FilterFilled
  378. style={{ color: filters.rov != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
  379. />
  380. ),
  381. filterDropdown: thresholdDropdown('rov', 'ROV大于', 0.01),
  382. render: (_v, item) => (
  383. <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
  384. {formatRatio(item.videoDetail?.rov)}
  385. </span>
  386. ),
  387. },
  388. ]
  389. /** 素材专属列 */
  390. const materialOnlyCols: ColumnsType<RowItem> = [
  391. {
  392. title: '图片张数',
  393. key: 'imageCount',
  394. width: 90,
  395. align: 'right',
  396. render: (_v, item) => {
  397. const n = item.materialDetail?.imageCount ?? item.imageList?.length
  398. return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{n != null ? n : '--'}</span>
  399. },
  400. },
  401. {
  402. title: '来源',
  403. key: 'material.source',
  404. width: 100,
  405. render: (_v, item) => textOrDash(item.materialDetail?.source),
  406. },
  407. {
  408. title: '上传时间',
  409. key: 'material.uploadTime',
  410. width: 140,
  411. render: (_v, item) => textOrDash(item.materialDetail?.uploadTime),
  412. },
  413. {
  414. title: '使用次数',
  415. key: 'material.usageCount',
  416. width: 100,
  417. align: 'right',
  418. onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
  419. onCell: () => ({ style: METRIC_CELL_STYLE }),
  420. sorter: (a, b) => {
  421. const av = parseNum(a.materialDetail?.usageCount)
  422. const bv = parseNum(b.materialDetail?.usageCount)
  423. if (av == null && bv == null) return 0
  424. if (av == null) return 1
  425. if (bv == null) return -1
  426. return av - bv
  427. },
  428. sortDirections: ['descend', 'ascend'],
  429. render: (_v, item) => (
  430. <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
  431. {formatNumber(item.materialDetail?.usageCount)}
  432. </span>
  433. ),
  434. },
  435. {
  436. title: '标签',
  437. key: 'material.tags',
  438. width: 240,
  439. render: (_v, item) => <TagsCell tags={item.materialDetail?.tags} />,
  440. },
  441. deconstructTopicCol(280),
  442. pointsCol('解构:灵感点', '灵感点', 240),
  443. pointsCol('解构:关键点', '关键点', 240),
  444. pointsCol('解构:目的点', '目的点', 240),
  445. ]
  446. /** 长文专属列 */
  447. const articleOnlyCols: ColumnsType<RowItem> = [
  448. {
  449. title: '来源',
  450. key: 'article.channelName',
  451. width: 140,
  452. render: (_v, item) => textOrDash(item.articleDetail?.channelName),
  453. },
  454. {
  455. title: '作者',
  456. key: 'article.channelAccountName',
  457. width: 140,
  458. render: (_v, item) => textOrDash(item.articleDetail?.channelAccountName),
  459. },
  460. {
  461. title: '阅读量',
  462. key: 'article.readCount',
  463. width: 110,
  464. align: 'right',
  465. onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
  466. onCell: () => ({ style: METRIC_CELL_STYLE }),
  467. sorter: (a, b) => {
  468. const av = parseNum(a.articleDetail?.readCount)
  469. const bv = parseNum(b.articleDetail?.readCount)
  470. if (av == null && bv == null) return 0
  471. if (av == null) return 1
  472. if (bv == null) return -1
  473. return av - bv
  474. },
  475. sortDirections: ['descend', 'ascend'],
  476. render: (_v, item) => (
  477. <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
  478. {formatNumber(item.articleDetail?.readCount)}
  479. </span>
  480. ),
  481. },
  482. {
  483. title: '点赞',
  484. key: 'article.likeCount',
  485. width: 100,
  486. align: 'right',
  487. render: (_v, item) => (
  488. <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
  489. {formatNumber(item.articleDetail?.likeCount)}
  490. </span>
  491. ),
  492. },
  493. {
  494. title: '在看',
  495. key: 'article.lookingCount',
  496. width: 100,
  497. align: 'right',
  498. render: (_v, item) => (
  499. <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
  500. {formatNumber(item.articleDetail?.lookingCount)}
  501. </span>
  502. ),
  503. },
  504. {
  505. title: '字数',
  506. key: 'article.wordCount',
  507. width: 90,
  508. align: 'right',
  509. render: (_v, item) => {
  510. const n = item.articleDetail?.wordCount
  511. return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{n ?? '--'}</span>
  512. },
  513. },
  514. {
  515. title: '摘要',
  516. key: 'article.summary',
  517. width: 320,
  518. render: (_v, item) => {
  519. const s = item.articleDetail?.summary
  520. if (!s) return <Text type="secondary">--</Text>
  521. return (
  522. <Paragraph
  523. style={{ marginBottom: 0, fontSize: 12, lineHeight: 1.45, whiteSpace: 'normal', wordBreak: 'break-word' }}
  524. ellipsis={{ rows: 3, tooltip: s }}
  525. >
  526. {s}
  527. </Paragraph>
  528. )
  529. },
  530. },
  531. {
  532. title: '发布时间',
  533. key: 'article.publishTime',
  534. width: 150,
  535. render: (_v, item) => textOrDash(item.articleDetail?.publishTime),
  536. },
  537. {
  538. title: '原文',
  539. key: 'article.htmlUrl',
  540. width: 80,
  541. align: 'center',
  542. render: (_v, item) => {
  543. const url = item.articleDetail?.htmlUrl
  544. if (!url) return <Text type="secondary">--</Text>
  545. return (
  546. <a href={toHttps(url)} target="_blank" rel="noopener noreferrer">
  547. 打开
  548. </a>
  549. )
  550. },
  551. },
  552. deconstructTopicCol(280),
  553. pointsCol('解构:灵感点', '灵感点', 240),
  554. pointsCol('解构:关键点', '关键点', 240),
  555. pointsCol('解构:目的点', '目的点', 240),
  556. ]
  557. /** 按 modality 拼装最终列 + 计算 scroll.x */
  558. let columns: ColumnsType<RowItem>
  559. if (activeModality === 'MATERIAL') {
  560. // 素材 Tab: 不展示综合得分列(rov 不适用)
  561. columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...materialOnlyCols]
  562. } else if (activeModality === 'ARTICLE') {
  563. columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...articleOnlyCols]
  564. } else {
  565. // VIDEO + ALL 走视频列布局
  566. columns = [titleCol, coverCol, configCodeCol, compositeCol, scoreColumn, ...videoOnlyCols]
  567. }
  568. const scrollX = columns.reduce((s, c) => s + (Number(c.width) || 0), 0)
  569. return (
  570. <div>
  571. {thresholdRejected > 0 && (
  572. <div
  573. style={{
  574. marginBottom: 8,
  575. padding: '6px 10px',
  576. background: '#fff7e6',
  577. border: '1px solid #ffd591',
  578. borderRadius: 4,
  579. }}
  580. >
  581. <Text style={{ fontSize: 12, color: '#d46b08' }}>
  582. 相似度阈值 ≥ {rankingParams.simThreshold} (剔除 <b>{thresholdRejected}</b> 条);
  583. 如需调整请点右上角"排序参数"
  584. </Text>
  585. </div>
  586. )}
  587. {hasFilter && (
  588. <div
  589. style={{
  590. marginBottom: 8,
  591. display: 'flex',
  592. alignItems: 'center',
  593. gap: 12,
  594. padding: '6px 10px',
  595. background: '#f0f5ff',
  596. border: '1px solid #adc6ff',
  597. borderRadius: 4,
  598. }}
  599. >
  600. <Text style={{ fontSize: 12 }}>
  601. 已应用筛选: 显示 <b>{filteredItems.length}</b> / {thresholdFiltered.length}
  602. </Text>
  603. <ActiveFilterTags
  604. filters={filters}
  605. configCodes={configCodes}
  606. onClear={(field) =>
  607. setFilters((f) => ({ ...f, [field]: field === 'configCodes' ? [] : null }))
  608. }
  609. />
  610. <Button size="small" onClick={() => setFilters(EMPTY_FILTERS)}>
  611. 清除全部筛选
  612. </Button>
  613. </div>
  614. )}
  615. <Table<RowItem>
  616. size="small"
  617. bordered
  618. rowKey={(r) => `${r.modality}-${r.id}-${r.configCode ?? ''}`}
  619. dataSource={filteredItems}
  620. columns={columns}
  621. pagination={false}
  622. scroll={{ x: scrollX }}
  623. onChange={(_pagination, _filters, sorter) => {
  624. const s = sorter as SorterResult<RowItem>
  625. if (s && s.columnKey === 'composite') {
  626. onCompositeSortChange((s.order as 'descend' | 'ascend' | undefined) ?? null)
  627. } else {
  628. // 点了别的列的 sorter (例如 ROV) → 取消综合排序
  629. onCompositeSortChange(null)
  630. }
  631. }}
  632. />
  633. </div>
  634. )
  635. }
  636. function ActiveFilterTags({
  637. filters,
  638. configCodes,
  639. onClear,
  640. }: {
  641. filters: Filters
  642. configCodes: Record<string, string>
  643. onClear: (field: keyof Filters) => void
  644. }) {
  645. const tags: { key: keyof Filters; text: string }[] = []
  646. if (filters.configCodes.length > 0) {
  647. const labels = filters.configCodes.map((c) => getConfigDisplayLabel(c, configCodes)).join('/')
  648. tags.push({ key: 'configCodes', text: `维度: ${labels}` })
  649. }
  650. if (filters.pv != null) tags.push({ key: 'pv', text: `分发曝光pv>${filters.pv}` })
  651. if (filters.hl != null) tags.push({ key: 'hl', text: `总回流>${filters.hl}` })
  652. if (filters.rov != null) tags.push({ key: 'rov', text: `ROV>${filters.rov}` })
  653. return (
  654. <Space size={[4, 4]} wrap style={{ flex: 1 }}>
  655. {tags.map((t) => (
  656. <Tag key={t.key} closable color="blue" onClose={() => onClear(t.key)}>
  657. {t.text}
  658. </Tag>
  659. ))}
  660. </Space>
  661. )
  662. }
  663. /**
  664. * 长文本列
  665. * - wrap=false (默认): ellipsis 单行 + hover tooltip 看全
  666. * - wrap=true: 自动换行,最多 4 行后仍 tooltip 兜底
  667. */
  668. function textCol(
  669. title: string,
  670. key: string,
  671. width: number,
  672. wrap = false,
  673. ): ColumnsType<RowItem>[number] {
  674. return {
  675. title,
  676. key,
  677. width,
  678. render: (_v, item) => {
  679. const raw = item.videoDetail?.[key]
  680. const text = typeof raw === 'string' ? raw : ''
  681. if (!text) return <Text type="secondary">--</Text>
  682. if (wrap) {
  683. return (
  684. <Paragraph
  685. style={{ marginBottom: 0, fontSize: 12, lineHeight: 1.45, whiteSpace: 'normal', wordBreak: 'break-word' }}
  686. ellipsis={{ rows: 4, tooltip: text }}
  687. >
  688. {text}
  689. </Paragraph>
  690. )
  691. }
  692. return (
  693. <Tooltip title={text} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
  694. <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
  695. {text}
  696. </Text>
  697. </Tooltip>
  698. )
  699. },
  700. }
  701. }
  702. /** 解构:选题 列 - 文本 ellipsis + tooltip,按 modality 从对应 detail.deconstruct 取 */
  703. function deconstructTopicCol(width: number): ColumnsType<RowItem>[number] {
  704. return {
  705. title: '解构:选题',
  706. key: 'deconstruct.topic',
  707. width,
  708. render: (_v, item) => {
  709. const topic = getDeconstruct(item)?.topic
  710. if (!topic) return <Text type="secondary">--</Text>
  711. return (
  712. <Tooltip title={topic} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
  713. <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
  714. {topic}
  715. </Text>
  716. </Tooltip>
  717. )
  718. },
  719. }
  720. }
  721. /**
  722. * 解构:灵感点 / 关键点 / 目的点 列
  723. * 名称: 全展示 (Tag wrap, 不折叠)
  724. * 实质: 前 3 个 + "+N", hover tooltip 看全 (含 score)
  725. */
  726. function pointsCol(
  727. title: string,
  728. type: '灵感点' | '关键点' | '目的点',
  729. width: number,
  730. ): ColumnsType<RowItem>[number] {
  731. const essenceKey = `${type}-实质` as const
  732. return {
  733. title,
  734. key: title,
  735. width,
  736. render: (_v, item) => {
  737. const dec = getDeconstruct(item)
  738. if (!dec) return <Text type="secondary">--</Text>
  739. const names = (dec[type] ?? []) as string[]
  740. const essences = (dec[essenceKey] ?? []) as EssenceWord[]
  741. if (names.length === 0 && essences.length === 0) {
  742. return <Text type="secondary">--</Text>
  743. }
  744. return (
  745. <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
  746. {names.length > 0 && (
  747. <div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
  748. <Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto', lineHeight: '20px' }}>
  749. 名称:
  750. </Text>
  751. <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
  752. {names.map((n, i) => (
  753. <Tag key={`${n}-${i}`} color="blue" style={{ margin: 0, fontSize: 11 }}>
  754. {n}
  755. </Tag>
  756. ))}
  757. </Space>
  758. </div>
  759. )}
  760. {essences.length > 0 && (
  761. <div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
  762. <Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto', lineHeight: '20px' }}>
  763. 实质:
  764. </Text>
  765. <EssenceTags essences={essences} />
  766. </div>
  767. )}
  768. </div>
  769. )
  770. },
  771. }
  772. }
  773. function EssenceTags({ essences }: { essences: EssenceWord[] }) {
  774. const visible = essences.slice(0, 3)
  775. const rest = essences.length - visible.length
  776. const allText = essences
  777. .map((e) => (e.score != null ? `${e.word} (${e.score.toFixed(2)})` : e.word))
  778. .join('、')
  779. return (
  780. <Tooltip title={allText} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
  781. <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
  782. {visible.map((e, i) => (
  783. <Tag key={`${e.word}-${i}`} color="cyan" style={{ margin: 0, fontSize: 11 }}>
  784. {e.word}
  785. </Tag>
  786. ))}
  787. {rest > 0 && <Tag style={{ margin: 0, fontSize: 11 }}>+{rest}</Tag>}
  788. </Space>
  789. </Tooltip>
  790. )
  791. }
  792. function TitleCell({ item }: { item: VideoMatchEnrichedVO }) {
  793. return (
  794. <div style={{ minWidth: 0 }}>
  795. <Paragraph
  796. ellipsis={{ rows: 2, tooltip: item.title ?? '' }}
  797. style={{ marginBottom: 2, fontWeight: 500, fontSize: 13 }}
  798. >
  799. {item.title || <Text type="secondary">(无标题)</Text>}
  800. </Paragraph>
  801. <Text type="secondary" style={{ fontSize: 11 }}>
  802. ID: {item.id}
  803. </Text>
  804. </div>
  805. )
  806. }
  807. function CoverCell({ item }: { item: VideoMatchEnrichedVO }) {
  808. let imgSrc: string | null = null
  809. if (item.cover) imgSrc = toHttps(item.cover)
  810. else if (item.imageList && item.imageList.length > 0) imgSrc = toHttps(item.imageList[0])
  811. const playable = !!item.videoUrl
  812. const onClick = () => {
  813. if (playable) window.open(toHttps(item.videoUrl!), '_blank', 'noopener,noreferrer')
  814. }
  815. return (
  816. <div
  817. onClick={onClick}
  818. style={{
  819. position: 'relative',
  820. width: 80,
  821. height: 64,
  822. background: '#fafafa',
  823. borderRadius: 4,
  824. overflow: 'hidden',
  825. cursor: playable ? 'pointer' : 'default',
  826. }}
  827. >
  828. {imgSrc ? (
  829. <img
  830. src={imgSrc}
  831. alt="cover"
  832. referrerPolicy="no-referrer"
  833. style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
  834. onError={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
  835. />
  836. ) : (
  837. <div
  838. style={{
  839. width: '100%',
  840. height: '100%',
  841. display: 'flex',
  842. alignItems: 'center',
  843. justifyContent: 'center',
  844. color: '#bfbfbf',
  845. fontSize: 11,
  846. }}
  847. >
  848. 无封面
  849. </div>
  850. )}
  851. {playable && (
  852. <div
  853. style={{
  854. position: 'absolute',
  855. inset: 0,
  856. display: 'flex',
  857. alignItems: 'center',
  858. justifyContent: 'center',
  859. pointerEvents: 'none',
  860. }}
  861. >
  862. <PlayCircleFilled
  863. style={{
  864. fontSize: 22,
  865. color: '#fff',
  866. filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.6))',
  867. }}
  868. />
  869. </div>
  870. )}
  871. </div>
  872. )
  873. }
  874. function ScoreCell({ score }: { score: number | null }) {
  875. const style = getScoreStyle(score)
  876. const text = score != null ? score.toFixed(4) : '--'
  877. return (
  878. <span
  879. style={{
  880. display: 'inline-block',
  881. padding: '2px 8px',
  882. background: style.bg,
  883. border: `1px solid ${style.border}`,
  884. color: style.text,
  885. borderRadius: 4,
  886. fontWeight: 600,
  887. fontVariantNumeric: 'tabular-nums',
  888. fontSize: 12,
  889. }}
  890. >
  891. {text}
  892. </span>
  893. )
  894. }
  895. /** 推荐状态单元格 — 按状态关键字着色, 未知值走 default Tag */
  896. function RecommendStatusCell({ value }: { value: string | undefined }) {
  897. if (!value) return <Text type="secondary">--</Text>
  898. let color: string | undefined
  899. if (/推荐|上线|分发/.test(value)) color = 'green'
  900. else if (/下线|暂停|不推荐|停止/.test(value)) color = 'red'
  901. else if (/审核|待|中/.test(value)) color = 'orange'
  902. return (
  903. <Tag color={color} style={{ margin: 0, fontSize: 11 }}>
  904. {value}
  905. </Tag>
  906. )
  907. }
  908. /** 综合得分单元格 — hover Tooltip 展示 sim_norm/rov_norm/c 分解 */
  909. function CompositeScoreCell({ breakdown }: { breakdown: ScoreBreakdown | null }) {
  910. if (!breakdown) {
  911. return <Text type="secondary">--</Text>
  912. }
  913. const { composite, simNorm, rovNorm, boost } = breakdown
  914. // 用与向量相似度同款配色 — 但映射区间不同, 综合得分 [0, c] 内, 走 0.6/0.45/0.3 三档
  915. const styleScore =
  916. composite >= 0.6
  917. ? { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
  918. : composite >= 0.45
  919. ? { bg: '#fcffe6', border: '#eaff8f', text: '#7cb305' }
  920. : composite >= 0.3
  921. ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
  922. : { bg: '#fff1f0', border: '#ffa39e', text: '#cf1322' }
  923. const text = composite.toFixed(4)
  924. const tip = (
  925. <div style={{ fontSize: 12, lineHeight: 1.6 }}>
  926. <div>sim_norm = {simNorm.toFixed(3)}</div>
  927. <div>rov_norm = {rovNorm.toFixed(3)}</div>
  928. <div>c = {boost.toFixed(2)}</div>
  929. <div style={{ borderTop: '1px solid rgba(255,255,255,0.3)', marginTop: 4, paddingTop: 4 }}>
  930. composite = {boost.toFixed(2)} × (α·sim_norm + (1-α)·rov_norm) = <b>{text}</b>
  931. </div>
  932. </div>
  933. )
  934. return (
  935. <Tooltip title={tip} overlayStyle={{ maxWidth: 360 }}>
  936. <span
  937. style={{
  938. display: 'inline-block',
  939. padding: '2px 8px',
  940. background: styleScore.bg,
  941. border: `1px solid ${styleScore.border}`,
  942. color: styleScore.text,
  943. borderRadius: 4,
  944. fontWeight: 600,
  945. fontVariantNumeric: 'tabular-nums',
  946. fontSize: 12,
  947. cursor: 'help',
  948. }}
  949. >
  950. {text}
  951. </span>
  952. </Tooltip>
  953. )
  954. }
  955. /** 简单文本/占位 */
  956. function textOrDash(s: string | undefined) {
  957. if (!s) return <Text type="secondary">--</Text>
  958. return (
  959. <Tooltip title={s} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
  960. <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
  961. {s}
  962. </Text>
  963. </Tooltip>
  964. )
  965. }
  966. /** 标签列单元格 - 多个 Tag wrap 展示 */
  967. function TagsCell({ tags }: { tags: string[] | undefined }) {
  968. if (!tags || tags.length === 0) return <Text type="secondary">--</Text>
  969. return (
  970. <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
  971. {tags.map((t, i) => (
  972. <Tag key={`${t}-${i}`} color="geekblue" style={{ margin: 0, fontSize: 11 }}>
  973. {t}
  974. </Tag>
  975. ))}
  976. </Space>
  977. )
  978. }