|
|
@@ -1,17 +1,48 @@
|
|
|
-import { Table, Tag, Typography, Tooltip, Space, Empty } from 'antd'
|
|
|
-import { PlayCircleFilled } from '@ant-design/icons'
|
|
|
+import { useMemo, useState } from 'react'
|
|
|
+import {
|
|
|
+ Table,
|
|
|
+ Tag,
|
|
|
+ Typography,
|
|
|
+ Tooltip,
|
|
|
+ Space,
|
|
|
+ Empty,
|
|
|
+ InputNumber,
|
|
|
+ Button,
|
|
|
+ Checkbox,
|
|
|
+} from 'antd'
|
|
|
+import { PlayCircleFilled, FilterFilled } from '@ant-design/icons'
|
|
|
import type { ColumnsType } from 'antd/es/table'
|
|
|
import type { EssenceWord, VideoMatchEnrichedVO } from '../api/types'
|
|
|
import { useConfigCodes, getConfigDisplayLabel } from '../api/configCodes'
|
|
|
import { toHttps } from '../utils/url'
|
|
|
import {
|
|
|
- formatCount,
|
|
|
+ formatNumber,
|
|
|
formatRatio,
|
|
|
getScoreStyle,
|
|
|
+ parseNum,
|
|
|
} from '../utils/format'
|
|
|
|
|
|
const { Text, Paragraph } = Typography
|
|
|
|
|
|
+interface Filters {
|
|
|
+ score: number | null
|
|
|
+ configCodes: string[]
|
|
|
+ pv: number | null
|
|
|
+ hl: number | null
|
|
|
+ rov: number | null
|
|
|
+}
|
|
|
+
|
|
|
+const EMPTY_FILTERS: Filters = {
|
|
|
+ score: null,
|
|
|
+ configCodes: [],
|
|
|
+ pv: null,
|
|
|
+ hl: null,
|
|
|
+ rov: null,
|
|
|
+}
|
|
|
+
|
|
|
+const ACTIVE_ICON_COLOR = '#1677ff'
|
|
|
+const IDLE_ICON_COLOR = '#bfbfbf'
|
|
|
+
|
|
|
/** 指标类列(分发曝光pv / 总回流 / ROV)用 amber 色调与解构/旧字段区分 */
|
|
|
const METRIC_HEADER_STYLE: React.CSSProperties = {
|
|
|
background: '#fff7e6',
|
|
|
@@ -32,11 +63,101 @@ interface Props {
|
|
|
*/
|
|
|
export default function RecallResultTable({ items }: Props) {
|
|
|
const configCodes = useConfigCodes()
|
|
|
+ const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
|
|
|
+
|
|
|
+ const codeOptions = useMemo(() => {
|
|
|
+ const set = new Set<string>()
|
|
|
+ items.forEach((it) => {
|
|
|
+ if (it.configCode) set.add(it.configCode)
|
|
|
+ })
|
|
|
+ return Array.from(set).map((code) => ({
|
|
|
+ label: getConfigDisplayLabel(code, configCodes),
|
|
|
+ value: code,
|
|
|
+ }))
|
|
|
+ }, [items, configCodes])
|
|
|
+
|
|
|
+ const filteredItems = useMemo(() => {
|
|
|
+ return items.filter((it) => {
|
|
|
+ if (filters.score != null) {
|
|
|
+ if (it.score == null || it.score <= filters.score) return false
|
|
|
+ }
|
|
|
+ if (filters.configCodes.length > 0) {
|
|
|
+ if (!it.configCode || !filters.configCodes.includes(it.configCode)) return false
|
|
|
+ }
|
|
|
+ if (filters.pv != null) {
|
|
|
+ const v = parseNum(it.videoDetail?.['分发曝光pv'])
|
|
|
+ if (v == null || v <= filters.pv) return false
|
|
|
+ }
|
|
|
+ if (filters.hl != null) {
|
|
|
+ const v = parseNum(it.videoDetail?.['总回流'])
|
|
|
+ if (v == null || v <= filters.hl) return false
|
|
|
+ }
|
|
|
+ if (filters.rov != null) {
|
|
|
+ const v = parseNum(it.videoDetail?.rov)
|
|
|
+ if (v == null || v <= filters.rov) return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ })
|
|
|
+ }, [items, filters])
|
|
|
+
|
|
|
+ const hasFilter =
|
|
|
+ filters.score != null ||
|
|
|
+ filters.configCodes.length > 0 ||
|
|
|
+ filters.pv != null ||
|
|
|
+ filters.hl != null ||
|
|
|
+ filters.rov != null
|
|
|
|
|
|
if (!items || items.length === 0) {
|
|
|
return <Empty description="该模态下无召回结果" />
|
|
|
}
|
|
|
|
|
|
+ const thresholdDropdown = (
|
|
|
+ field: 'score' | 'pv' | 'hl' | 'rov',
|
|
|
+ placeholder: string,
|
|
|
+ step?: number,
|
|
|
+ ) =>
|
|
|
+ function ThresholdDropdown({ confirm }: { confirm: (p?: { closeDropdown: boolean }) => void }) {
|
|
|
+ return (
|
|
|
+ <div style={{ padding: 8, minWidth: 180 }} onKeyDown={(e) => e.stopPropagation()}>
|
|
|
+ <InputNumber
|
|
|
+ autoFocus
|
|
|
+ placeholder={placeholder}
|
|
|
+ value={filters[field] ?? undefined}
|
|
|
+ step={step}
|
|
|
+ onChange={(v) =>
|
|
|
+ setFilters((f) => ({ ...f, [field]: typeof v === 'number' ? v : null }))
|
|
|
+ }
|
|
|
+ onPressEnter={() => confirm({ closeDropdown: true })}
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ marginTop: 8,
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'space-between',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ onClick={() => {
|
|
|
+ setFilters((f) => ({ ...f, [field]: null }))
|
|
|
+ confirm({ closeDropdown: true })
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 清除
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ onClick={() => confirm({ closeDropdown: true })}
|
|
|
+ >
|
|
|
+ 确定
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
const columns: ColumnsType<VideoMatchEnrichedVO> = [
|
|
|
{
|
|
|
title: '标题',
|
|
|
@@ -55,8 +176,58 @@ export default function RecallResultTable({ items }: Props) {
|
|
|
{
|
|
|
title: '召回维度',
|
|
|
key: 'configCode',
|
|
|
- width: 110,
|
|
|
+ width: 130,
|
|
|
fixed: 'left',
|
|
|
+ filterIcon: () => (
|
|
|
+ <FilterFilled
|
|
|
+ style={{
|
|
|
+ color: filters.configCodes.length > 0 ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ filterDropdown: ({ confirm }) => (
|
|
|
+ <div style={{ padding: 8, minWidth: 180 }}>
|
|
|
+ {codeOptions.length === 0 ? (
|
|
|
+ <Text type="secondary" style={{ fontSize: 12 }}>
|
|
|
+ 无可选维度
|
|
|
+ </Text>
|
|
|
+ ) : (
|
|
|
+ <Checkbox.Group
|
|
|
+ value={filters.configCodes}
|
|
|
+ onChange={(vals) => setFilters((f) => ({ ...f, configCodes: vals as string[] }))}
|
|
|
+ style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 280, overflowY: 'auto' }}
|
|
|
+ >
|
|
|
+ {codeOptions.map((opt) => (
|
|
|
+ <Checkbox key={opt.value} value={opt.value}>
|
|
|
+ {opt.label}
|
|
|
+ </Checkbox>
|
|
|
+ ))}
|
|
|
+ </Checkbox.Group>
|
|
|
+ )}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ marginTop: 8,
|
|
|
+ paddingTop: 8,
|
|
|
+ borderTop: '1px solid #f0f0f0',
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'space-between',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ onClick={() => {
|
|
|
+ setFilters((f) => ({ ...f, configCodes: [] }))
|
|
|
+ confirm({ closeDropdown: true })
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 清除
|
|
|
+ </Button>
|
|
|
+ <Button size="small" type="primary" onClick={() => confirm({ closeDropdown: true })}>
|
|
|
+ 确定
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
render: (_v, item) => {
|
|
|
if (!item.configCode) return <Text type="secondary">--</Text>
|
|
|
const label = getConfigDisplayLabel(item.configCode, configCodes)
|
|
|
@@ -72,9 +243,15 @@ export default function RecallResultTable({ items }: Props) {
|
|
|
{
|
|
|
title: '向量相似度',
|
|
|
key: 'score',
|
|
|
- width: 100,
|
|
|
+ width: 130,
|
|
|
align: 'center',
|
|
|
fixed: 'left',
|
|
|
+ filterIcon: () => (
|
|
|
+ <FilterFilled
|
|
|
+ style={{ color: filters.score != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ filterDropdown: thresholdDropdown('score', '相似度大于', 0.01),
|
|
|
render: (_v, item) => <ScoreCell score={item.score} />,
|
|
|
},
|
|
|
deconstructTopicCol(280),
|
|
|
@@ -87,28 +264,54 @@ export default function RecallResultTable({ items }: Props) {
|
|
|
{
|
|
|
title: '分发曝光pv',
|
|
|
key: '分发曝光pv',
|
|
|
- width: 110,
|
|
|
+ width: 140,
|
|
|
align: 'right',
|
|
|
onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
|
|
|
onCell: () => ({ style: METRIC_CELL_STYLE }),
|
|
|
- render: (_v, item) => formatCount(item.videoDetail?.['分发曝光pv']),
|
|
|
+ filterIcon: () => (
|
|
|
+ <FilterFilled
|
|
|
+ style={{ color: filters.pv != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ filterDropdown: thresholdDropdown('pv', '分发曝光pv大于', 1000),
|
|
|
+ render: (_v, item) => (
|
|
|
+ <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
|
|
|
+ {formatNumber(item.videoDetail?.['分发曝光pv'])}
|
|
|
+ </span>
|
|
|
+ ),
|
|
|
},
|
|
|
{
|
|
|
title: '总回流',
|
|
|
key: '总回流',
|
|
|
- width: 100,
|
|
|
+ width: 130,
|
|
|
align: 'right',
|
|
|
onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
|
|
|
onCell: () => ({ style: METRIC_CELL_STYLE }),
|
|
|
- render: (_v, item) => formatCount(item.videoDetail?.['总回流']),
|
|
|
+ filterIcon: () => (
|
|
|
+ <FilterFilled
|
|
|
+ style={{ color: filters.hl != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ filterDropdown: thresholdDropdown('hl', '总回流大于', 1000),
|
|
|
+ render: (_v, item) => (
|
|
|
+ <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
|
|
|
+ {formatNumber(item.videoDetail?.['总回流'])}
|
|
|
+ </span>
|
|
|
+ ),
|
|
|
},
|
|
|
{
|
|
|
title: 'ROV',
|
|
|
key: 'rov',
|
|
|
- width: 90,
|
|
|
+ width: 110,
|
|
|
align: 'right',
|
|
|
onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
|
|
|
onCell: () => ({ style: METRIC_CELL_STYLE }),
|
|
|
+ filterIcon: () => (
|
|
|
+ <FilterFilled
|
|
|
+ style={{ color: filters.rov != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ filterDropdown: thresholdDropdown('rov', 'ROV大于', 0.01),
|
|
|
render: (_v, item) => (
|
|
|
<span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
|
|
|
{formatRatio(item.videoDetail?.rov)}
|
|
|
@@ -118,15 +321,74 @@ export default function RecallResultTable({ items }: Props) {
|
|
|
]
|
|
|
|
|
|
return (
|
|
|
- <Table<VideoMatchEnrichedVO>
|
|
|
- size="small"
|
|
|
- bordered
|
|
|
- rowKey={(r) => `${r.modality}-${r.id}-${r.configCode ?? ''}`}
|
|
|
- dataSource={items}
|
|
|
- columns={columns}
|
|
|
- pagination={false}
|
|
|
- scroll={{ x: 2200 }}
|
|
|
- />
|
|
|
+ <div>
|
|
|
+ {hasFilter && (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ marginBottom: 8,
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: 12,
|
|
|
+ padding: '6px 10px',
|
|
|
+ background: '#f0f5ff',
|
|
|
+ border: '1px solid #adc6ff',
|
|
|
+ borderRadius: 4,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Text style={{ fontSize: 12 }}>
|
|
|
+ 已应用筛选: 显示 <b>{filteredItems.length}</b> / {items.length}
|
|
|
+ </Text>
|
|
|
+ <ActiveFilterTags
|
|
|
+ filters={filters}
|
|
|
+ configCodes={configCodes}
|
|
|
+ onClear={(field) =>
|
|
|
+ setFilters((f) => ({ ...f, [field]: field === 'configCodes' ? [] : null }))
|
|
|
+ }
|
|
|
+ />
|
|
|
+ <Button size="small" onClick={() => setFilters(EMPTY_FILTERS)}>
|
|
|
+ 清除全部筛选
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <Table<VideoMatchEnrichedVO>
|
|
|
+ size="small"
|
|
|
+ bordered
|
|
|
+ rowKey={(r) => `${r.modality}-${r.id}-${r.configCode ?? ''}`}
|
|
|
+ dataSource={filteredItems}
|
|
|
+ columns={columns}
|
|
|
+ pagination={false}
|
|
|
+ scroll={{ x: 2300 }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function ActiveFilterTags({
|
|
|
+ filters,
|
|
|
+ configCodes,
|
|
|
+ onClear,
|
|
|
+}: {
|
|
|
+ filters: Filters
|
|
|
+ configCodes: Record<string, string>
|
|
|
+ onClear: (field: keyof Filters) => void
|
|
|
+}) {
|
|
|
+ const tags: { key: keyof Filters; text: string }[] = []
|
|
|
+ if (filters.score != null) tags.push({ key: 'score', text: `相似度>${filters.score}` })
|
|
|
+ if (filters.configCodes.length > 0) {
|
|
|
+ const labels = filters.configCodes.map((c) => getConfigDisplayLabel(c, configCodes)).join('/')
|
|
|
+ tags.push({ key: 'configCodes', text: `维度: ${labels}` })
|
|
|
+ }
|
|
|
+ if (filters.pv != null) tags.push({ key: 'pv', text: `分发曝光pv>${filters.pv}` })
|
|
|
+ if (filters.hl != null) tags.push({ key: 'hl', text: `总回流>${filters.hl}` })
|
|
|
+ if (filters.rov != null) tags.push({ key: 'rov', text: `ROV>${filters.rov}` })
|
|
|
+ return (
|
|
|
+ <Space size={[4, 4]} wrap style={{ flex: 1 }}>
|
|
|
+ {tags.map((t) => (
|
|
|
+ <Tag key={t.key} closable color="blue" onClose={() => onClear(t.key)}>
|
|
|
+ {t.text}
|
|
|
+ </Tag>
|
|
|
+ ))}
|
|
|
+ </Space>
|
|
|
)
|
|
|
}
|
|
|
|