Pārlūkot izejas kodu

feat: 召回结果表格加筛选 — 维度多选 + 相似度/PV/回流/ROV 大于阈值

- 召回维度列: 多选 Checkbox(选项由当前结果中实际命中的 configCode 派生)
- 向量相似度 / 分发曝光pv / 总回流 / ROV 列: InputNumber 阈值,严格"大于 X"
- 多条件 AND 组合; 顶部蓝色筛选条显示生效条件 + 可逐条关闭 + "清除全部筛选"
- 分发曝光pv / 总回流 改为千分位整数显示(去 K/M),便于和阈值对照
- 移除不再使用的 formatCount; 新增 formatNumber / parseNum 工具

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 2 dienas atpakaļ
vecāks
revīzija
f12a292f8d
2 mainītis faili ar 291 papildinājumiem un 25 dzēšanām
  1. 281 19
      src/components/RecallResultTable.tsx
  2. 10 6
      src/utils/format.ts

+ 281 - 19
src/components/RecallResultTable.tsx

@@ -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>
   )
 }
 

+ 10 - 6
src/utils/format.ts

@@ -13,15 +13,19 @@ export function formatRatio(s: string | undefined | null): string {
   return n.toFixed(4)
 }
 
-/** 量级数值 → K/M 缩写 (614275 → 614.3K, 1925000 → 1.93M) */
-export function formatCount(s: string | undefined | null): string {
+/** 整数(含千分位) — 614275 → "614,275"; 非数值 → -- */
+export function formatNumber(s: string | undefined | null): string {
   if (s == null || s === '' || s === PLACEHOLDER) return PLACEHOLDER
   const n = Number(s)
   if (!Number.isFinite(n)) return PLACEHOLDER
-  if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M'
-  if (n >= 10_000) return (n / 1000).toFixed(1) + 'K'
-  if (n >= 1000) return (n / 1000).toFixed(2) + 'K'
-  return String(n)
+  return Math.round(n).toLocaleString('en-US')
+}
+
+/** 字符串/数字 → number; 非数值或空 → null */
+export function parseNum(s: string | number | undefined | null): number | null {
+  if (s == null || s === '') return null
+  const n = typeof s === 'number' ? s : Number(s)
+  return Number.isFinite(n) ? n : null
 }
 
 /** "20250622" → "2025-06-22"; 形态不对返回原值或 -- */