| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- import { useState } from 'react'
- import { Button, InputNumber, Slider, Typography } from 'antd'
- import type { Modality } from '../api/types'
- import {
- DEFAULT_RANKING_PARAMS,
- type RankingParams,
- } from '../utils/scoring'
- const { Text } = Typography
- type ActiveModality = 'ALL' | Modality
- interface Props {
- params: RankingParams
- onChange: (next: RankingParams) => void
- activeModality: ActiveModality
- collapsible?: boolean
- }
- const ROW_LABEL_WIDTH = 72
- const CONTROL_ROW_HEIGHT = 32
- export default function RankingWeightsPanel({
- params,
- onChange,
- activeModality,
- collapsible = false,
- }: Props) {
- const [open, setOpen] = useState(true)
- const update = (patch: Partial<RankingParams>) => onChange({ ...params, ...patch })
- const showMaterialQuality = activeModality === 'MATERIAL' || activeModality === 'ALL'
- const showArticleQuality = activeModality === 'ARTICLE' || activeModality === 'ALL'
- const showVideoExtra = activeModality === 'VIDEO' || activeModality === 'ALL'
- const controls = (
- <div style={{ paddingTop: collapsible && open ? 8 : 0 }}>
- {/* 第一行:α */}
- <RankingRow label="α 值">
- <InlineSlider
- hideLabel
- label="α 值"
- value={params.alpha}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ alpha: v })}
- width={160}
- />
- </RankingRow>
- {/* 第二行:相关性阈值 */}
- <RankingRow label="相关性阈值">
- <InlineSlider
- label="sim 阈值"
- value={params.simThreshold}
- min={0}
- max={1}
- step={0.01}
- onChange={(v) => update({ simThreshold: v })}
- width={160}
- />
- </RankingRow>
- {/* 第三行:质量分权重 */}
- {showMaterialQuality && (
- <RankingRow label="素材质量">
- <InlineSlider
- label="CTR wCtr"
- value={params.wCtr}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wCtr: v })}
- width={120}
- />
- <InlineSlider
- label="CVR wCvr"
- value={params.wCvr}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wCvr: v })}
- width={120}
- />
- <InlineSlider
- label="ROI wRoi"
- value={params.wRoi}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wRoi: v })}
- width={120}
- />
- <InlineSlider
- label="打开率 wOpenRate"
- value={params.wOpenRate}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wOpenRate: v })}
- width={120}
- />
- <InlineSlider
- label="裂变率 wFissionRate"
- value={params.wFissionRate}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wFissionRate: v })}
- width={120}
- />
- </RankingRow>
- )}
- {showArticleQuality && (
- <RankingRow label="文章质量">
- <InlineSlider
- label="阅读 wRead"
- value={params.wRead}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wRead: v })}
- width={120}
- />
- <InlineSlider
- label="打开率 wOpen"
- value={params.wOpen}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wOpen: v })}
- width={120}
- />
- <InlineSlider
- label="裂变率 wFission"
- value={params.wFission}
- min={0}
- max={1}
- step={0.05}
- onChange={(v) => update({ wFission: v })}
- width={120}
- />
- </RankingRow>
- )}
- {/* 视频模态补充:ROV 归一化 */}
- {showVideoExtra && (
- <RankingRow label={showMaterialQuality ? '视频维度' : '质量分'}>
- <RovClipInput
- label="ROV下界"
- value={params.rovClipLow}
- onChange={(v) => update({ rovClipLow: v })}
- />
- <RovClipInput
- label="ROV上界"
- value={params.rovClipHigh}
- onChange={(v) => update({ rovClipHigh: v })}
- />
- </RankingRow>
- )}
- </div>
- )
- if (!collapsible) {
- return <div>{controls}</div>
- }
- return (
- <div
- style={{
- marginBottom: 8,
- padding: open ? '8px 12px' : '4px 12px',
- background: '#fafafa',
- border: '1px solid #d9d9d9',
- borderRadius: 4,
- }}
- >
- <div
- style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
- onClick={() => setOpen(!open)}
- >
- <Text style={{ fontSize: 12, fontWeight: 500 }}>{open ? '▼' : '▶'} 精排权重</Text>
- <Text type="secondary" style={{ fontSize: 11 }}>
- α={params.alpha} | sim≥{params.simThreshold}
- {showMaterialQuality
- ? ` | 素材: ctr=${params.wCtr} cvr=${params.wCvr} roi=${params.wRoi} open=${params.wOpenRate} fission=${params.wFissionRate}`
- : ''}
- {showArticleQuality
- ? ` | 文章: read=${params.wRead} open=${params.wOpen} fission=${params.wFission}`
- : ''}
- {showVideoExtra
- ? ` | ROV=[${params.rovClipLow},${params.rovClipHigh}]`
- : ''}
- </Text>
- <Button
- size="small"
- style={{ marginLeft: 'auto' }}
- onClick={(e) => {
- e.stopPropagation()
- onChange(DEFAULT_RANKING_PARAMS)
- }}
- >
- 重置
- </Button>
- </div>
- {open && controls}
- </div>
- )
- }
- function RankingRow({ label, children }: { label: string; children: React.ReactNode }) {
- return (
- <div
- style={{
- display: 'flex',
- gap: 12,
- alignItems: 'center',
- minHeight: CONTROL_ROW_HEIGHT,
- marginBottom: 10,
- }}
- >
- <Text
- style={{
- fontSize: 12,
- color: '#666',
- width: ROW_LABEL_WIDTH,
- flexShrink: 0,
- lineHeight: `${CONTROL_ROW_HEIGHT}px`,
- }}
- >
- {label}
- </Text>
- <div
- style={{
- flex: 1,
- display: 'flex',
- gap: 20,
- flexWrap: 'wrap',
- alignItems: 'center',
- minHeight: CONTROL_ROW_HEIGHT,
- }}
- >
- {children}
- </div>
- </div>
- )
- }
- function RovClipInput({
- label,
- value,
- onChange,
- min = 0,
- max = 1,
- step = 0.001,
- }: {
- label: string
- value: number
- onChange: (v: number) => void
- min?: number
- max?: number
- step?: number
- }) {
- return (
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, height: CONTROL_ROW_HEIGHT }}>
- <Text style={{ fontSize: 11, whiteSpace: 'nowrap', lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
- <InputNumber
- size="small"
- min={min}
- max={max}
- step={step}
- value={value}
- onChange={(v) => onChange(typeof v === 'number' ? v : 0)}
- style={{ width: 88 }}
- />
- </div>
- )
- }
- function InlineSlider({
- label,
- value,
- min,
- max,
- step,
- onChange,
- width = 100,
- hideLabel = false,
- }: {
- label: string
- value: number
- min: number
- max: number
- step: number
- onChange: (v: number) => void
- width?: number
- hideLabel?: boolean
- }) {
- return (
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: hideLabel ? 160 : 200, height: CONTROL_ROW_HEIGHT }}>
- {!hideLabel && (
- <Text style={{ fontSize: 11, whiteSpace: 'nowrap', minWidth: 72, lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
- )}
- <Slider
- min={min}
- max={max}
- step={step}
- value={value}
- onChange={onChange}
- style={{ width, margin: 0, flex: 1 }}
- tooltip={{ formatter: (v) => v?.toFixed(2) ?? '' }}
- />
- <Text style={{ fontSize: 11, width: 36, textAlign: 'right' }}>{value.toFixed(2)}</Text>
- </div>
- )
- }
|