Просмотр исходного кода

feat: 召回结果表格化 + 嵌入解构数据(灵感/关键/目的点)

- 列表卡片改 AntD Table,14 列横向并列
- 5-8 列接入后端新 videoDetail.deconstruct(选题/灵感点/关键点/目的点)
  名称行全展示(蓝 Tag),实质行前 3 + N(青 Tag,hover 看 score)
- 删除旧的 4 列卡片(VideoCard)
- 标题/封面/召回维度/相似度 4 列左侧固定
- 指标列(分发曝光pv/总回流/ROV)用 amber 色调表头单元格区分
刘立冬 2 дней назад
Родитель
Сommit
93fa9e94bf

+ 63 - 0
src/api/types.ts

@@ -17,6 +17,67 @@ export interface VideoBasicVO {
   playCount: string
 }
 
+/**
+ * 后端返回的视频运营/分发维度详情(嵌套对象)
+ * key 为后端原样字段(中英文混合),全部可选 — 缺字段时前端用 "--" 占位
+ */
+export interface VideoDetail {
+  '标题'?: string
+  '视频时长'?: string
+  '解构选题'?: string
+  '推荐状态'?: string
+  '首次推荐时间'?: string
+  // 比率类
+  rov?: string
+  str?: string
+  ros?: string
+  vov0?: string
+  vov1?: string
+  vor_t0?: string
+  // 量级类
+  '分发曝光pv'?: string
+  '分发曝光uv'?: string
+  '总回流uv'?: string
+  '总回流'?: string
+  '总分享pv'?: string
+  '当日分发分享pv'?: string
+  '当日分发回流uv'?: string
+  '推荐曝光'?: string
+  '推荐回流'?: string
+  '分发视频量'?: string
+  // 分类/元素 merge
+  'merge二级品类'?: string
+  'top1分类'?: string
+  'top1元素'?: string
+  '分类merge'?: string
+  '元素merge'?: string
+  /** 视频解构(后端新增,从 Redis recall:vid_decode:{vid} 取出) */
+  deconstruct?: VideoDetailDeconstruct
+  // 兜底:允许任意额外 key
+  [key: string]: string | VideoDetailDeconstruct | undefined
+}
+
+/**
+ * videoDetail.deconstruct 子对象
+ * 后端 VideoSearchServiceImpl.buildFlatDeconstruct 输出
+ */
+export interface VideoDetailDeconstruct {
+  /** 最终选题文本 */
+  topic?: string
+  /** 灵感点名称列表 */
+  '灵感点'?: string[]
+  /** 灵感点拆解出的实质词(score>=0.8) */
+  '灵感点-实质'?: EssenceWord[]
+  /** 关键点(类型=='实质')名称列表 */
+  '关键点'?: string[]
+  /** 关键点实质词 */
+  '关键点-实质'?: EssenceWord[]
+  /** 目的点名称列表 */
+  '目的点'?: string[]
+  /** 目的点实质词 */
+  '目的点-实质'?: EssenceWord[]
+}
+
 export interface VideoMatchEnrichedVO {
   id: number
   modality: Modality
@@ -32,6 +93,8 @@ export interface VideoMatchEnrichedVO {
   ctr: string
   readCount: string
   rov: string
+  /** 运营/分发指标详情 (后端新增,旧后端无此字段时为 null/undefined) */
+  videoDetail?: VideoDetail | null
 }
 
 export interface RecallResultVO {

+ 7 - 13
src/components/RecallResultList.tsx

@@ -1,7 +1,7 @@
 import { useMemo, useState } from 'react'
-import { Empty, Row, Col, Tabs } from 'antd'
+import { Empty, Tabs } from 'antd'
 import type { Modality, RecallResultVO } from '../api/types'
-import VideoCard from './VideoCard'
+import RecallResultTable from './RecallResultTable'
 
 interface Props {
   result: RecallResultVO | null
@@ -15,6 +15,10 @@ const MODALITY_LABEL: Record<Modality | 'ALL', string> = {
   ARTICLE: '长文',
 }
 
+/**
+ * 召回结果壳:模态切换 Tabs + 当前模态对应的表格
+ * Tabs 复用现有 ALL/VIDEO/MATERIAL/ARTICLE 计数,内容用 RecallResultTable 渲染
+ */
 export default function RecallResultList({ result, loading }: Props) {
   const [active, setActive] = useState<'ALL' | Modality>('VIDEO')
 
@@ -53,17 +57,7 @@ export default function RecallResultList({ result, loading }: Props) {
         onChange={(k) => setActive(k as 'ALL' | Modality)}
         items={items}
       />
-      {filtered.length === 0 ? (
-        <Empty description="该模态下无召回结果" />
-      ) : (
-        <Row gutter={[16, 16]}>
-          {filtered.map((item) => (
-            <Col key={`${item.modality}-${item.id}`} xs={24} sm={12} md={8} lg={6}>
-              <VideoCard item={item} />
-            </Col>
-          ))}
-        </Row>
-      )}
+      <RecallResultTable items={filtered} />
     </div>
   )
 }

+ 355 - 0
src/components/RecallResultTable.tsx

@@ -0,0 +1,355 @@
+import { Table, Tag, Typography, Tooltip, Space, Empty } from 'antd'
+import { PlayCircleFilled } from '@ant-design/icons'
+import type { ColumnsType } from 'antd/es/table'
+import type { EssenceWord, VideoMatchEnrichedVO } from '../api/types'
+import { useConfigCodes } from '../api/configCodes'
+import { toHttps } from '../utils/url'
+import {
+  formatCount,
+  formatRatio,
+  getScoreStyle,
+} from '../utils/format'
+
+const { Text, Paragraph } = Typography
+
+/** 指标类列(分发曝光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[]
+}
+
+/**
+ * 召回结果表格 — 14 列横向并列, 缺字段统一 "--", 长文案 ellipsis + hover tooltip
+ * 列顺序按业务约定: 标题/封面/召回维度/相似度/4个解构/3个旧AI/3个量级
+ */
+export default function RecallResultTable({ items }: Props) {
+  const configCodes = useConfigCodes()
+
+  if (!items || items.length === 0) {
+    return <Empty description="该模态下无召回结果" />
+  }
+
+  const columns: ColumnsType<VideoMatchEnrichedVO> = [
+    {
+      title: '标题',
+      key: 'title',
+      width: 240,
+      fixed: 'left',
+      render: (_v, item) => <TitleCell item={item} />,
+    },
+    {
+      title: '封面',
+      key: 'cover',
+      width: 100,
+      fixed: 'left',
+      render: (_v, item) => <CoverCell item={item} />,
+    },
+    {
+      title: '召回维度',
+      key: 'configCode',
+      width: 110,
+      fixed: 'left',
+      render: (_v, item) => {
+        if (!item.configCode) return <Text type="secondary">--</Text>
+        const label = configCodes[item.configCode] ?? item.configCode
+        return (
+          <Tooltip title={item.configCode}>
+            <Tag color="purple" style={{ margin: 0 }}>
+              {label}
+            </Tag>
+          </Tooltip>
+        )
+      },
+    },
+    {
+      title: '向量相似度',
+      key: 'score',
+      width: 100,
+      align: 'center',
+      fixed: 'left',
+      render: (_v, item) => <ScoreCell score={item.score} />,
+    },
+    deconstructTopicCol(280),
+    pointsCol('解构:灵感点', '灵感点', 240),
+    pointsCol('解构:关键点', '关键点', 240),
+    pointsCol('解构:目的点', '目的点', 240),
+    textCol('视频主题-旧', '视频主题', 180),
+    textCol('内容选题-旧', '内容选题', 220),
+    textCol('视频关键词-旧', '视频关键词', 200),
+    {
+      title: '分发曝光pv',
+      key: '分发曝光pv',
+      width: 110,
+      align: 'right',
+      onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
+      onCell: () => ({ style: METRIC_CELL_STYLE }),
+      render: (_v, item) => formatCount(item.videoDetail?.['分发曝光pv']),
+    },
+    {
+      title: '总回流',
+      key: '总回流',
+      width: 100,
+      align: 'right',
+      onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
+      onCell: () => ({ style: METRIC_CELL_STYLE }),
+      render: (_v, item) => formatCount(item.videoDetail?.['总回流']),
+    },
+    {
+      title: 'ROV',
+      key: 'rov',
+      width: 90,
+      align: 'right',
+      onHeaderCell: () => ({ style: METRIC_HEADER_STYLE }),
+      onCell: () => ({ style: METRIC_CELL_STYLE }),
+      render: (_v, item) => (
+        <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
+          {formatRatio(item.videoDetail?.rov)}
+        </span>
+      ),
+    },
+  ]
+
+  return (
+    <Table<VideoMatchEnrichedVO>
+      size="small"
+      rowKey={(r) => `${r.modality}-${r.id}`}
+      dataSource={items}
+      columns={columns}
+      pagination={false}
+      scroll={{ x: 2400 }}
+    />
+  )
+}
+
+/** 长文本列: ellipsis 单行 + hover tooltip 显示完整 */
+function textCol(
+  title: string,
+  key: string,
+  width: number,
+): ColumnsType<VideoMatchEnrichedVO>[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>
+      return (
+        <Tooltip title={text} placement="topLeft" overlayStyle={{ maxWidth: 480 }}>
+          <Text style={{ fontSize: 12 }} ellipsis={{ tooltip: false }}>
+            {text}
+          </Text>
+        </Tooltip>
+      )
+    },
+  }
+}
+
+/** 解构:选题 列 - 文本 ellipsis + tooltip,数据源 videoDetail.deconstruct.topic */
+function deconstructTopicCol(width: number): ColumnsType<VideoMatchEnrichedVO>[number] {
+  return {
+    title: '解构:选题',
+    key: 'deconstruct.topic',
+    width,
+    render: (_v, item) => {
+      const topic = item.videoDetail?.deconstruct?.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<VideoMatchEnrichedVO>[number] {
+  const essenceKey = `${type}-实质` as const
+  return {
+    title,
+    key: title,
+    width,
+    render: (_v, item) => {
+      const dec = item.videoDetail?.deconstruct
+      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"
+          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>
+  )
+}

+ 0 - 185
src/components/VideoCard.tsx

@@ -1,185 +0,0 @@
-import { Card, Tag, Typography } from 'antd'
-import { PlayCircleFilled } from '@ant-design/icons'
-import type { VideoMatchEnrichedVO } from '../api/types'
-import { toHttps } from '../utils/url'
-
-const { Text, Paragraph } = Typography
-
-interface VideoCardProps {
-  item: VideoMatchEnrichedVO
-}
-
-export default function VideoCard({ item }: VideoCardProps) {
-  const score = item.score != null ? item.score.toFixed(4) : '--'
-  const scoreStyle = getScoreStyle(item.score)
-
-  return (
-    <Card
-      size="small"
-      hoverable
-      style={{ width: '100%' }}
-      cover={renderCover(item)}
-    >
-      <div
-        style={{
-          display: 'flex',
-          alignItems: 'baseline',
-          gap: 8,
-          padding: '8px 10px',
-          marginBottom: 8,
-          background: scoreStyle.bg,
-          border: `1px solid ${scoreStyle.border}`,
-          borderRadius: 6,
-        }}
-      >
-        <span style={{ fontSize: 12, color: 'rgba(0,0,0,0.55)' }}>相似度</span>
-        <span style={{ fontSize: 22, fontWeight: 700, color: scoreStyle.text, lineHeight: 1 }}>
-          {score}
-        </span>
-      </div>
-
-      {item.configCode && (
-        <div style={{ marginBottom: 8 }}>
-          <Tag>{item.configCode}</Tag>
-        </div>
-      )}
-
-      <Paragraph
-        ellipsis={{ rows: 2, tooltip: item.title ?? '' }}
-        style={{ marginBottom: 6, fontWeight: 500, minHeight: 44 }}
-      >
-        {item.title || <Text type="secondary">(无标题)</Text>}
-      </Paragraph>
-
-      <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>
-        ID: {item.id}
-      </div>
-
-      {renderMetrics(item)}
-    </Card>
-  )
-}
-
-function getScoreStyle(score: number | null | undefined) {
-  if (score == null) {
-    return { bg: '#fafafa', border: '#e8e8e8', text: 'rgba(0,0,0,0.45)' }
-  }
-  if (score >= 0.85) return { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
-  if (score >= 0.75) return { bg: '#fcffe6', border: '#eaff8f', text: '#7cb305' }
-  if (score >= 0.65) return { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
-  return { bg: '#fff1f0', border: '#ffa39e', text: '#cf1322' }
-}
-
-function renderCover(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 handleClick = () => {
-    if (playable) {
-      window.open(toHttps(item.videoUrl!), '_blank', 'noopener,noreferrer')
-    }
-  }
-
-  // 5:4 宽高比 (宽=5, 高=4) → paddingTop = 4/5 = 80%
-  const containerStyle: React.CSSProperties = {
-    position: 'relative',
-    width: '100%',
-    paddingTop: '80%',
-    background: '#fafafa',
-    overflow: 'hidden',
-    cursor: playable ? 'pointer' : 'default',
-  }
-
-  return (
-    <div style={containerStyle} onClick={handleClick}>
-      {imgSrc ? (
-        <img
-          src={imgSrc}
-          alt="cover"
-          style={{
-            position: 'absolute',
-            inset: 0,
-            width: '100%',
-            height: '100%',
-            objectFit: 'cover',
-            display: 'block',
-          }}
-          onError={(e) => {
-            ;(e.currentTarget as HTMLImageElement).style.visibility = 'hidden'
-          }}
-        />
-      ) : (
-        <div
-          style={{
-            position: 'absolute',
-            inset: 0,
-            display: 'flex',
-            alignItems: 'center',
-            justifyContent: 'center',
-            color: '#bfbfbf',
-          }}
-        >
-          无封面
-        </div>
-      )}
-      {playable && <PlayOverlay />}
-    </div>
-  )
-}
-
-function PlayOverlay() {
-  return (
-    <div
-      style={{
-        position: 'absolute',
-        inset: 0,
-        display: 'flex',
-        alignItems: 'center',
-        justifyContent: 'center',
-        pointerEvents: 'none',
-      }}
-    >
-      <div
-        style={{
-          width: 60,
-          height: 60,
-          borderRadius: '50%',
-          background: 'rgba(0,0,0,0.45)',
-          display: 'flex',
-          alignItems: 'center',
-          justifyContent: 'center',
-          backdropFilter: 'blur(2px)',
-        }}
-      >
-        <PlayCircleFilled style={{ fontSize: 36, color: '#fff' }} />
-      </div>
-    </div>
-  )
-}
-
-function renderMetrics(item: VideoMatchEnrichedVO) {
-  const items: Array<[string, string]> = []
-  if (item.modality === 'VIDEO') {
-    items.push(['播放量', item.playCount])
-    items.push(['ROV', item.rov])
-  } else if (item.modality === 'MATERIAL') {
-    items.push(['曝光', item.exposure])
-    items.push(['CTR', item.ctr])
-  } else {
-    items.push(['阅读', item.readCount])
-  }
-  return (
-    <div style={{ marginTop: 6, fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>
-      {items.map(([k, v]) => (
-        <span key={k} style={{ marginRight: 12 }}>
-          {k}: {v}
-        </span>
-      ))}
-    </div>
-  )
-}

+ 67 - 0
src/utils/format.ts

@@ -0,0 +1,67 @@
+/**
+ * 后端 videoDetail 字段值都是 string,需要前端按用途格式化
+ * 缺值/非数值统一返回 "--" 占位
+ */
+
+const PLACEHOLDER = '--'
+
+/** 比率值 (rov/str/ros/vov0/vov1) → 取 4 位小数;非数值返回 -- */
+export function formatRatio(s: string | undefined | null): string {
+  if (s == null || s === '' || s === PLACEHOLDER) return PLACEHOLDER
+  const n = Number(s)
+  if (!Number.isFinite(n)) return PLACEHOLDER
+  return n.toFixed(4)
+}
+
+/** 量级数值 → K/M 缩写 (614275 → 614.3K, 1925000 → 1.93M) */
+export function formatCount(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)
+}
+
+/** "20250622" → "2025-06-22"; 形态不对返回原值或 -- */
+export function formatDate(s: string | undefined | null): string {
+  if (!s) return PLACEHOLDER
+  if (/^\d{8}$/.test(s)) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
+  return s
+}
+
+/** 视频时长秒 → "3 分 10 秒" 或 "190s"(后端给的是 "190.0") */
+export function formatDuration(s: string | undefined | null): string {
+  if (!s) return PLACEHOLDER
+  const n = Number(s)
+  if (!Number.isFinite(n) || n <= 0) return PLACEHOLDER
+  const sec = Math.round(n)
+  if (sec < 60) return `${sec}s`
+  const m = Math.floor(sec / 60)
+  const r = sec % 60
+  return r === 0 ? `${m}分` : `${m}分${r}秒`
+}
+
+/**
+ * 拆分形如 "老年生活:45123,通用老年:45419" 的标签串,
+ * 冒号后的 ID 丢弃,只保留中文名;空串/空值返回 []
+ */
+export function splitTags(s: string | undefined | null): string[] {
+  if (!s) return []
+  return s
+    .split(',')
+    .map((part) => part.split(':')[0].trim())
+    .filter(Boolean)
+}
+
+/** 相似度配色(按分数走 4 档) */
+export function getScoreStyle(score: number | null | undefined) {
+  if (score == null) {
+    return { bg: '#fafafa', border: '#e8e8e8', text: 'rgba(0,0,0,0.45)' }
+  }
+  if (score >= 0.85) return { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
+  if (score >= 0.75) return { bg: '#fcffe6', border: '#eaff8f', text: '#7cb305' }
+  if (score >= 0.65) return { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
+  return { bg: '#fff1f0', border: '#ffa39e', text: '#cf1322' }
+}