|
@@ -1,7 +1,7 @@
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
import { computed, ref, watch } from 'vue'
|
|
import { computed, ref, watch } from 'vue'
|
|
|
import { fetchDemandGradeVideos } from '../api/demand'
|
|
import { fetchDemandGradeVideos } from '../api/demand'
|
|
|
-import type { DemandGradeItem, DemandVideoItem } from '../types/demand'
|
|
|
|
|
|
|
+import type { DemandExpansionPoint, DemandGradeItem, DemandVideoItem } from '../types/demand'
|
|
|
import { gradeRank, parseStrategies } from '../types/demand'
|
|
import { gradeRank, parseStrategies } from '../types/demand'
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
@@ -41,9 +41,12 @@ const selectedVideo = computed(
|
|
|
() => videos.value.find((v) => v.vid === selectedVid.value) ?? null,
|
|
() => videos.value.find((v) => v.vid === selectedVid.value) ?? null,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+const hasVideoInstances = computed(() => videos.value.length > 0)
|
|
|
|
|
+
|
|
|
interface TopicPoint {
|
|
interface TopicPoint {
|
|
|
title: string
|
|
title: string
|
|
|
- description: string
|
|
|
|
|
|
|
+ pointDesc: string
|
|
|
|
|
+ reason: string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface TopicSection {
|
|
interface TopicSection {
|
|
@@ -53,50 +56,49 @@ interface TopicSection {
|
|
|
items: TopicPoint[]
|
|
items: TopicPoint[]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function parsePointsJson(raw: string | null | undefined): TopicPoint[] {
|
|
|
|
|
- if (!raw) return []
|
|
|
|
|
- try {
|
|
|
|
|
- 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 []
|
|
|
|
|
|
|
+const POINT_TYPE_LABELS: Record<string, string> = {
|
|
|
|
|
+ purpose: '目的点',
|
|
|
|
|
+ key: '关键点',
|
|
|
|
|
+ inspiration: '灵感点',
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const POINT_TYPE_ORDER = ['purpose', 'key', 'inspiration'] as const
|
|
|
|
|
+
|
|
|
|
|
+function buildTopicSections(points: DemandExpansionPoint[]): TopicSection[] | null {
|
|
|
|
|
+ if (!points.length) return null
|
|
|
|
|
+
|
|
|
|
|
+ const grouped = new Map<string, TopicPoint[]>()
|
|
|
|
|
+ for (const point of points) {
|
|
|
|
|
+ const key = point.point_type || 'other'
|
|
|
|
|
+ const items = grouped.get(key) ?? []
|
|
|
|
|
+ items.push({
|
|
|
|
|
+ title: point.expanded_text?.trim() || '未命名',
|
|
|
|
|
+ pointDesc: point.point_desc?.trim() || '',
|
|
|
|
|
+ reason: point.reason?.trim() || '',
|
|
|
|
|
+ })
|
|
|
|
|
+ grouped.set(key, items)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ const sections: TopicSection[] = []
|
|
|
|
|
+ for (const key of POINT_TYPE_ORDER) {
|
|
|
|
|
+ const items = grouped.get(key)
|
|
|
|
|
+ if (!items?.length) continue
|
|
|
|
|
+ sections.push({
|
|
|
|
|
+ key,
|
|
|
|
|
+ label: POINT_TYPE_LABELS[key] ?? key,
|
|
|
|
|
+ accent: key === 'purpose' ? 'purpose' : key === 'key' ? 'key' : 'inspiration',
|
|
|
|
|
+ items,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!sections.length) return null
|
|
|
|
|
+ return sections
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const topicSections = computed((): TopicSection[] | null => {
|
|
const topicSections = computed((): TopicSection[] | null => {
|
|
|
const video = selectedVideo.value
|
|
const video = selectedVideo.value
|
|
|
if (!video) return null
|
|
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
|
|
|
|
|
|
|
+ return buildTopicSections(video.expansion_points ?? [])
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
watch(
|
|
watch(
|
|
@@ -235,85 +237,86 @@ function selectVideo(video: DemandVideoItem) {
|
|
|
</section>
|
|
</section>
|
|
|
|
|
|
|
|
<!-- 需求词 → 视频 -->
|
|
<!-- 需求词 → 视频 -->
|
|
|
- <div class="arrow-col" aria-hidden="true">
|
|
|
|
|
- <div v-if="selectedDemand" class="arrow arrow-green">
|
|
|
|
|
- <span class="arrow-line" />
|
|
|
|
|
- <span class="arrow-head">▶</span>
|
|
|
|
|
|
|
+ <template v-if="hasVideoInstances">
|
|
|
|
|
+ <div class="arrow-col" aria-hidden="true">
|
|
|
|
|
+ <div v-if="selectedDemand" class="arrow arrow-green">
|
|
|
|
|
+ <span class="arrow-line" />
|
|
|
|
|
+ <span class="arrow-head">▶</span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <section class="col col-video">
|
|
|
|
|
- <h3 class="col-title">真实视频实例</h3>
|
|
|
|
|
- <template v-if="selectedDemand">
|
|
|
|
|
- <div class="detail-meta">
|
|
|
|
|
- <span class="grade-badge" :class="gradeClass(selectedDemand.grade)">
|
|
|
|
|
- {{ selectedDemand.grade || '—' }}
|
|
|
|
|
- </span>
|
|
|
|
|
- <div v-if="selectedStrategies.length" class="strategy-tags">
|
|
|
|
|
- <span v-for="s in selectedStrategies" :key="s" class="strategy-tag">{{ s }}</span>
|
|
|
|
|
|
|
+ <section class="col col-video">
|
|
|
|
|
+ <h3 class="col-title">真实视频实例</h3>
|
|
|
|
|
+ <template v-if="selectedDemand">
|
|
|
|
|
+ <div class="detail-meta">
|
|
|
|
|
+ <span class="grade-badge" :class="gradeClass(selectedDemand.grade)">
|
|
|
|
|
+ {{ selectedDemand.grade || '—' }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div v-if="selectedStrategies.length" class="strategy-tags">
|
|
|
|
|
+ <span v-for="s in selectedStrategies" :key="s" class="strategy-tag">{{ s }}</span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- <div v-if="videosLoading" class="empty">加载中…</div>
|
|
|
|
|
- <div v-else-if="videosError" class="empty error">{{ videosError }}</div>
|
|
|
|
|
- <div v-else-if="videos.length" class="card-list">
|
|
|
|
|
- <button
|
|
|
|
|
- v-for="video in videos"
|
|
|
|
|
- :key="video.vid"
|
|
|
|
|
- type="button"
|
|
|
|
|
- class="card video-card"
|
|
|
|
|
- :class="{ active: selectedVid === video.vid }"
|
|
|
|
|
- @click="selectVideo(video)"
|
|
|
|
|
- >
|
|
|
|
|
- <span class="card-label">Video {{ video.vid }}</span>
|
|
|
|
|
- <span class="card-value">{{ video.title || '(无标题)' }}</span>
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-else class="empty">暂无关联视频</div>
|
|
|
|
|
- </template>
|
|
|
|
|
- <div v-else class="empty hint">点击需求词展开</div>
|
|
|
|
|
- </section>
|
|
|
|
|
|
|
+ <div v-if="videosLoading" class="empty">加载中…</div>
|
|
|
|
|
+ <div v-else-if="videosError" class="empty error">{{ videosError }}</div>
|
|
|
|
|
+ <div v-else class="card-list">
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-for="video in videos"
|
|
|
|
|
+ :key="video.vid"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="card video-card"
|
|
|
|
|
+ :class="{ active: selectedVid === video.vid }"
|
|
|
|
|
+ @click="selectVideo(video)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="card-label">Video {{ video.vid }}</span>
|
|
|
|
|
+ <span class="card-value">{{ video.title || '(无标题)' }}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </section>
|
|
|
|
|
|
|
|
- <!-- 视频 → JSON -->
|
|
|
|
|
- <div class="arrow-col" aria-hidden="true">
|
|
|
|
|
- <div v-if="selectedVideo" class="arrow arrow-purple">
|
|
|
|
|
- <span class="arrow-line" />
|
|
|
|
|
- <span class="arrow-head">▶</span>
|
|
|
|
|
|
|
+ <!-- 视频 → 选题 -->
|
|
|
|
|
+ <div class="arrow-col" aria-hidden="true">
|
|
|
|
|
+ <div v-if="selectedVideo" class="arrow arrow-purple">
|
|
|
|
|
+ <span class="arrow-line" />
|
|
|
|
|
+ <span class="arrow-head">▶</span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <section class="col col-topic">
|
|
|
|
|
- <h3 class="col-title">选题结果</h3>
|
|
|
|
|
- <template v-if="selectedVideo">
|
|
|
|
|
- <div v-if="topicSections" class="topic-card">
|
|
|
|
|
- <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
|
|
|
|
|
- <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>
|
|
|
|
|
|
|
+ <section class="col col-topic">
|
|
|
|
|
+ <h3 class="col-title">选题结果</h3>
|
|
|
|
|
+ <template v-if="selectedVideo">
|
|
|
|
|
+ <div v-if="topicSections" class="topic-card">
|
|
|
|
|
+ <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
|
|
|
|
|
+ <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.pointDesc" class="point-desc">{{ item.pointDesc }}</p>
|
|
|
|
|
+ <p v-if="item.reason" class="point-reason">{{ item.reason }}</p>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else class="section-empty">暂无</div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- <div v-else class="empty">暂无选题结果</div>
|
|
|
|
|
- </template>
|
|
|
|
|
- <div v-else class="empty hint">点击视频展开</div>
|
|
|
|
|
- </section>
|
|
|
|
|
|
|
+ <div v-else class="empty">暂无选题结果</div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <div v-else class="empty hint">点击视频展开</div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </template>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
@@ -769,6 +772,16 @@ function selectVideo(video: DemandVideoItem) {
|
|
|
word-break: break-word;
|
|
word-break: break-word;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.point-reason {
|
|
|
|
|
+ margin: 4px 0 0;
|
|
|
|
|
+ padding-left: 26px;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ line-height: 1.5;
|
|
|
|
|
+ color: #64748b;
|
|
|
|
|
+ white-space: pre-wrap;
|
|
|
|
|
+ word-break: break-word;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.section-empty {
|
|
.section-empty {
|
|
|
padding: 8px 10px;
|
|
padding: 8px 10px;
|
|
|
font-size: 12px;
|
|
font-size: 12px;
|