Преглед на файлове

增加寻找视频可视化

xueyiming преди 1 ден
родител
ревизия
7db8805a05
променени са 2 файла, в които са добавени 286 реда и са изтрити 2 реда
  1. 2 0
      prd/09-找视频记录展示设计.md
  2. 284 2
      web/src/views/FindAgentRecordsView.vue

+ 2 - 0
prd/09-找视频记录展示设计.md

@@ -42,6 +42,8 @@
 - Agent 解释:`intent_summary`。
 - Agent 解释:`intent_summary`。
 - 运行结果:搜索页、全部候选、主推荐、淘汰、待评估。
 - 运行结果:搜索页、全部候选、主推荐、淘汰、待评估。
 - 默认展开上下文:`relevant_points_json` 和 `stop_reason`,用户仍可手动收起。
 - 默认展开上下文:`relevant_points_json` 和 `stop_reason`,用户仍可手动收起。
+- `relevant_points_json` 不直接输出 JSON,而是展示需求摘要,并按参考视频分组展示灵感点、
+  目的点、关键点及其说明;同时兼容包含 `reference_videos` 的对象格式和历史点位数组格式。
 
 
 计数由关联表实时聚合,避免只依赖运行表中的缓存计数而掩盖数据不一致。
 计数由关联表实时聚合,避免只依赖运行表中的缓存计数而掩盖数据不一致。
 
 

+ 284 - 2
web/src/views/FindAgentRecordsView.vue

@@ -14,6 +14,27 @@ import type {
 } from '../types/videoDiscoveryRecords'
 } from '../types/videoDiscoveryRecords'
 
 
 type DetailTab = 'searches' | 'candidates'
 type DetailTab = 'searches' | 'candidates'
+type JsonRecord = Record<string, unknown>
+
+interface ReadablePoint {
+  point: string
+  pointType: string
+  description: string
+}
+
+interface ReadableReferenceVideo {
+  videoId: string
+  title: string
+  points: ReadablePoint[]
+}
+
+interface ReadablePointContext {
+  bizDt: string
+  demandGradeId: string
+  demandName: string
+  grade: string
+  videos: ReadableReferenceVideo[]
+}
 
 
 const PAGE_SIZE = 20
 const PAGE_SIZE = 20
 const runs = ref<VideoDiscoveryRunRecord[]>([])
 const runs = ref<VideoDiscoveryRunRecord[]>([])
@@ -44,6 +65,7 @@ const runPages = computed(() => Math.max(1, Math.ceil(runTotal.value / PAGE_SIZE
 const detailTotal = computed(() => tab.value === 'searches' ? searchTotal.value : candidateTotal.value)
 const detailTotal = computed(() => tab.value === 'searches' ? searchTotal.value : candidateTotal.value)
 const detailPage = computed(() => tab.value === 'searches' ? searchPage.value : candidatePage.value)
 const detailPage = computed(() => tab.value === 'searches' ? searchPage.value : candidatePage.value)
 const detailPages = computed(() => Math.max(1, Math.ceil(detailTotal.value / PAGE_SIZE)))
 const detailPages = computed(() => Math.max(1, Math.ceil(detailTotal.value / PAGE_SIZE)))
+const pointContext = computed(() => buildPointContext(selected.value?.relevant_points))
 
 
 onMounted(() => loadRuns())
 onMounted(() => loadRuns())
 
 
@@ -262,6 +284,79 @@ function formatScore(value: number | null): string {
   return value.toFixed(value > 1 ? 2 : 3)
   return value.toFixed(value > 1 ? 2 : 3)
 }
 }
 
 
+function isJsonRecord(value: unknown): value is JsonRecord {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function textValue(value: unknown): string {
+  if (value === null || value === undefined) return ''
+  return String(value).trim()
+}
+
+function normalizePoint(value: unknown): ReadablePoint | null {
+  if (!isJsonRecord(value)) return null
+  const point = textValue(value.point)
+  const description = textValue(value.point_desc)
+  if (!point && !description) return null
+  return {
+    point: point || '未命名点位',
+    pointType: textValue(value.point_type),
+    description,
+  }
+}
+
+function buildPointContext(value: JsonValue): ReadablePointContext | null {
+  const root = isJsonRecord(value) ? value : {}
+  const sourceVideos = Array.isArray(root.reference_videos) ? root.reference_videos : []
+  const videos: ReadableReferenceVideo[] = sourceVideos
+    .filter(isJsonRecord)
+    .map((video) => ({
+      videoId: textValue(video.video_id),
+      title: textValue(video.title) || '未命名参考视频',
+      points: (Array.isArray(video.points) ? video.points : [])
+        .map(normalizePoint)
+        .filter((point): point is ReadablePoint => point !== null),
+    }))
+    .filter((video) => video.points.length > 0)
+
+  if (!videos.length) {
+    const rawPoints = Array.isArray(value)
+      ? value
+      : Array.isArray(root.relevant_points)
+        ? root.relevant_points
+        : []
+    const grouped = new Map<string, ReadableReferenceVideo>()
+    rawPoints.filter(isJsonRecord).forEach((rawPoint) => {
+      const point = normalizePoint(rawPoint)
+      if (!point) return
+      const videoId = textValue(rawPoint.video_id)
+      const title = textValue(rawPoint.video_title) || '未标记参考视频'
+      const key = videoId || title
+      const group = grouped.get(key) || { videoId, title, points: [] }
+      group.points.push(point)
+      grouped.set(key, group)
+    })
+    videos.push(...grouped.values())
+  }
+
+  if (!videos.length && !Object.keys(root).length) return null
+  return {
+    bizDt: textValue(root.biz_dt),
+    demandGradeId: textValue(root.demand_grade_id),
+    demandName: textValue(root.demand_name),
+    grade: textValue(root.grade),
+    videos,
+  }
+}
+
+function pointTypeLabel(value: string): string {
+  return {
+    inspiration: '灵感点',
+    purpose: '目的点',
+    key: '关键点',
+  }[value] || value || '内容点'
+}
+
 function engagementText(video: {
 function engagementText(video: {
   like_count: number | null
   like_count: number | null
   comment_count: number | null
   comment_count: number | null
@@ -401,9 +496,66 @@ function openCandidate(candidate: VideoDiscoveryCandidateRecord) {
                 <strong>停止原因</strong>
                 <strong>停止原因</strong>
                 <p>{{ selected.stop_reason }}</p>
                 <p>{{ selected.stop_reason }}</p>
               </div>
               </div>
-              <div v-if="selected.relevant_points">
+              <div v-if="pointContext">
                 <strong>相关点位</strong>
                 <strong>相关点位</strong>
-                <pre>{{ formatJson(selected.relevant_points) }}</pre>
+                <div class="point-context">
+                  <div
+                    v-if="
+                      pointContext.demandName
+                        || pointContext.grade
+                        || pointContext.bizDt
+                        || pointContext.demandGradeId
+                    "
+                    class="point-context-meta"
+                  >
+                    <span v-if="pointContext.demandName">
+                      <small>需求</small>{{ pointContext.demandName }}
+                    </span>
+                    <span v-if="pointContext.grade">
+                      <small>等级</small><b>{{ pointContext.grade }}</b>
+                    </span>
+                    <span v-if="pointContext.bizDt">
+                      <small>业务日</small>{{ formatDate(pointContext.bizDt) }}
+                    </span>
+                    <span v-if="pointContext.demandGradeId">
+                      <small>分级 ID</small>#{{ pointContext.demandGradeId }}
+                    </span>
+                  </div>
+                  <div v-if="pointContext.videos.length" class="reference-video-list">
+                    <article
+                      v-for="video in pointContext.videos"
+                      :key="video.videoId || video.title"
+                      class="reference-video-card"
+                    >
+                      <header>
+                        <div>
+                          <small>参考视频</small>
+                          <h3 :title="video.title">{{ video.title }}</h3>
+                        </div>
+                        <code v-if="video.videoId">{{ video.videoId }}</code>
+                      </header>
+                      <div class="readable-point-list">
+                        <div
+                          v-for="(point, index) in video.points"
+                          :key="`${point.point}-${index}`"
+                          class="readable-point"
+                        >
+                          <span
+                            class="point-type"
+                            :class="`point-type-${point.pointType || 'default'}`"
+                          >
+                            {{ pointTypeLabel(point.pointType) }}
+                          </span>
+                          <div>
+                            <strong>{{ point.point }}</strong>
+                            <p v-if="point.description">{{ point.description }}</p>
+                          </div>
+                        </div>
+                      </div>
+                    </article>
+                  </div>
+                  <p v-else class="no-readable-points">暂无可展示的点位内容。</p>
+                </div>
               </div>
               </div>
             </details>
             </details>
           </div>
           </div>
@@ -982,6 +1134,136 @@ button, input, select { font: inherit; }
 .run-context summary { color: #6262cc; cursor: pointer; }
 .run-context summary { color: #6262cc; cursor: pointer; }
 .run-context strong { display: block; margin-top: 8px; color: #606878; }
 .run-context strong { display: block; margin-top: 8px; color: #606878; }
 .run-context p { margin: 4px 0; color: #737b89; }
 .run-context p { margin: 4px 0; color: #737b89; }
+.point-context {
+  margin-top: 8px;
+  padding: 13px;
+  border: 1px solid #e2e5eb;
+  border-radius: 11px;
+  background: rgba(255, 255, 255, .78);
+}
+.point-context-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 11px;
+}
+.point-context-meta > span {
+  display: inline-flex;
+  min-height: 31px;
+  align-items: center;
+  gap: 7px;
+  padding: 5px 9px;
+  border-radius: 7px;
+  background: #f1f2f6;
+  color: #535b6b;
+  font-size: 12px;
+  font-weight: 650;
+}
+.point-context-meta small {
+  color: #9299a6;
+  font-size: 11px;
+  font-weight: 600;
+}
+.point-context-meta b {
+  color: #5557c3;
+  font-size: 14px;
+}
+.reference-video-list {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
+  gap: 10px;
+}
+.reference-video-card {
+  min-width: 0;
+  overflow: hidden;
+  border: 1px solid #e2e5ea;
+  border-radius: 10px;
+  background: #fff;
+}
+.reference-video-card > header {
+  display: flex;
+  min-height: 61px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 10px 12px;
+  border-bottom: 1px solid #eceef2;
+  background: #fafbfc;
+}
+.reference-video-card > header > div { min-width: 0; }
+.reference-video-card header small {
+  display: block;
+  color: #969daa;
+  font-size: 11px;
+}
+.reference-video-card h3 {
+  margin: 3px 0 0;
+  overflow: hidden;
+  color: #394152;
+  font-size: 14px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.reference-video-card header code {
+  flex-shrink: 0;
+  padding: 4px 7px;
+  border-radius: 6px;
+  background: #eff0f5;
+  color: #707787;
+  font-size: 11px;
+}
+.readable-point-list { display: grid; }
+.readable-point {
+  display: grid;
+  padding: 11px 12px;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: start;
+  gap: 10px;
+}
+.readable-point + .readable-point { border-top: 1px solid #eff0f3; }
+.readable-point .point-type {
+  display: inline-flex;
+  min-width: 50px;
+  min-height: 23px;
+  align-items: center;
+  justify-content: center;
+  padding: 2px 7px;
+  border-radius: 999px;
+  background: #eef0f4;
+  color: #687080;
+  font-size: 11px;
+  font-weight: 750;
+  white-space: nowrap;
+}
+.readable-point .point-type-inspiration {
+  background: #f0edff;
+  color: #6860c7;
+}
+.readable-point .point-type-purpose {
+  background: #e9f7f2;
+  color: #287b62;
+}
+.readable-point .point-type-key {
+  background: #fff3df;
+  color: #97660b;
+}
+.readable-point > div > strong {
+  margin: 1px 0 0;
+  color: #41495a;
+  font-size: 13px;
+}
+.readable-point > div > p {
+  margin: 6px 0 0;
+  color: #6d7585;
+  font-size: 12px;
+  line-height: 1.7;
+  white-space: pre-line;
+}
+.no-readable-points {
+  margin: 0;
+  color: #9299a6;
+  font-size: 12px;
+}
 pre {
 pre {
   max-height: 210px;
   max-height: 210px;
   margin: 6px 0 0;
   margin: 6px 0 0;