|
@@ -5,6 +5,7 @@ import {
|
|
|
fetchVideoDiscoveryDemands,
|
|
fetchVideoDiscoveryDemands,
|
|
|
} from '../api/videoDiscovery'
|
|
} from '../api/videoDiscovery'
|
|
|
import { formatPosteriorDiff } from '../types/category'
|
|
import { formatPosteriorDiff } from '../types/category'
|
|
|
|
|
+import { pickDecodeUrl, videoDetailUrl } from '../utils/videoLinks'
|
|
|
import type {
|
|
import type {
|
|
|
VideoDiscoveryDemand,
|
|
VideoDiscoveryDemand,
|
|
|
VideoDiscoveryDemandDetail,
|
|
VideoDiscoveryDemandDetail,
|
|
@@ -16,6 +17,7 @@ type SupplyFilter = 'all' | 'with-video' | 'posterior' | 'no-video'
|
|
|
type PointFilter = 'all' | 'purpose' | 'key' | 'inspiration'
|
|
type PointFilter = 'all' | 'purpose' | 'key' | 'inspiration'
|
|
|
|
|
|
|
|
const GRADE_ORDER = ['S', 'A', 'B', 'C', 'D'] as const
|
|
const GRADE_ORDER = ['S', 'A', 'B', 'C', 'D'] as const
|
|
|
|
|
+const SOURCE_VIDEO_GRADES = new Set(['B', 'C', 'D'])
|
|
|
const POINT_META = {
|
|
const POINT_META = {
|
|
|
purpose: { label: '目的点', short: '目的', symbol: '◎' },
|
|
purpose: { label: '目的点', short: '目的', symbol: '◎' },
|
|
|
key: { label: '关键点', short: '关键', symbol: '◆' },
|
|
key: { label: '关键点', short: '关键', symbol: '◆' },
|
|
@@ -92,6 +94,14 @@ const selectedDemand = computed(
|
|
|
() => demands.value.find((item) => item.id === selectedDemandId.value) ?? null,
|
|
() => demands.value.find((item) => item.id === selectedDemandId.value) ?? null,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+const isSourceOnlyGrade = computed(() =>
|
|
|
|
|
+ SOURCE_VIDEO_GRADES.has((selectedDemand.value?.grade ?? '').toUpperCase()),
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const showHitContentPanel = computed(
|
|
|
|
|
+ () => !isSourceOnlyGrade.value && (selectedVideo.value?.point_count ?? 0) > 0,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
const visibleDemands = computed(() =>
|
|
const visibleDemands = computed(() =>
|
|
|
filteredDemands.value.slice(0, visibleDemandLimit.value),
|
|
filteredDemands.value.slice(0, visibleDemandLimit.value),
|
|
|
)
|
|
)
|
|
@@ -170,6 +180,19 @@ const progressWidth = computed(() => {
|
|
|
return `${Math.min(100, count * 10)}%`
|
|
return `${Math.min(100, count * 10)}%`
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+const emptyVideoTitle = computed(() =>
|
|
|
|
|
+ isSourceOnlyGrade.value ? '这个需求暂无关联源视频' : '这个需求暂无命中视频',
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const emptyVideoHint = computed(() => {
|
|
|
|
|
+ if ((detail.value?.videos.length ?? 0) > 0) {
|
|
|
|
|
+ return '清空搜索或切换点位类型'
|
|
|
|
|
+ }
|
|
|
|
|
+ return isSourceOnlyGrade.value
|
|
|
|
|
+ ? 'demand_grade.video_list 中暂无 vid'
|
|
|
|
|
+ : 'demand_video_expansion 中暂无命中记录'
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
onMounted(async () => {
|
|
onMounted(async () => {
|
|
|
try {
|
|
try {
|
|
|
const response = await fetchVideoDiscoveryDemands()
|
|
const response = await fetchVideoDiscoveryDemands()
|
|
@@ -276,6 +299,14 @@ async function copyText(text: string, key: string) {
|
|
|
copiedKey.value = null
|
|
copiedKey.value = null
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+function decodeUrlFor(video: VideoDiscoveryVideo): string | null {
|
|
|
|
|
+ return pickDecodeUrl(video)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function detailUrlFor(video: VideoDiscoveryVideo): string {
|
|
|
|
|
+ return videoDetailUrl(video.vid, video.video_detail_url)
|
|
|
|
|
+}
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
@@ -403,7 +434,13 @@ async function copyText(text: string, key: string) {
|
|
|
<span>需求分 {{ formatScore(item.score) }}</span>
|
|
<span>需求分 {{ formatScore(item.score) }}</span>
|
|
|
<span class="dot-sep">·</span>
|
|
<span class="dot-sep">·</span>
|
|
|
<span :class="{ muted: item.video_count === 0 }">
|
|
<span :class="{ muted: item.video_count === 0 }">
|
|
|
- {{ item.video_count ? `${item.video_count} 条命中视频` : '暂无命中' }}
|
|
|
|
|
|
|
+ {{
|
|
|
|
|
+ item.video_count
|
|
|
|
|
+ ? SOURCE_VIDEO_GRADES.has(item.grade)
|
|
|
|
|
+ ? `${item.video_count} 条源视频`
|
|
|
|
|
+ : `${item.video_count} 条命中视频`
|
|
|
|
|
+ : '暂无视频'
|
|
|
|
|
+ }}
|
|
|
</span>
|
|
</span>
|
|
|
<span class="card-arrow">›</span>
|
|
<span class="card-arrow">›</span>
|
|
|
</div>
|
|
</div>
|
|
@@ -507,7 +544,7 @@ async function copyText(text: string, key: string) {
|
|
|
aria-label="搜索候选视频"
|
|
aria-label="搜索候选视频"
|
|
|
/>
|
|
/>
|
|
|
</label>
|
|
</label>
|
|
|
- <div v-if="detail.point_count > 0" class="point-filter">
|
|
|
|
|
|
|
+ <div v-if="detail.point_count > 0 && !isSourceOnlyGrade" class="point-filter">
|
|
|
<button
|
|
<button
|
|
|
v-for="filter in availablePointFilters"
|
|
v-for="filter in availablePointFilters"
|
|
|
:key="filter"
|
|
:key="filter"
|
|
@@ -534,10 +571,31 @@ async function copyText(text: string, key: string) {
|
|
|
<div class="video-main">
|
|
<div class="video-main">
|
|
|
<div class="video-title-row">
|
|
<div class="video-title-row">
|
|
|
<strong>{{ video.title || `视频 ${video.vid}` }}</strong>
|
|
<strong>{{ video.title || `视频 ${video.vid}` }}</strong>
|
|
|
- <span>{{ video.point_count }} 个内容点</span>
|
|
|
|
|
|
|
+ <span v-if="!isSourceOnlyGrade">{{ video.point_count }} 个内容点</span>
|
|
|
</div>
|
|
</div>
|
|
|
<code>{{ video.vid }}</code>
|
|
<code>{{ video.vid }}</code>
|
|
|
- <div class="video-point-badges">
|
|
|
|
|
|
|
+ <div class="video-link-row">
|
|
|
|
|
+ <a
|
|
|
|
|
+ v-if="decodeUrlFor(video)"
|
|
|
|
|
+ :href="decodeUrlFor(video)!"
|
|
|
|
|
+ class="video-link"
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noopener noreferrer"
|
|
|
|
|
+ @click.stop
|
|
|
|
|
+ >
|
|
|
|
|
+ 解构结果
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <a
|
|
|
|
|
+ :href="detailUrlFor(video)"
|
|
|
|
|
+ class="video-link"
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noopener noreferrer"
|
|
|
|
|
+ @click.stop
|
|
|
|
|
+ >
|
|
|
|
|
+ 视频详情
|
|
|
|
|
+ </a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="!isSourceOnlyGrade" class="video-point-badges">
|
|
|
<span v-if="videoPointCount(video, 'purpose')" class="point-badge purpose">
|
|
<span v-if="videoPointCount(video, 'purpose')" class="point-badge purpose">
|
|
|
◎ 目的 {{ videoPointCount(video, 'purpose') }}
|
|
◎ 目的 {{ videoPointCount(video, 'purpose') }}
|
|
|
</span>
|
|
</span>
|
|
@@ -557,14 +615,8 @@ async function copyText(text: string, key: string) {
|
|
|
</div>
|
|
</div>
|
|
|
<div v-else class="empty-state">
|
|
<div v-else class="empty-state">
|
|
|
<span class="empty-mark">○</span>
|
|
<span class="empty-mark">○</span>
|
|
|
- <strong>{{ detail.videos.length ? '没有匹配的视频' : '这个需求暂无命中视频' }}</strong>
|
|
|
|
|
- <span>
|
|
|
|
|
- {{
|
|
|
|
|
- detail.videos.length
|
|
|
|
|
- ? '清空搜索或切换点位类型'
|
|
|
|
|
- : 'demand_video_expansion 中暂无命中记录'
|
|
|
|
|
- }}
|
|
|
|
|
- </span>
|
|
|
|
|
|
|
+ <strong>{{ detail.videos.length ? '没有匹配的视频' : emptyVideoTitle }}</strong>
|
|
|
|
|
+ <span>{{ emptyVideoHint }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
</section>
|
|
</section>
|
|
@@ -573,9 +625,11 @@ async function copyText(text: string, key: string) {
|
|
|
<div class="panel-head">
|
|
<div class="panel-head">
|
|
|
<div>
|
|
<div>
|
|
|
<span class="step-index">03</span>
|
|
<span class="step-index">03</span>
|
|
|
- <h2>命中视频内容</h2>
|
|
|
|
|
|
|
+ <h2>{{ isSourceOnlyGrade ? '视频信息' : '命中视频内容' }}</h2>
|
|
|
</div>
|
|
</div>
|
|
|
- <span v-if="selectedVideo" class="source-label">EXPANSION</span>
|
|
|
|
|
|
|
+ <span v-if="selectedVideo" class="source-label">
|
|
|
|
|
+ {{ isSourceOnlyGrade ? 'POOL' : 'EXPANSION' }}
|
|
|
|
|
+ </span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<template v-if="selectedVideo">
|
|
<template v-if="selectedVideo">
|
|
@@ -591,9 +645,28 @@ async function copyText(text: string, key: string) {
|
|
|
{{ copiedKey === `video-${selectedVideo.vid}` ? '已复制' : '复制 ID' }}
|
|
{{ copiedKey === `video-${selectedVideo.vid}` ? '已复制' : '复制 ID' }}
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div class="video-link-row panel-links">
|
|
|
|
|
+ <a
|
|
|
|
|
+ v-if="decodeUrlFor(selectedVideo)"
|
|
|
|
|
+ :href="decodeUrlFor(selectedVideo)!"
|
|
|
|
|
+ class="video-link"
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noopener noreferrer"
|
|
|
|
|
+ >
|
|
|
|
|
+ 解构结果
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <a
|
|
|
|
|
+ :href="detailUrlFor(selectedVideo)"
|
|
|
|
|
+ class="video-link"
|
|
|
|
|
+ target="_blank"
|
|
|
|
|
+ rel="noopener noreferrer"
|
|
|
|
|
+ >
|
|
|
|
|
+ 视频详情
|
|
|
|
|
+ </a>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div class="coverage-card">
|
|
|
|
|
|
|
+ <div v-if="showHitContentPanel" class="coverage-card">
|
|
|
<div class="coverage-head">
|
|
<div class="coverage-head">
|
|
|
<span>点位覆盖</span>
|
|
<span>点位覆盖</span>
|
|
|
<strong>{{ selectedVideo.point_count }} 个</strong>
|
|
<strong>{{ selectedVideo.point_count }} 个</strong>
|
|
@@ -604,7 +677,7 @@ async function copyText(text: string, key: string) {
|
|
|
<p>结合目的、关键和灵感点判断该视频能否承接当前需求。</p>
|
|
<p>结合目的、关键和灵感点判断该视频能否承接当前需求。</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div v-if="selectedPointGroups.length" class="point-groups">
|
|
|
|
|
|
|
+ <div v-if="showHitContentPanel && selectedPointGroups.length" class="point-groups">
|
|
|
<section
|
|
<section
|
|
|
v-for="group in selectedPointGroups"
|
|
v-for="group in selectedPointGroups"
|
|
|
:key="group.type"
|
|
:key="group.type"
|
|
@@ -653,6 +726,11 @@ async function copyText(text: string, key: string) {
|
|
|
</article>
|
|
</article>
|
|
|
</section>
|
|
</section>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div v-else-if="isSourceOnlyGrade" class="empty-state evidence-empty">
|
|
|
|
|
+ <span class="empty-mark">◇</span>
|
|
|
|
|
+ <strong>暂无命中视频内容</strong>
|
|
|
|
|
+ <span>B/C/D 等级展示需求池关联源视频,可通过上方链接查看解构结果与视频详情</span>
|
|
|
|
|
+ </div>
|
|
|
<div v-else class="empty-state evidence-empty">
|
|
<div v-else class="empty-state evidence-empty">
|
|
|
<span class="empty-mark">◇</span>
|
|
<span class="empty-mark">◇</span>
|
|
|
<strong>暂无内容点位</strong>
|
|
<strong>暂无内容点位</strong>
|
|
@@ -1364,7 +1442,7 @@ async function copyText(text: string, key: string) {
|
|
|
.video-main code,
|
|
.video-main code,
|
|
|
.video-id-row code {
|
|
.video-id-row code {
|
|
|
display: block;
|
|
display: block;
|
|
|
- margin: 3px 0 7px;
|
|
|
|
|
|
|
+ margin: 3px 0 5px;
|
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
|
color: #919a93;
|
|
color: #919a93;
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
@@ -1373,6 +1451,38 @@ async function copyText(text: string, key: string) {
|
|
|
white-space: nowrap;
|
|
white-space: nowrap;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.video-link-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ margin-bottom: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-link-row.panel-links {
|
|
|
|
|
+ margin-top: 10px;
|
|
|
|
|
+ margin-bottom: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-link {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 2px 7px;
|
|
|
|
|
+ border: 1px solid #cfd9d2;
|
|
|
|
|
+ border-radius: 5px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ color: #3a6a50;
|
|
|
|
|
+ font-size: 9px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ text-decoration: none;
|
|
|
|
|
+ transition: 0.15s ease;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-link:hover {
|
|
|
|
|
+ border-color: #8eb19a;
|
|
|
|
|
+ background: #f3f8f4;
|
|
|
|
|
+ color: #24553a;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.video-point-badges {
|
|
.video-point-badges {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
flex-wrap: wrap;
|