| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014 |
- import { useMemo, useState } from 'react'
- import {
- Table,
- Tag,
- Typography,
- Tooltip,
- Space,
- Empty,
- InputNumber,
- Button,
- Checkbox,
- } from 'antd'
- import {
- PlayCircleFilled,
- FilterFilled,
- QuestionCircleOutlined,
- } from '@ant-design/icons'
- import type { ColumnsType, SorterResult } from 'antd/es/table/interface'
- import type {
- EssenceWord,
- Modality,
- VideoDetailDeconstruct,
- VideoMatchEnrichedVO,
- } from '../api/types'
- import { useConfigCodes, getConfigDisplayLabel } from '../api/configCodes'
- import { toHttps } from '../utils/url'
- import {
- formatNumber,
- formatRatio,
- getScoreStyle,
- parseNum,
- } from '../utils/format'
- import {
- computeCompositeScore,
- type RankingParams,
- type ScoreBreakdown,
- } from '../utils/scoring'
- const { Text, Paragraph } = Typography
- interface Filters {
- configCodes: string[]
- pv: number | null
- hl: number | null
- rov: number | null
- }
- const EMPTY_FILTERS: Filters = {
- configCodes: [],
- pv: null,
- hl: null,
- rov: null,
- }
- /** 派生:item + 综合得分分解, 渲染时统一用这个壳 */
- type RowItem = VideoMatchEnrichedVO & { _breakdown: ScoreBreakdown | null }
- const ACTIVE_ICON_COLOR = '#1677ff'
- const IDLE_ICON_COLOR = '#bfbfbf'
- /** 指标类列(分发曝光pv / 总回流 / ROV)用 amber 色调与解构/旧字段区分 */
- const METRIC_HEADER_STYLE: React.CSSProperties = {
- background: '#fff7e6',
- color: '#d48806',
- fontWeight: 600,
- }
- const METRIC_CELL_STYLE: React.CSSProperties = {
- background: '#fffbe6',
- }
- interface Props {
- items: VideoMatchEnrichedVO[]
- /** 'ALL' 走视频列布局 */
- activeModality: 'ALL' | Modality
- rankingParams: RankingParams
- /** 受控的"综合得分"列 sort 状态 — 由外部按钮 + 列头联动 */
- compositeSort: 'descend' | 'ascend' | null
- onCompositeSortChange: (next: 'descend' | 'ascend' | null) => void
- }
- /**
- * 按 modality 取解构对象 - 视频取 videoDetail.deconstruct, 素材/长文取各自 detail
- */
- function getDeconstruct(item: VideoMatchEnrichedVO): VideoDetailDeconstruct | undefined {
- if (item.modality === 'MATERIAL') return item.materialDetail?.deconstruct
- if (item.modality === 'ARTICLE') return item.articleDetail?.deconstruct
- return item.videoDetail?.deconstruct
- }
- /**
- * 召回结果表格
- * 列顺序: 标题/封面/召回维度/综合得分/向量相似度/解构/旧AI/指标
- * 综合得分 = c × (α × sim_norm + (1-α) × rov_norm), 参数走 rankingParams (浮层可改)
- * sim < simThreshold 的 item 直接剔除
- */
- export default function RecallResultTable({
- items,
- activeModality,
- rankingParams,
- compositeSort,
- onCompositeSortChange,
- }: Props) {
- const configCodes = useConfigCodes()
- const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
- /** 给每条 item 挂上综合得分分解 */
- const rowItems: RowItem[] = useMemo(
- () => items.map((it) => ({ ...it, _breakdown: computeCompositeScore(it, rankingParams) })),
- [items, rankingParams],
- )
- /** 阈值剔除 — sim 缺失或 < simThreshold 的项不进入展示 */
- const thresholdFiltered = useMemo(
- () => rowItems.filter((it) => it._breakdown != null && it._breakdown.passesThreshold),
- [rowItems],
- )
- const thresholdRejected = rowItems.length - thresholdFiltered.length
- const codeOptions = useMemo(() => {
- const set = new Set<string>()
- thresholdFiltered.forEach((it) => {
- if (it.configCode) set.add(it.configCode)
- })
- return Array.from(set).map((code) => ({
- label: getConfigDisplayLabel(code, configCodes),
- value: code,
- }))
- }, [thresholdFiltered, configCodes])
- const filteredItems = useMemo(() => {
- return thresholdFiltered.filter((it) => {
- 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
- })
- }, [thresholdFiltered, filters])
- const hasFilter =
- filters.configCodes.length > 0 ||
- filters.pv != null ||
- filters.hl != null ||
- filters.rov != null
- if (!items || items.length === 0) {
- return <Empty description="该模态下无召回结果" />
- }
- const thresholdDropdown = (
- field: '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 titleCol: ColumnsType<RowItem>[number] = {
- title: '标题',
- key: 'title',
- width: 240,
- fixed: 'left',
- render: (_v, item) => <TitleCell item={item} />,
- }
- const coverCol: ColumnsType<RowItem>[number] = {
- title: '封面',
- key: 'cover',
- width: 100,
- fixed: 'left',
- render: (_v, item) => <CoverCell item={item} />,
- }
- const configCodeCol: ColumnsType<RowItem>[number] = {
- title: '召回维度',
- key: 'configCode',
- 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)
- return (
- <Tooltip title={item.configCode}>
- <Tag color="purple" style={{ margin: 0 }}>
- {label}
- </Tag>
- </Tooltip>
- )
- },
- }
- const compositeCol: ColumnsType<RowItem>[number] = {
- title: (
- <Tooltip
- overlayStyle={{ maxWidth: 360 }}
- title={
- <div style={{ fontSize: 12 }}>
- 点击列头按综合得分倒排,公式见右上角"排序参数"。
- </div>
- }
- >
- <span>
- 综合得分{' '}
- <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
- </span>
- </Tooltip>
- ),
- key: 'composite',
- width: 130,
- align: 'center',
- fixed: 'left',
- sorter: (a, b) =>
- (a._breakdown?.composite ?? -Infinity) - (b._breakdown?.composite ?? -Infinity),
- sortDirections: ['descend', 'ascend'],
- sortOrder: compositeSort ?? null,
- render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} />,
- }
- const scoreColumn: ColumnsType<RowItem>[number] = {
- title: '向量相似度',
- key: 'score',
- width: 130,
- align: 'center',
- fixed: 'left',
- render: (_v, item) => <ScoreCell score={item.score} />,
- }
- /** 视频专属列(推荐状态 + 解构 + 旧 AI + 运营指标) */
- const videoOnlyCols: ColumnsType<RowItem> = [
- {
- title: '推荐状态',
- key: 'recommendStatus',
- width: 100,
- align: 'center',
- render: (_v, item) => <RecommendStatusCell value={item.videoDetail?.['推荐状态']} />,
- },
- deconstructTopicCol(280),
- pointsCol('解构:灵感点', '灵感点', 240),
- pointsCol('解构:关键点', '关键点', 240),
- pointsCol('解构:目的点', '目的点', 240),
- textCol('视频主题-旧', '视频主题', 110, true),
- textCol('内容选题-旧', '内容选题', 130, true),
- textCol('视频关键词-旧', '视频关键词', 120, true),
- {
- title: '分发曝光pv',
- key: '分发曝光pv',
- width: 140,
- align: 'right',
- onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
- onCell: () => ({ style: METRIC_CELL_STYLE }),
- 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: 130,
- align: 'right',
- onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
- onCell: () => ({ style: METRIC_CELL_STYLE }),
- 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: 110,
- align: 'right',
- onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
- onCell: () => ({ style: METRIC_CELL_STYLE }),
- sorter: (a, b) => {
- const av = parseNum(a.videoDetail?.rov)
- const bv = parseNum(b.videoDetail?.rov)
- if (av == null && bv == null) return 0
- if (av == null) return 1
- if (bv == null) return -1
- return av - bv
- },
- sortDirections: ['descend', 'ascend'],
- 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)}
- </span>
- ),
- },
- ]
- /** 素材专属列 */
- const materialOnlyCols: ColumnsType<RowItem> = [
- {
- title: '图片张数',
- key: 'imageCount',
- width: 90,
- align: 'right',
- render: (_v, item) => {
- const n = item.materialDetail?.imageCount ?? item.imageList?.length
- return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{n != null ? n : '--'}</span>
- },
- },
- {
- title: '来源',
- key: 'material.source',
- width: 100,
- render: (_v, item) => textOrDash(item.materialDetail?.source),
- },
- {
- title: '上传时间',
- key: 'material.uploadTime',
- width: 140,
- render: (_v, item) => textOrDash(item.materialDetail?.uploadTime),
- },
- {
- title: '使用次数',
- key: 'material.usageCount',
- width: 100,
- align: 'right',
- onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
- onCell: () => ({ style: METRIC_CELL_STYLE }),
- sorter: (a, b) => {
- const av = parseNum(a.materialDetail?.usageCount)
- const bv = parseNum(b.materialDetail?.usageCount)
- if (av == null && bv == null) return 0
- if (av == null) return 1
- if (bv == null) return -1
- return av - bv
- },
- sortDirections: ['descend', 'ascend'],
- render: (_v, item) => (
- <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
- {formatNumber(item.materialDetail?.usageCount)}
- </span>
- ),
- },
- {
- title: '标签',
- key: 'material.tags',
- width: 240,
- render: (_v, item) => <TagsCell tags={item.materialDetail?.tags} />,
- },
- deconstructTopicCol(280),
- pointsCol('解构:灵感点', '灵感点', 240),
- pointsCol('解构:关键点', '关键点', 240),
- pointsCol('解构:目的点', '目的点', 240),
- ]
- /** 长文专属列 */
- const articleOnlyCols: ColumnsType<RowItem> = [
- {
- title: '来源',
- key: 'article.channelName',
- width: 140,
- render: (_v, item) => textOrDash(item.articleDetail?.channelName),
- },
- {
- title: '作者',
- key: 'article.channelAccountName',
- width: 140,
- render: (_v, item) => textOrDash(item.articleDetail?.channelAccountName),
- },
- {
- title: '阅读量',
- key: 'article.readCount',
- width: 110,
- align: 'right',
- onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
- onCell: () => ({ style: METRIC_CELL_STYLE }),
- sorter: (a, b) => {
- const av = parseNum(a.articleDetail?.readCount)
- const bv = parseNum(b.articleDetail?.readCount)
- if (av == null && bv == null) return 0
- if (av == null) return 1
- if (bv == null) return -1
- return av - bv
- },
- sortDirections: ['descend', 'ascend'],
- render: (_v, item) => (
- <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
- {formatNumber(item.articleDetail?.readCount)}
- </span>
- ),
- },
- {
- title: '点赞',
- key: 'article.likeCount',
- width: 100,
- align: 'right',
- render: (_v, item) => (
- <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
- {formatNumber(item.articleDetail?.likeCount)}
- </span>
- ),
- },
- {
- title: '在看',
- key: 'article.lookingCount',
- width: 100,
- align: 'right',
- render: (_v, item) => (
- <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
- {formatNumber(item.articleDetail?.lookingCount)}
- </span>
- ),
- },
- {
- title: '字数',
- key: 'article.wordCount',
- width: 90,
- align: 'right',
- render: (_v, item) => {
- const n = item.articleDetail?.wordCount
- return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{n ?? '--'}</span>
- },
- },
- {
- title: '摘要',
- key: 'article.summary',
- width: 320,
- render: (_v, item) => {
- const s = item.articleDetail?.summary
- if (!s) return <Text type="secondary">--</Text>
- return (
- <Paragraph
- style={{ marginBottom: 0, fontSize: 12, lineHeight: 1.45, whiteSpace: 'normal', wordBreak: 'break-word' }}
- ellipsis={{ rows: 3, tooltip: s }}
- >
- {s}
- </Paragraph>
- )
- },
- },
- {
- title: '发布时间',
- key: 'article.publishTime',
- width: 150,
- render: (_v, item) => textOrDash(item.articleDetail?.publishTime),
- },
- {
- title: '原文',
- key: 'article.htmlUrl',
- width: 80,
- align: 'center',
- render: (_v, item) => {
- const url = item.articleDetail?.htmlUrl
- if (!url) return <Text type="secondary">--</Text>
- return (
- <a href={toHttps(url)} target="_blank" rel="noopener noreferrer">
- 打开
- </a>
- )
- },
- },
- deconstructTopicCol(280),
- pointsCol('解构:灵感点', '灵感点', 240),
- pointsCol('解构:关键点', '关键点', 240),
- pointsCol('解构:目的点', '目的点', 240),
- ]
- /** 按 modality 拼装最终列 + 计算 scroll.x */
- let columns: ColumnsType<RowItem>
- if (activeModality === 'MATERIAL') {
- // 素材 Tab: 不展示综合得分列(rov 不适用)
- columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...materialOnlyCols]
- } else if (activeModality === 'ARTICLE') {
- columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...articleOnlyCols]
- } else {
- // VIDEO + ALL 走视频列布局
- columns = [titleCol, coverCol, configCodeCol, compositeCol, scoreColumn, ...videoOnlyCols]
- }
- const scrollX = columns.reduce((s, c) => s + (Number(c.width) || 0), 0)
- return (
- <div>
- {thresholdRejected > 0 && (
- <div
- style={{
- marginBottom: 8,
- padding: '6px 10px',
- background: '#fff7e6',
- border: '1px solid #ffd591',
- borderRadius: 4,
- }}
- >
- <Text style={{ fontSize: 12, color: '#d46b08' }}>
- 相似度阈值 ≥ {rankingParams.simThreshold} (剔除 <b>{thresholdRejected}</b> 条);
- 如需调整请点右上角"排序参数"
- </Text>
- </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> / {thresholdFiltered.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<RowItem>
- size="small"
- bordered
- rowKey={(r) => `${r.modality}-${r.id}-${r.configCode ?? ''}`}
- dataSource={filteredItems}
- columns={columns}
- pagination={false}
- scroll={{ x: scrollX }}
- onChange={(_pagination, _filters, sorter) => {
- const s = sorter as SorterResult<RowItem>
- if (s && s.columnKey === 'composite') {
- onCompositeSortChange((s.order as 'descend' | 'ascend' | undefined) ?? null)
- } else {
- // 点了别的列的 sorter (例如 ROV) → 取消综合排序
- onCompositeSortChange(null)
- }
- }}
- />
- </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.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>
- )
- }
- /**
- * 长文本列
- * - wrap=false (默认): ellipsis 单行 + hover tooltip 看全
- * - wrap=true: 自动换行,最多 4 行后仍 tooltip 兜底
- */
- function textCol(
- title: string,
- key: string,
- width: number,
- wrap = false,
- ): ColumnsType<RowItem>[number] {
- return {
- title,
- key,
- width,
- render: (_v, item) => {
- const raw = item.videoDetail?.[key]
- const text = typeof raw === 'string' ? raw : ''
- if (!text) return <Text type="secondary">--</Text>
- if (wrap) {
- return (
- <Paragraph
- style={{ marginBottom: 0, fontSize: 12, lineHeight: 1.45, whiteSpace: 'normal', wordBreak: 'break-word' }}
- ellipsis={{ rows: 4, tooltip: text }}
- >
- {text}
- </Paragraph>
- )
- }
- return (
- <Tooltip title={text} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
- <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
- {text}
- </Text>
- </Tooltip>
- )
- },
- }
- }
- /** 解构:选题 列 - 文本 ellipsis + tooltip,按 modality 从对应 detail.deconstruct 取 */
- function deconstructTopicCol(width: number): ColumnsType<RowItem>[number] {
- return {
- title: '解构:选题',
- key: 'deconstruct.topic',
- width,
- render: (_v, item) => {
- const topic = getDeconstruct(item)?.topic
- if (!topic) return <Text type="secondary">--</Text>
- return (
- <Tooltip title={topic} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
- <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
- {topic}
- </Text>
- </Tooltip>
- )
- },
- }
- }
- /**
- * 解构:灵感点 / 关键点 / 目的点 列
- * 名称: 全展示 (Tag wrap, 不折叠)
- * 实质: 前 3 个 + "+N", hover tooltip 看全 (含 score)
- */
- function pointsCol(
- title: string,
- type: '灵感点' | '关键点' | '目的点',
- width: number,
- ): ColumnsType<RowItem>[number] {
- const essenceKey = `${type}-实质` as const
- return {
- title,
- key: title,
- width,
- render: (_v, item) => {
- const dec = getDeconstruct(item)
- if (!dec) return <Text type="secondary">--</Text>
- const names = (dec[type] ?? []) as string[]
- const essences = (dec[essenceKey] ?? []) as EssenceWord[]
- if (names.length === 0 && essences.length === 0) {
- return <Text type="secondary">--</Text>
- }
- return (
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
- {names.length > 0 && (
- <div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
- <Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto', lineHeight: '20px' }}>
- 名称:
- </Text>
- <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
- {names.map((n, i) => (
- <Tag key={`${n}-${i}`} color="blue" style={{ margin: 0, fontSize: 11 }}>
- {n}
- </Tag>
- ))}
- </Space>
- </div>
- )}
- {essences.length > 0 && (
- <div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
- <Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto', lineHeight: '20px' }}>
- 实质:
- </Text>
- <EssenceTags essences={essences} />
- </div>
- )}
- </div>
- )
- },
- }
- }
- function EssenceTags({ essences }: { essences: EssenceWord[] }) {
- const visible = essences.slice(0, 3)
- const rest = essences.length - visible.length
- const allText = essences
- .map((e) => (e.score != null ? `${e.word} (${e.score.toFixed(2)})` : e.word))
- .join('、')
- return (
- <Tooltip title={allText} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
- <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
- {visible.map((e, i) => (
- <Tag key={`${e.word}-${i}`} color="cyan" style={{ margin: 0, fontSize: 11 }}>
- {e.word}
- </Tag>
- ))}
- {rest > 0 && <Tag style={{ margin: 0, fontSize: 11 }}>+{rest}</Tag>}
- </Space>
- </Tooltip>
- )
- }
- function TitleCell({ item }: { item: VideoMatchEnrichedVO }) {
- return (
- <div style={{ minWidth: 0 }}>
- <Paragraph
- ellipsis={{ rows: 2, tooltip: item.title ?? '' }}
- style={{ marginBottom: 2, fontWeight: 500, fontSize: 13 }}
- >
- {item.title || <Text type="secondary">(无标题)</Text>}
- </Paragraph>
- <Text type="secondary" style={{ fontSize: 11 }}>
- ID: {item.id}
- </Text>
- </div>
- )
- }
- function CoverCell({ item }: { item: VideoMatchEnrichedVO }) {
- let imgSrc: string | null = null
- if (item.cover) imgSrc = toHttps(item.cover)
- else if (item.imageList && item.imageList.length > 0) imgSrc = toHttps(item.imageList[0])
- const playable = !!item.videoUrl
- const onClick = () => {
- if (playable) window.open(toHttps(item.videoUrl!), '_blank', 'noopener,noreferrer')
- }
- return (
- <div
- onClick={onClick}
- style={{
- position: 'relative',
- width: 80,
- height: 64,
- background: '#fafafa',
- borderRadius: 4,
- overflow: 'hidden',
- cursor: playable ? 'pointer' : 'default',
- }}
- >
- {imgSrc ? (
- <img
- src={imgSrc}
- alt="cover"
- referrerPolicy="no-referrer"
- style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
- onError={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
- />
- ) : (
- <div
- style={{
- width: '100%',
- height: '100%',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- color: '#bfbfbf',
- fontSize: 11,
- }}
- >
- 无封面
- </div>
- )}
- {playable && (
- <div
- style={{
- position: 'absolute',
- inset: 0,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- pointerEvents: 'none',
- }}
- >
- <PlayCircleFilled
- style={{
- fontSize: 22,
- color: '#fff',
- filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.6))',
- }}
- />
- </div>
- )}
- </div>
- )
- }
- function ScoreCell({ score }: { score: number | null }) {
- const style = getScoreStyle(score)
- const text = score != null ? score.toFixed(4) : '--'
- return (
- <span
- style={{
- display: 'inline-block',
- padding: '2px 8px',
- background: style.bg,
- border: `1px solid ${style.border}`,
- color: style.text,
- borderRadius: 4,
- fontWeight: 600,
- fontVariantNumeric: 'tabular-nums',
- fontSize: 12,
- }}
- >
- {text}
- </span>
- )
- }
- /** 推荐状态单元格 — 按状态关键字着色, 未知值走 default Tag */
- function RecommendStatusCell({ value }: { value: string | undefined }) {
- if (!value) return <Text type="secondary">--</Text>
- let color: string | undefined
- if (/推荐|上线|分发/.test(value)) color = 'green'
- else if (/下线|暂停|不推荐|停止/.test(value)) color = 'red'
- else if (/审核|待|中/.test(value)) color = 'orange'
- return (
- <Tag color={color} style={{ margin: 0, fontSize: 11 }}>
- {value}
- </Tag>
- )
- }
- /** 综合得分单元格 — hover Tooltip 展示 sim_norm/rov_norm/c 分解 */
- function CompositeScoreCell({ breakdown }: { breakdown: ScoreBreakdown | null }) {
- if (!breakdown) {
- return <Text type="secondary">--</Text>
- }
- const { composite, simNorm, rovNorm, boost } = breakdown
- // 用与向量相似度同款配色 — 但映射区间不同, 综合得分 [0, c] 内, 走 0.6/0.45/0.3 三档
- const styleScore =
- composite >= 0.6
- ? { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
- : composite >= 0.45
- ? { bg: '#fcffe6', border: '#eaff8f', text: '#7cb305' }
- : composite >= 0.3
- ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
- : { bg: '#fff1f0', border: '#ffa39e', text: '#cf1322' }
- const text = composite.toFixed(4)
- const tip = (
- <div style={{ fontSize: 12, lineHeight: 1.6 }}>
- <div>sim_norm = {simNorm.toFixed(3)}</div>
- <div>rov_norm = {rovNorm.toFixed(3)}</div>
- <div>c = {boost.toFixed(2)}</div>
- <div style={{ borderTop: '1px solid rgba(255,255,255,0.3)', marginTop: 4, paddingTop: 4 }}>
- composite = {boost.toFixed(2)} × (α·sim_norm + (1-α)·rov_norm) = <b>{text}</b>
- </div>
- </div>
- )
- return (
- <Tooltip title={tip} overlayStyle={{ maxWidth: 360 }}>
- <span
- style={{
- display: 'inline-block',
- padding: '2px 8px',
- background: styleScore.bg,
- border: `1px solid ${styleScore.border}`,
- color: styleScore.text,
- borderRadius: 4,
- fontWeight: 600,
- fontVariantNumeric: 'tabular-nums',
- fontSize: 12,
- cursor: 'help',
- }}
- >
- {text}
- </span>
- </Tooltip>
- )
- }
- /** 简单文本/占位 */
- function textOrDash(s: string | undefined) {
- if (!s) return <Text type="secondary">--</Text>
- return (
- <Tooltip title={s} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
- <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
- {s}
- </Text>
- </Tooltip>
- )
- }
- /** 标签列单元格 - 多个 Tag wrap 展示 */
- function TagsCell({ tags }: { tags: string[] | undefined }) {
- if (!tags || tags.length === 0) return <Text type="secondary">--</Text>
- return (
- <Space size={[4, 4]} wrap style={{ rowGap: 4 }}>
- {tags.map((t, i) => (
- <Tag key={`${t}-${i}`} color="geekblue" style={{ margin: 0, fontSize: 11 }}>
- {t}
- </Tag>
- ))}
- </Space>
- )
- }
|