RankingWeightsPanel.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { useState } from 'react'
  2. import { Button, InputNumber, Slider, Typography } from 'antd'
  3. import type { Modality } from '../api/types'
  4. import {
  5. DEFAULT_RANKING_PARAMS,
  6. type RankingParams,
  7. } from '../utils/scoring'
  8. const { Text } = Typography
  9. type ActiveModality = 'ALL' | Modality
  10. interface Props {
  11. params: RankingParams
  12. onChange: (next: RankingParams) => void
  13. activeModality: ActiveModality
  14. collapsible?: boolean
  15. }
  16. const ROW_LABEL_WIDTH = 72
  17. const CONTROL_ROW_HEIGHT = 32
  18. export default function RankingWeightsPanel({
  19. params,
  20. onChange,
  21. activeModality,
  22. collapsible = false,
  23. }: Props) {
  24. const [open, setOpen] = useState(true)
  25. const update = (patch: Partial<RankingParams>) => onChange({ ...params, ...patch })
  26. const showMaterialQuality = activeModality === 'MATERIAL' || activeModality === 'ALL'
  27. const showVideoExtra = activeModality === 'VIDEO' || activeModality === 'ALL'
  28. const controls = (
  29. <div style={{ paddingTop: collapsible && open ? 8 : 0 }}>
  30. {/* 第一行:α */}
  31. <RankingRow label="α 值">
  32. <InlineSlider
  33. hideLabel
  34. label="α 值"
  35. value={params.alpha}
  36. min={0}
  37. max={1}
  38. step={0.05}
  39. onChange={(v) => update({ alpha: v })}
  40. width={160}
  41. />
  42. </RankingRow>
  43. {/* 第二行:相关性阈值 */}
  44. <RankingRow label="相关性阈值">
  45. <InlineSlider
  46. label="sim 阈值"
  47. value={params.simThreshold}
  48. min={0}
  49. max={1}
  50. step={0.01}
  51. onChange={(v) => update({ simThreshold: v })}
  52. width={160}
  53. />
  54. </RankingRow>
  55. {/* 第三行:质量分权重 */}
  56. {showMaterialQuality && (
  57. <RankingRow label="质量分">
  58. <InlineSlider
  59. label="打开率 wCtr"
  60. value={params.wCtr}
  61. min={0}
  62. max={1}
  63. step={0.05}
  64. onChange={(v) => update({ wCtr: v })}
  65. width={120}
  66. />
  67. <InlineSlider
  68. label="裂变率 wViral"
  69. value={params.wViral}
  70. min={0}
  71. max={1}
  72. step={0.05}
  73. onChange={(v) => update({ wViral: v })}
  74. width={120}
  75. />
  76. <InlineSlider
  77. label="ROI wRoi"
  78. value={params.wRoi}
  79. min={0}
  80. max={1}
  81. step={0.05}
  82. onChange={(v) => update({ wRoi: v })}
  83. width={120}
  84. />
  85. </RankingRow>
  86. )}
  87. {/* 视频模态补充:ROV 归一化 + 解构加权 */}
  88. {showVideoExtra && (
  89. <RankingRow label={showMaterialQuality ? '视频维度' : '质量分'}>
  90. <RovClipInput
  91. label="ROV下界"
  92. value={params.rovClipLow}
  93. onChange={(v) => update({ rovClipLow: v })}
  94. />
  95. <RovClipInput
  96. label="ROV上界"
  97. value={params.rovClipHigh}
  98. onChange={(v) => update({ rovClipHigh: v })}
  99. />
  100. <InlineSlider
  101. label="解构加权 c"
  102. value={params.deconstructBoost}
  103. min={0.5}
  104. max={3}
  105. step={0.05}
  106. onChange={(v) => update({ deconstructBoost: v })}
  107. width={100}
  108. />
  109. </RankingRow>
  110. )}
  111. </div>
  112. )
  113. if (!collapsible) {
  114. return <div>{controls}</div>
  115. }
  116. return (
  117. <div
  118. style={{
  119. marginBottom: 8,
  120. padding: open ? '8px 12px' : '4px 12px',
  121. background: '#fafafa',
  122. border: '1px solid #d9d9d9',
  123. borderRadius: 4,
  124. }}
  125. >
  126. <div
  127. style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
  128. onClick={() => setOpen(!open)}
  129. >
  130. <Text style={{ fontSize: 12, fontWeight: 500 }}>{open ? '▼' : '▶'} 精排权重</Text>
  131. <Text type="secondary" style={{ fontSize: 11 }}>
  132. α={params.alpha} | sim≥{params.simThreshold}
  133. {showMaterialQuality
  134. ? ` | wCtr=${params.wCtr} wViral=${params.wViral} wRoi=${params.wRoi}`
  135. : ''}
  136. {showVideoExtra
  137. ? ` | c=${params.deconstructBoost} ROV=[${params.rovClipLow},${params.rovClipHigh}]`
  138. : ''}
  139. </Text>
  140. <Button
  141. size="small"
  142. style={{ marginLeft: 'auto' }}
  143. onClick={(e) => {
  144. e.stopPropagation()
  145. onChange(DEFAULT_RANKING_PARAMS)
  146. }}
  147. >
  148. 重置
  149. </Button>
  150. </div>
  151. {open && activeModality !== 'ARTICLE' && controls}
  152. </div>
  153. )
  154. }
  155. function RankingRow({ label, children }: { label: string; children: React.ReactNode }) {
  156. return (
  157. <div
  158. style={{
  159. display: 'flex',
  160. gap: 12,
  161. alignItems: 'center',
  162. minHeight: CONTROL_ROW_HEIGHT,
  163. marginBottom: 10,
  164. }}
  165. >
  166. <Text
  167. style={{
  168. fontSize: 12,
  169. color: '#666',
  170. width: ROW_LABEL_WIDTH,
  171. flexShrink: 0,
  172. lineHeight: `${CONTROL_ROW_HEIGHT}px`,
  173. }}
  174. >
  175. {label}
  176. </Text>
  177. <div
  178. style={{
  179. flex: 1,
  180. display: 'flex',
  181. gap: 20,
  182. flexWrap: 'wrap',
  183. alignItems: 'center',
  184. minHeight: CONTROL_ROW_HEIGHT,
  185. }}
  186. >
  187. {children}
  188. </div>
  189. </div>
  190. )
  191. }
  192. function RovClipInput({
  193. label,
  194. value,
  195. onChange,
  196. min = 0,
  197. max = 1,
  198. step = 0.001,
  199. }: {
  200. label: string
  201. value: number
  202. onChange: (v: number) => void
  203. min?: number
  204. max?: number
  205. step?: number
  206. }) {
  207. return (
  208. <div style={{ display: 'flex', alignItems: 'center', gap: 6, height: CONTROL_ROW_HEIGHT }}>
  209. <Text style={{ fontSize: 11, whiteSpace: 'nowrap', lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
  210. <InputNumber
  211. size="small"
  212. min={min}
  213. max={max}
  214. step={step}
  215. value={value}
  216. onChange={(v) => onChange(typeof v === 'number' ? v : 0)}
  217. style={{ width: 88 }}
  218. />
  219. </div>
  220. )
  221. }
  222. function InlineSlider({
  223. label,
  224. value,
  225. min,
  226. max,
  227. step,
  228. onChange,
  229. width = 100,
  230. hideLabel = false,
  231. }: {
  232. label: string
  233. value: number
  234. min: number
  235. max: number
  236. step: number
  237. onChange: (v: number) => void
  238. width?: number
  239. hideLabel?: boolean
  240. }) {
  241. return (
  242. <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: hideLabel ? 160 : 200, height: CONTROL_ROW_HEIGHT }}>
  243. {!hideLabel && (
  244. <Text style={{ fontSize: 11, whiteSpace: 'nowrap', minWidth: 72, lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
  245. )}
  246. <Slider
  247. min={min}
  248. max={max}
  249. step={step}
  250. value={value}
  251. onChange={onChange}
  252. style={{ width, margin: 0, flex: 1 }}
  253. tooltip={{ formatter: (v) => v?.toFixed(2) ?? '' }}
  254. />
  255. <Text style={{ fontSize: 11, width: 36, textAlign: 'right' }}>{value.toFixed(2)}</Text>
  256. </div>
  257. )
  258. }