| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- import { useMemo } from 'react'
- import {
- Button,
- Popover,
- Slider,
- InputNumber,
- Space,
- Typography,
- Divider,
- Collapse,
- Tooltip,
- } from 'antd'
- import { SettingOutlined, QuestionCircleOutlined, ReloadOutlined } from '@ant-design/icons'
- import {
- DEFAULT_RANKING_PARAMS,
- type RankingParams,
- } from '../utils/scoring'
- import { getConfigDisplayLabel } from '../api/configCodes'
- const { Text } = Typography
- interface Props {
- params: RankingParams
- onChange: (next: RankingParams) => void
- /** 当前结果集中出现过的 configCode 列表, 用于 "按维度阈值覆盖" 折叠面板 */
- configCodesInResult: string[]
- /** 后端字典 (configCode → 中文标签) */
- configCodes: Record<string, string>
- }
- const FORMULA_TEXT = `综合得分 = c × (α × sim_norm + (1-α) × rov_norm)
- sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
- rov_norm = clip((rov - rovP5) / (rovP95 - rovP5), 0, 1)
- c = deconstructBoost (选题/灵感点/关键点/目的点) / 1.0 (其他维度)
- 先按 simThreshold 硬筛 (sim < 阈值剔除), 再按综合分排序.`
- export default function RankingSettingsButton({
- params,
- onChange,
- configCodesInResult,
- configCodes,
- }: Props) {
- const update = (patch: Partial<RankingParams>) => onChange({ ...params, ...patch })
- const updateCodeThreshold = (code: string, value: number | null) => {
- const next = { ...params.simThresholdsByCode }
- if (value == null) delete next[code]
- else next[code] = value
- update({ simThresholdsByCode: next })
- }
- const codeRows = useMemo(
- () =>
- configCodesInResult.map((c) => ({
- code: c,
- label: getConfigDisplayLabel(c, configCodes),
- override: params.simThresholdsByCode[c],
- })),
- [configCodesInResult, configCodes, params.simThresholdsByCode],
- )
- const content = (
- <div style={{ width: 380 }}>
- <Space direction="vertical" size={12} style={{ width: '100%' }}>
- <Row label="相似度阈值 simThreshold" tip="低于此值的结果被剔除, 同时作为 sim_norm 的下界">
- <InputNumber
- min={0}
- max={1}
- step={0.01}
- value={params.simThreshold}
- onChange={(v) => update({ simThreshold: typeof v === 'number' ? v : 0 })}
- style={{ width: 120 }}
- />
- </Row>
- <Row label="α 相关性权重" tip="α 越大越看重相关性, 越小越看重 ROV 质量">
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
- <Slider
- min={0}
- max={1}
- step={0.05}
- value={params.alpha}
- onChange={(v) => update({ alpha: v })}
- style={{ flex: 1 }}
- />
- <InputNumber
- min={0}
- max={1}
- step={0.05}
- value={params.alpha}
- onChange={(v) => update({ alpha: typeof v === 'number' ? v : 0.6 })}
- style={{ width: 80 }}
- />
- </div>
- </Row>
- <Row label="ROV 归一化下界 rovP5">
- <InputNumber
- min={0}
- max={1}
- step={0.001}
- value={params.rovP5}
- onChange={(v) => update({ rovP5: typeof v === 'number' ? v : 0 })}
- style={{ width: 120 }}
- />
- </Row>
- <Row label="ROV 归一化上界 rovP95">
- <InputNumber
- min={0}
- max={1}
- step={0.001}
- value={params.rovP95}
- onChange={(v) => update({ rovP95: typeof v === 'number' ? v : 0.07 })}
- style={{ width: 120 }}
- />
- </Row>
- {/* 素材质量维度权重 */}
- <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>素材质量维度权重</Divider>
- <Row label="相似度 wSim" tip="素材模态下相似度的权重">
- <Slider
- min={0} max={1} step={0.05}
- value={params.wSim}
- onChange={(v) => update({ wSim: typeof v === 'number' ? v : 0.4 })}
- />
- <span style={{ width: 48, textAlign: 'right' }}>{params.wSim.toFixed(2)}</span>
- </Row>
- <Row label="打开率 wCtr" tip="CTR 百分位的权重">
- <Slider
- min={0} max={1} step={0.05}
- value={params.wCtr}
- onChange={(v) => update({ wCtr: typeof v === 'number' ? v : 0.3 })}
- />
- <span style={{ width: 48, textAlign: 'right' }}>{params.wCtr.toFixed(2)}</span>
- </Row>
- <Row label="裂变率 wViral" tip="裂变率百分位的权重">
- <Slider
- min={0} max={1} step={0.05}
- value={params.wViral}
- onChange={(v) => update({ wViral: typeof v === 'number' ? v : 0.2 })}
- />
- <span style={{ width: 48, textAlign: 'right' }}>{params.wViral.toFixed(2)}</span>
- </Row>
- <Row label="ROI wRoi" tip="ROI 百分位的权重">
- <Slider
- min={0} max={1} step={0.05}
- value={params.wRoi}
- onChange={(v) => update({ wRoi: typeof v === 'number' ? v : 0.1 })}
- />
- <span style={{ width: 48, textAlign: 'right' }}>{params.wRoi.toFixed(2)}</span>
- </Row>
- <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>视频维度</Divider>
- <Row
- label="解构加权 c"
- tip="选题 / 灵感点 / 关键点 / 目的点 维度的额外加权系数, 其他维度恒为 1"
- >
- <InputNumber
- min={0.5}
- max={3}
- step={0.05}
- value={params.deconstructBoost}
- onChange={(v) => update({ deconstructBoost: typeof v === 'number' ? v : 1.2 })}
- style={{ width: 120 }}
- />
- </Row>
- {codeRows.length > 0 && (
- <Collapse
- size="small"
- ghost
- items={[
- {
- key: 'override',
- label: (
- <Text style={{ fontSize: 12 }}>
- 按维度覆盖阈值 (
- {Object.keys(params.simThresholdsByCode).length} / {codeRows.length} 已设)
- </Text>
- ),
- children: (
- <Space direction="vertical" size={6} style={{ width: '100%' }}>
- {codeRows.map((r) => (
- <div
- key={r.code}
- style={{ display: 'flex', alignItems: 'center', gap: 8 }}
- >
- <Text style={{ flex: 1, fontSize: 12 }}>{r.label}</Text>
- <InputNumber
- size="small"
- min={0}
- max={1}
- step={0.01}
- placeholder={`默认 ${params.simThreshold}`}
- value={r.override}
- onChange={(v) =>
- updateCodeThreshold(r.code, typeof v === 'number' ? v : null)
- }
- style={{ width: 110 }}
- />
- </div>
- ))}
- </Space>
- ),
- },
- ]}
- />
- )}
- <Divider style={{ margin: '4px 0' }} />
- <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
- <Button
- size="small"
- icon={<ReloadOutlined />}
- onClick={() => onChange(DEFAULT_RANKING_PARAMS)}
- >
- 重置默认
- </Button>
- </div>
- </Space>
- </div>
- )
- const title = (
- <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
- <span>排序参数</span>
- <Tooltip
- overlayStyle={{ maxWidth: 480 }}
- title={<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 }}>{FORMULA_TEXT}</pre>}
- >
- <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
- </Tooltip>
- </div>
- )
- return (
- <Popover trigger="click" placement="bottomRight" title={title} content={content}>
- <Button icon={<SettingOutlined />}>排序参数</Button>
- </Popover>
- )
- }
- function Row({ label, tip, children }: { label: string; tip?: string; children: React.ReactNode }) {
- return (
- <div>
- <div style={{ marginBottom: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
- <Text style={{ fontSize: 12 }}>{label}</Text>
- {tip && (
- <Tooltip title={tip}>
- <QuestionCircleOutlined style={{ fontSize: 11, color: 'rgba(0,0,0,0.45)' }} />
- </Tooltip>
- )}
- </div>
- {children}
- </div>
- )
- }
|