RankingWeightsPanel.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  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 showArticleQuality = activeModality === 'ARTICLE' || activeModality === 'ALL'
  28. const showVideoExtra = activeModality === 'VIDEO' || activeModality === 'ALL'
  29. const controls = (
  30. <div style={{ paddingTop: collapsible && open ? 8 : 0 }}>
  31. {/* 第一行:α */}
  32. <RankingRow label="α 值">
  33. <InlineSlider
  34. hideLabel
  35. label="α 值"
  36. value={params.alpha}
  37. min={0}
  38. max={1}
  39. step={0.05}
  40. onChange={(v) => update({ alpha: v })}
  41. width={160}
  42. />
  43. </RankingRow>
  44. {/* 第二行:相关性阈值 */}
  45. <RankingRow label="相关性阈值">
  46. <InlineSlider
  47. label="sim 阈值"
  48. value={params.simThreshold}
  49. min={0}
  50. max={1}
  51. step={0.01}
  52. onChange={(v) => update({ simThreshold: v })}
  53. width={160}
  54. />
  55. </RankingRow>
  56. {/* 第三行:质量分权重 */}
  57. {showMaterialQuality && (
  58. <RankingRow label="素材质量">
  59. <InlineSlider
  60. label="CTR wCtr"
  61. value={params.wCtr}
  62. min={0}
  63. max={1}
  64. step={0.05}
  65. onChange={(v) => update({ wCtr: v })}
  66. width={120}
  67. />
  68. <InlineSlider
  69. label="CVR wCvr"
  70. value={params.wCvr}
  71. min={0}
  72. max={1}
  73. step={0.05}
  74. onChange={(v) => update({ wCvr: v })}
  75. width={120}
  76. />
  77. <InlineSlider
  78. label="ROI wRoi"
  79. value={params.wRoi}
  80. min={0}
  81. max={1}
  82. step={0.05}
  83. onChange={(v) => update({ wRoi: v })}
  84. width={120}
  85. />
  86. <InlineSlider
  87. label="打开率 wOpenRate"
  88. value={params.wOpenRate}
  89. min={0}
  90. max={1}
  91. step={0.05}
  92. onChange={(v) => update({ wOpenRate: v })}
  93. width={120}
  94. />
  95. <InlineSlider
  96. label="裂变率 wFissionRate"
  97. value={params.wFissionRate}
  98. min={0}
  99. max={1}
  100. step={0.05}
  101. onChange={(v) => update({ wFissionRate: v })}
  102. width={120}
  103. />
  104. </RankingRow>
  105. )}
  106. {showArticleQuality && (
  107. <RankingRow label="文章质量">
  108. <InlineSlider
  109. label="阅读 wRead"
  110. value={params.wRead}
  111. min={0}
  112. max={1}
  113. step={0.05}
  114. onChange={(v) => update({ wRead: v })}
  115. width={120}
  116. />
  117. <InlineSlider
  118. label="打开率 wOpen"
  119. value={params.wOpen}
  120. min={0}
  121. max={1}
  122. step={0.05}
  123. onChange={(v) => update({ wOpen: v })}
  124. width={120}
  125. />
  126. <InlineSlider
  127. label="裂变率 wFission"
  128. value={params.wFission}
  129. min={0}
  130. max={1}
  131. step={0.05}
  132. onChange={(v) => update({ wFission: v })}
  133. width={120}
  134. />
  135. </RankingRow>
  136. )}
  137. {/* 视频模态补充:ROV 归一化 */}
  138. {showVideoExtra && (
  139. <RankingRow label={showMaterialQuality ? '视频维度' : '质量分'}>
  140. <RovClipInput
  141. label="ROV下界"
  142. value={params.rovClipLow}
  143. onChange={(v) => update({ rovClipLow: v })}
  144. />
  145. <RovClipInput
  146. label="ROV上界"
  147. value={params.rovClipHigh}
  148. onChange={(v) => update({ rovClipHigh: v })}
  149. />
  150. </RankingRow>
  151. )}
  152. </div>
  153. )
  154. if (!collapsible) {
  155. return <div>{controls}</div>
  156. }
  157. return (
  158. <div
  159. style={{
  160. marginBottom: 8,
  161. padding: open ? '8px 12px' : '4px 12px',
  162. background: '#fafafa',
  163. border: '1px solid #d9d9d9',
  164. borderRadius: 4,
  165. }}
  166. >
  167. <div
  168. style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
  169. onClick={() => setOpen(!open)}
  170. >
  171. <Text style={{ fontSize: 12, fontWeight: 500 }}>{open ? '▼' : '▶'} 精排权重</Text>
  172. <Text type="secondary" style={{ fontSize: 11 }}>
  173. α={params.alpha} | sim≥{params.simThreshold}
  174. {showMaterialQuality
  175. ? ` | 素材: ctr=${params.wCtr} cvr=${params.wCvr} roi=${params.wRoi} open=${params.wOpenRate} fission=${params.wFissionRate}`
  176. : ''}
  177. {showArticleQuality
  178. ? ` | 文章: read=${params.wRead} open=${params.wOpen} fission=${params.wFission}`
  179. : ''}
  180. {showVideoExtra
  181. ? ` | ROV=[${params.rovClipLow},${params.rovClipHigh}]`
  182. : ''}
  183. </Text>
  184. <Button
  185. size="small"
  186. style={{ marginLeft: 'auto' }}
  187. onClick={(e) => {
  188. e.stopPropagation()
  189. onChange(DEFAULT_RANKING_PARAMS)
  190. }}
  191. >
  192. 重置
  193. </Button>
  194. </div>
  195. {open && controls}
  196. </div>
  197. )
  198. }
  199. function RankingRow({ label, children }: { label: string; children: React.ReactNode }) {
  200. return (
  201. <div
  202. style={{
  203. display: 'flex',
  204. gap: 12,
  205. alignItems: 'center',
  206. minHeight: CONTROL_ROW_HEIGHT,
  207. marginBottom: 10,
  208. }}
  209. >
  210. <Text
  211. style={{
  212. fontSize: 12,
  213. color: '#666',
  214. width: ROW_LABEL_WIDTH,
  215. flexShrink: 0,
  216. lineHeight: `${CONTROL_ROW_HEIGHT}px`,
  217. }}
  218. >
  219. {label}
  220. </Text>
  221. <div
  222. style={{
  223. flex: 1,
  224. display: 'flex',
  225. gap: 20,
  226. flexWrap: 'wrap',
  227. alignItems: 'center',
  228. minHeight: CONTROL_ROW_HEIGHT,
  229. }}
  230. >
  231. {children}
  232. </div>
  233. </div>
  234. )
  235. }
  236. function RovClipInput({
  237. label,
  238. value,
  239. onChange,
  240. min = 0,
  241. max = 1,
  242. step = 0.001,
  243. }: {
  244. label: string
  245. value: number
  246. onChange: (v: number) => void
  247. min?: number
  248. max?: number
  249. step?: number
  250. }) {
  251. return (
  252. <div style={{ display: 'flex', alignItems: 'center', gap: 6, height: CONTROL_ROW_HEIGHT }}>
  253. <Text style={{ fontSize: 11, whiteSpace: 'nowrap', lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
  254. <InputNumber
  255. size="small"
  256. min={min}
  257. max={max}
  258. step={step}
  259. value={value}
  260. onChange={(v) => onChange(typeof v === 'number' ? v : 0)}
  261. style={{ width: 88 }}
  262. />
  263. </div>
  264. )
  265. }
  266. function InlineSlider({
  267. label,
  268. value,
  269. min,
  270. max,
  271. step,
  272. onChange,
  273. width = 100,
  274. hideLabel = false,
  275. }: {
  276. label: string
  277. value: number
  278. min: number
  279. max: number
  280. step: number
  281. onChange: (v: number) => void
  282. width?: number
  283. hideLabel?: boolean
  284. }) {
  285. return (
  286. <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: hideLabel ? 160 : 200, height: CONTROL_ROW_HEIGHT }}>
  287. {!hideLabel && (
  288. <Text style={{ fontSize: 11, whiteSpace: 'nowrap', minWidth: 72, lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
  289. )}
  290. <Slider
  291. min={min}
  292. max={max}
  293. step={step}
  294. value={value}
  295. onChange={onChange}
  296. style={{ width, margin: 0, flex: 1 }}
  297. tooltip={{ formatter: (v) => v?.toFixed(2) ?? '' }}
  298. />
  299. <Text style={{ fontSize: 11, width: 36, textAlign: 'right' }}>{value.toFixed(2)}</Text>
  300. </div>
  301. )
  302. }