VideoCard.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { Card, Tag, Typography } from 'antd'
  2. import { PlayCircleFilled } from '@ant-design/icons'
  3. import type { VideoMatchEnrichedVO } from '../api/types'
  4. import { toHttps } from '../utils/url'
  5. const { Text, Paragraph } = Typography
  6. interface VideoCardProps {
  7. item: VideoMatchEnrichedVO
  8. }
  9. export default function VideoCard({ item }: VideoCardProps) {
  10. const score = item.score != null ? item.score.toFixed(4) : '--'
  11. const scoreStyle = getScoreStyle(item.score)
  12. return (
  13. <Card
  14. size="small"
  15. hoverable
  16. style={{ width: '100%' }}
  17. cover={renderCover(item)}
  18. >
  19. <div
  20. style={{
  21. display: 'flex',
  22. alignItems: 'baseline',
  23. gap: 8,
  24. padding: '8px 10px',
  25. marginBottom: 8,
  26. background: scoreStyle.bg,
  27. border: `1px solid ${scoreStyle.border}`,
  28. borderRadius: 6,
  29. }}
  30. >
  31. <span style={{ fontSize: 12, color: 'rgba(0,0,0,0.55)' }}>相似度</span>
  32. <span style={{ fontSize: 22, fontWeight: 700, color: scoreStyle.text, lineHeight: 1 }}>
  33. {score}
  34. </span>
  35. </div>
  36. {item.configCode && (
  37. <div style={{ marginBottom: 8 }}>
  38. <Tag>{item.configCode}</Tag>
  39. </div>
  40. )}
  41. <Paragraph
  42. ellipsis={{ rows: 2, tooltip: item.title ?? '' }}
  43. style={{ marginBottom: 6, fontWeight: 500, minHeight: 44 }}
  44. >
  45. {item.title || <Text type="secondary">(无标题)</Text>}
  46. </Paragraph>
  47. <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>
  48. ID: {item.id}
  49. </div>
  50. {renderMetrics(item)}
  51. </Card>
  52. )
  53. }
  54. function getScoreStyle(score: number | null | undefined) {
  55. if (score == null) {
  56. return { bg: '#fafafa', border: '#e8e8e8', text: 'rgba(0,0,0,0.45)' }
  57. }
  58. if (score >= 0.85) return { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
  59. if (score >= 0.75) return { bg: '#fcffe6', border: '#eaff8f', text: '#7cb305' }
  60. if (score >= 0.65) return { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
  61. return { bg: '#fff1f0', border: '#ffa39e', text: '#cf1322' }
  62. }
  63. function renderCover(item: VideoMatchEnrichedVO) {
  64. let imgSrc: string | null = null
  65. if (item.cover) {
  66. imgSrc = toHttps(item.cover)
  67. } else if (item.imageList && item.imageList.length > 0) {
  68. imgSrc = toHttps(item.imageList[0])
  69. }
  70. const playable = !!item.videoUrl
  71. const handleClick = () => {
  72. if (playable) {
  73. window.open(toHttps(item.videoUrl!), '_blank', 'noopener,noreferrer')
  74. }
  75. }
  76. // 5:4 宽高比 (宽=5, 高=4) → paddingTop = 4/5 = 80%
  77. const containerStyle: React.CSSProperties = {
  78. position: 'relative',
  79. width: '100%',
  80. paddingTop: '80%',
  81. background: '#fafafa',
  82. overflow: 'hidden',
  83. cursor: playable ? 'pointer' : 'default',
  84. }
  85. return (
  86. <div style={containerStyle} onClick={handleClick}>
  87. {imgSrc ? (
  88. <img
  89. src={imgSrc}
  90. alt="cover"
  91. style={{
  92. position: 'absolute',
  93. inset: 0,
  94. width: '100%',
  95. height: '100%',
  96. objectFit: 'cover',
  97. display: 'block',
  98. }}
  99. onError={(e) => {
  100. ;(e.currentTarget as HTMLImageElement).style.visibility = 'hidden'
  101. }}
  102. />
  103. ) : (
  104. <div
  105. style={{
  106. position: 'absolute',
  107. inset: 0,
  108. display: 'flex',
  109. alignItems: 'center',
  110. justifyContent: 'center',
  111. color: '#bfbfbf',
  112. }}
  113. >
  114. 无封面
  115. </div>
  116. )}
  117. {playable && <PlayOverlay />}
  118. </div>
  119. )
  120. }
  121. function PlayOverlay() {
  122. return (
  123. <div
  124. style={{
  125. position: 'absolute',
  126. inset: 0,
  127. display: 'flex',
  128. alignItems: 'center',
  129. justifyContent: 'center',
  130. pointerEvents: 'none',
  131. }}
  132. >
  133. <div
  134. style={{
  135. width: 60,
  136. height: 60,
  137. borderRadius: '50%',
  138. background: 'rgba(0,0,0,0.45)',
  139. display: 'flex',
  140. alignItems: 'center',
  141. justifyContent: 'center',
  142. backdropFilter: 'blur(2px)',
  143. }}
  144. >
  145. <PlayCircleFilled style={{ fontSize: 36, color: '#fff' }} />
  146. </div>
  147. </div>
  148. )
  149. }
  150. function renderMetrics(item: VideoMatchEnrichedVO) {
  151. const items: Array<[string, string]> = []
  152. if (item.modality === 'VIDEO') {
  153. items.push(['播放量', item.playCount])
  154. items.push(['ROV', item.rov])
  155. } else if (item.modality === 'MATERIAL') {
  156. items.push(['曝光', item.exposure])
  157. items.push(['CTR', item.ctr])
  158. } else {
  159. items.push(['阅读', item.readCount])
  160. }
  161. return (
  162. <div style={{ marginTop: 6, fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>
  163. {items.map(([k, v]) => (
  164. <span key={k} style={{ marginRight: 12 }}>
  165. {k}: {v}
  166. </span>
  167. ))}
  168. </div>
  169. )
  170. }