Sfoglia il codice sorgente

补充下缀数据

xueyiming 2 settimane fa
parent
commit
859eb9ad74

+ 1 - 1
api/app.py

@@ -53,7 +53,7 @@ def demand_belong_category() -> dict:
 
 @app.get("/api/demand-belong-category/{belong_id}/videos")
 def demand_belong_videos(belong_id: int) -> dict:
-    """Return videos linked to a demand_belong_category row (vid + title + final_topic_json)."""
+    """Return videos linked to a demand_belong_category row (vid + title + points JSON)."""
     result = list_videos_for_demand_belong(belong_id)
     if result is None:
         raise HTTPException(status_code=404, detail="demand_belong_category not found")

+ 6 - 2
api/services/demand_videos.py

@@ -29,7 +29,7 @@ def list_videos_for_demand_belong(belong_id: int) -> dict[str, Any] | None:
     """
     按 demand_belong_category.id 返回关联视频详情。
 
-    顺序与 video_list 一致;详情表缺失的 vid 仍返回,title/final_topic_json 为空。
+    顺序与 video_list 一致;详情表缺失的 vid 仍返回,title/三点 JSON 为空。
     """
     with get_session() as session:
         belong = DemandBelongCategoryRepository(session).get_by_id(belong_id)
@@ -46,7 +46,11 @@ def list_videos_for_demand_belong(belong_id: int) -> dict[str, Any] | None:
                 {
                     "vid": vid,
                     "title": row.title if row else None,
-                    "final_topic_json": row.final_topic_json if row else None,
+                    "inspiration_points_json": (
+                        row.inspiration_points_json if row else None
+                    ),
+                    "purpose_points_json": row.purpose_points_json if row else None,
+                    "key_points_json": row.key_points_json if row else None,
                 }
             )
 

+ 34 - 0
jobs/backfill_multi_demand_video_points.py

@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_video_detail 的灵感点/目的点/关键点
+(decode_result.灵感点 / 目的点 / 关键点,每项保留 点/点描述)。
+
+用法:
+    python jobs/backfill_multi_demand_video_points.py
+    python jobs/backfill_multi_demand_video_points.py 100   # 每批 100
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
+    VIDEO_SYNC_BATCH_SIZE,
+    backfill_video_points,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(batch_arg: str | None = None) -> dict:
+    batch_size = int(batch_arg) if batch_arg else VIDEO_SYNC_BATCH_SIZE
+    result = backfill_video_points(batch_size=batch_size)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    main(sys.argv[1] if len(sys.argv) > 1 else None)

+ 210 - 16
web/src/components/DemandPathPanel.vue

@@ -31,14 +31,62 @@ const selectedVideo = computed(
   () => videos.value.find((v) => v.vid === selectedVid.value) ?? null,
 )
 
-const topicDisplay = computed(() => {
-  const raw = selectedVideo.value?.final_topic_json
-  if (!raw) return null
+interface TopicPoint {
+  title: string
+  description: string
+}
+
+interface TopicSection {
+  key: string
+  label: string
+  accent: string
+  items: TopicPoint[]
+}
+
+function parsePointsJson(raw: string | null | undefined): TopicPoint[] {
+  if (!raw) return []
   try {
-    return JSON.stringify(JSON.parse(raw), null, 2)
+    const parsed = JSON.parse(raw)
+    const list = Array.isArray(parsed) ? parsed : [parsed]
+    return list
+      .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
+      .map((item) => ({
+        title: String(item['点'] ?? '').trim(),
+        description: String(item['点描述'] ?? '').trim(),
+      }))
+      .filter((item) => item.title || item.description)
   } catch {
-    return raw
+    return []
   }
+}
+
+const topicSections = computed((): TopicSection[] | null => {
+  const video = selectedVideo.value
+  if (!video) return null
+
+  const sections: TopicSection[] = [
+    {
+      key: 'inspiration',
+      label: '灵感点',
+      accent: 'inspiration',
+      items: parsePointsJson(video.inspiration_points_json),
+    },
+    {
+      key: 'purpose',
+      label: '目的点',
+      accent: 'purpose',
+      items: parsePointsJson(video.purpose_points_json),
+    },
+    {
+      key: 'key',
+      label: '关键点',
+      accent: 'key',
+      items: parsePointsJson(video.key_points_json),
+    },
+  ]
+
+  if (sections.every((s) => s.items.length === 0)) return null
+  return sections
 })
 
 watch(
@@ -180,9 +228,31 @@ function selectVideo(video: DemandVideoItem) {
     <section class="col col-topic">
       <h3 class="col-title">选题结果</h3>
       <template v-if="selectedVideo">
-        <div v-if="topicDisplay" class="topic-card">
+        <div v-if="topicSections" class="topic-card">
           <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
-          <pre class="topic-json">{{ topicDisplay }}</pre>
+          <div class="topic-body">
+            <section
+              v-for="section in topicSections"
+              :key="section.key"
+              class="topic-section"
+              :class="`accent-${section.accent}`"
+            >
+              <div class="section-head">
+                <span class="section-label">{{ section.label }}</span>
+                <span class="section-count">{{ section.items.length }}</span>
+              </div>
+              <div v-if="section.items.length" class="point-list">
+                <article v-for="(item, idx) in section.items" :key="idx" class="point-item">
+                  <div class="point-title-row">
+                    <span class="point-index">{{ idx + 1 }}</span>
+                    <h4 class="point-title">{{ item.title || '未命名' }}</h4>
+                  </div>
+                  <p v-if="item.description" class="point-desc">{{ item.description }}</p>
+                </article>
+              </div>
+              <div v-else class="section-empty">暂无</div>
+            </section>
+          </div>
         </div>
         <div v-else class="empty">暂无选题结果</div>
       </template>
@@ -210,7 +280,7 @@ function selectVideo(video: DemandVideoItem) {
 }
 
 .col-topic {
-  width: 300px;
+  width: 380px;
 }
 
 .col-head {
@@ -413,7 +483,7 @@ function selectVideo(video: DemandVideoItem) {
   border-radius: 12px;
   background: linear-gradient(180deg, #faf5ff 0%, #fff 45%);
   overflow: hidden;
-  max-height: 420px;
+  max-height: 520px;
   display: flex;
   flex-direction: column;
 }
@@ -425,20 +495,144 @@ function selectVideo(video: DemandVideoItem) {
   font-weight: 600;
   color: #7e22ce;
   font-variant-numeric: tabular-nums;
+  flex-shrink: 0;
 }
 
-.topic-json {
-  margin: 0;
-  padding: 10px 12px;
+.topic-body {
+  padding: 10px 12px 12px;
   overflow: auto;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 11px;
-  line-height: 1.45;
-  color: #334155;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.topic-section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.section-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.section-label {
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+}
+
+.section-count {
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  border-radius: 999px;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 18px;
+  text-align: center;
+  font-variant-numeric: tabular-nums;
+}
+
+.accent-inspiration .section-label {
+  color: #c2410c;
+}
+.accent-inspiration .section-count {
+  color: #c2410c;
+  background: #ffedd5;
+}
+.accent-inspiration .point-index {
+  background: #ffedd5;
+  color: #c2410c;
+}
+
+.accent-purpose .section-label {
+  color: #1d4ed8;
+}
+.accent-purpose .section-count {
+  color: #1d4ed8;
+  background: #dbeafe;
+}
+.accent-purpose .point-index {
+  background: #dbeafe;
+  color: #1d4ed8;
+}
+
+.accent-key .section-label {
+  color: #7e22ce;
+}
+.accent-key .section-count {
+  color: #7e22ce;
+  background: #f3e8ff;
+}
+.accent-key .point-index {
+  background: #f3e8ff;
+  color: #7e22ce;
+}
+
+.point-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.point-item {
+  padding: 10px 11px;
+  border-radius: 10px;
+  background: #fff;
+  border: 1px solid #e2e8f0;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
+}
+
+.point-title-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+}
+
+.point-index {
+  flex-shrink: 0;
+  width: 18px;
+  height: 18px;
+  margin-top: 1px;
+  border-radius: 5px;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 18px;
+  text-align: center;
+  font-variant-numeric: tabular-nums;
+}
+
+.point-title {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 1.35;
+  color: #0f172a;
+  word-break: break-word;
+}
+
+.point-desc {
+  margin: 6px 0 0;
+  padding-left: 26px;
+  font-size: 12px;
+  line-height: 1.55;
+  color: #475569;
   white-space: pre-wrap;
   word-break: break-word;
 }
 
+.section-empty {
+  padding: 8px 10px;
+  font-size: 12px;
+  color: #94a3b8;
+  border: 1px dashed #e2e8f0;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.6);
+}
+
 .empty {
   padding: 20px 10px;
   text-align: center;

+ 3 - 1
web/src/types/demand.ts

@@ -12,7 +12,9 @@ export interface DemandBelongResponse {
 export interface DemandVideoItem {
   vid: string
   title: string | null
-  final_topic_json: string | null
+  inspiration_points_json: string | null
+  purpose_points_json: string | null
+  key_points_json: string | null
 }
 
 export interface DemandVideosResponse {