|
|
@@ -0,0 +1,1751 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, onMounted, ref, watch } from 'vue'
|
|
|
+import {
|
|
|
+ fetchVideoDiscoveryDemand,
|
|
|
+ fetchVideoDiscoveryDemands,
|
|
|
+} from '../api/videoDiscovery'
|
|
|
+import { formatPosteriorDiff } from '../types/category'
|
|
|
+import type {
|
|
|
+ VideoDiscoveryDemand,
|
|
|
+ VideoDiscoveryDemandDetail,
|
|
|
+ VideoDiscoveryPoint,
|
|
|
+ VideoDiscoveryVideo,
|
|
|
+} from '../types/videoDiscovery'
|
|
|
+
|
|
|
+type SupplyFilter = 'all' | 'with-video' | 'posterior' | 'no-video'
|
|
|
+type PointFilter = 'all' | 'purpose' | 'key' | 'inspiration'
|
|
|
+
|
|
|
+const GRADE_ORDER = ['S', 'A', 'B', 'C', 'D'] as const
|
|
|
+const POINT_META = {
|
|
|
+ purpose: { label: '目的点', short: '目的', symbol: '◎' },
|
|
|
+ key: { label: '关键点', short: '关键', symbol: '◆' },
|
|
|
+ inspiration: { label: '灵感点', short: '灵感', symbol: '✦' },
|
|
|
+} as const
|
|
|
+
|
|
|
+const demands = ref<VideoDiscoveryDemand[]>([])
|
|
|
+const bizDt = ref<string | null>(null)
|
|
|
+const loading = ref(true)
|
|
|
+const error = ref<string | null>(null)
|
|
|
+const search = ref('')
|
|
|
+const gradeFilter = ref('all')
|
|
|
+const supplyFilter = ref<SupplyFilter>('all')
|
|
|
+const selectedDemandId = ref<number | null>(null)
|
|
|
+const detail = ref<VideoDiscoveryDemandDetail | null>(null)
|
|
|
+const detailLoading = ref(false)
|
|
|
+const detailError = ref<string | null>(null)
|
|
|
+const videoSearch = ref('')
|
|
|
+const pointFilter = ref<PointFilter>('all')
|
|
|
+const selectedVid = ref<string | null>(null)
|
|
|
+const copiedKey = ref<string | null>(null)
|
|
|
+const visibleDemandLimit = ref(80)
|
|
|
+let detailRequest = 0
|
|
|
+let copiedTimer = 0
|
|
|
+
|
|
|
+const gradeCounts = computed(() => {
|
|
|
+ const counts: Record<string, number> = { all: demands.value.length }
|
|
|
+ for (const grade of GRADE_ORDER) counts[grade] = 0
|
|
|
+ for (const item of demands.value) {
|
|
|
+ const grade = item.grade?.toUpperCase()
|
|
|
+ if (grade in counts) counts[grade] += 1
|
|
|
+ }
|
|
|
+ return counts
|
|
|
+})
|
|
|
+
|
|
|
+const stats = computed(() => {
|
|
|
+ const withVideo = demands.value.filter((item) => item.video_count > 0).length
|
|
|
+ const videoTotal = demands.value.reduce((sum, item) => sum + item.video_count, 0)
|
|
|
+ const posterior = demands.value.filter((item) => item.has_posterior).length
|
|
|
+ return { withVideo, videoTotal, posterior }
|
|
|
+})
|
|
|
+
|
|
|
+const filteredDemands = computed(() => {
|
|
|
+ const keyword = search.value.trim().toLocaleLowerCase()
|
|
|
+ return demands.value
|
|
|
+ .filter((item) => gradeFilter.value === 'all' || item.grade === gradeFilter.value)
|
|
|
+ .filter((item) => {
|
|
|
+ if (supplyFilter.value === 'with-video') return item.video_count > 0
|
|
|
+ if (supplyFilter.value === 'posterior') return item.has_posterior
|
|
|
+ if (supplyFilter.value === 'no-video') return item.video_count === 0
|
|
|
+ return true
|
|
|
+ })
|
|
|
+ .filter((item) => {
|
|
|
+ if (!keyword) return true
|
|
|
+ const haystack = [
|
|
|
+ item.demand_name,
|
|
|
+ item.reason ?? '',
|
|
|
+ ...item.strategies,
|
|
|
+ ...item.category_names,
|
|
|
+ ]
|
|
|
+ .join(' ')
|
|
|
+ .toLocaleLowerCase()
|
|
|
+ return haystack.includes(keyword)
|
|
|
+ })
|
|
|
+ .sort((a, b) => {
|
|
|
+ const gradeDiff = gradeRank(a.grade) - gradeRank(b.grade)
|
|
|
+ if (gradeDiff !== 0) return gradeDiff
|
|
|
+ if (a.video_count !== b.video_count) return b.video_count - a.video_count
|
|
|
+ return (b.score ?? -1) - (a.score ?? -1)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+const selectedDemand = computed(
|
|
|
+ () => demands.value.find((item) => item.id === selectedDemandId.value) ?? null,
|
|
|
+)
|
|
|
+
|
|
|
+const visibleDemands = computed(() =>
|
|
|
+ filteredDemands.value.slice(0, visibleDemandLimit.value),
|
|
|
+)
|
|
|
+
|
|
|
+const pointTypeCounts = computed(() => {
|
|
|
+ const counts: Record<PointFilter, number> = {
|
|
|
+ all: detail.value?.point_count ?? 0,
|
|
|
+ purpose: 0,
|
|
|
+ key: 0,
|
|
|
+ inspiration: 0,
|
|
|
+ }
|
|
|
+ for (const video of detail.value?.videos ?? []) {
|
|
|
+ for (const point of video.points) {
|
|
|
+ if (point.point_type in counts) {
|
|
|
+ counts[point.point_type as PointFilter] += 1
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return counts
|
|
|
+})
|
|
|
+
|
|
|
+const availablePointFilters = computed<PointFilter[]>(() => [
|
|
|
+ 'all',
|
|
|
+ ...(['purpose', 'key', 'inspiration'] as const).filter(
|
|
|
+ (type) => pointTypeCounts.value[type] > 0,
|
|
|
+ ),
|
|
|
+])
|
|
|
+
|
|
|
+const filteredVideos = computed(() => {
|
|
|
+ const keyword = videoSearch.value.trim().toLocaleLowerCase()
|
|
|
+ return [...(detail.value?.videos ?? [])]
|
|
|
+ .filter((video) => {
|
|
|
+ if (pointFilter.value === 'all') return true
|
|
|
+ return video.points.some((point) => point.point_type === pointFilter.value)
|
|
|
+ })
|
|
|
+ .filter((video) => {
|
|
|
+ if (!keyword) return true
|
|
|
+ const haystack = [
|
|
|
+ video.vid,
|
|
|
+ video.title ?? '',
|
|
|
+ ...video.points.flatMap((point) => [
|
|
|
+ point.expanded_text,
|
|
|
+ point.point_desc ?? '',
|
|
|
+ point.reason,
|
|
|
+ ]),
|
|
|
+ ]
|
|
|
+ .join(' ')
|
|
|
+ .toLocaleLowerCase()
|
|
|
+ return haystack.includes(keyword)
|
|
|
+ })
|
|
|
+ .sort((a, b) => b.point_count - a.point_count)
|
|
|
+})
|
|
|
+
|
|
|
+const selectedVideo = computed(
|
|
|
+ () => detail.value?.videos.find((video) => video.vid === selectedVid.value) ?? null,
|
|
|
+)
|
|
|
+
|
|
|
+const selectedPointGroups = computed(() => {
|
|
|
+ const groups = new Map<string, VideoDiscoveryPoint[]>()
|
|
|
+ for (const point of selectedVideo.value?.points ?? []) {
|
|
|
+ const items = groups.get(point.point_type) ?? []
|
|
|
+ items.push(point)
|
|
|
+ groups.set(point.point_type, items)
|
|
|
+ }
|
|
|
+ return ['purpose', 'key', 'inspiration']
|
|
|
+ .filter((type) => groups.has(type))
|
|
|
+ .map((type) => ({
|
|
|
+ type,
|
|
|
+ meta: pointMeta(type),
|
|
|
+ points: groups.get(type) ?? [],
|
|
|
+ }))
|
|
|
+})
|
|
|
+
|
|
|
+const progressWidth = computed(() => {
|
|
|
+ const count = selectedVideo.value?.point_count ?? 0
|
|
|
+ return `${Math.min(100, count * 10)}%`
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ try {
|
|
|
+ const response = await fetchVideoDiscoveryDemands()
|
|
|
+ demands.value = response.items ?? []
|
|
|
+ bizDt.value = response.biz_dt
|
|
|
+ selectedDemandId.value =
|
|
|
+ demands.value.find((item) => item.video_count > 0)?.id ?? demands.value[0]?.id ?? null
|
|
|
+ } catch (e) {
|
|
|
+ error.value = e instanceof Error ? e.message : String(e)
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+watch(selectedDemandId, async (id) => {
|
|
|
+ const request = ++detailRequest
|
|
|
+ detail.value = null
|
|
|
+ selectedVid.value = null
|
|
|
+ videoSearch.value = ''
|
|
|
+ pointFilter.value = 'all'
|
|
|
+ detailError.value = null
|
|
|
+ if (id == null) return
|
|
|
+
|
|
|
+ detailLoading.value = true
|
|
|
+ try {
|
|
|
+ const response = await fetchVideoDiscoveryDemand(id)
|
|
|
+ if (request !== detailRequest) return
|
|
|
+ detail.value = response
|
|
|
+ selectedVid.value =
|
|
|
+ response.videos.find((video) => video.point_count > 0)?.vid ??
|
|
|
+ response.videos[0]?.vid ??
|
|
|
+ null
|
|
|
+ } catch (e) {
|
|
|
+ if (request !== detailRequest) return
|
|
|
+ detailError.value = e instanceof Error ? e.message : String(e)
|
|
|
+ } finally {
|
|
|
+ if (request === detailRequest) detailLoading.value = false
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+watch(filteredDemands, (items) => {
|
|
|
+ if (!items.length) return
|
|
|
+ if (!items.some((item) => item.id === selectedDemandId.value)) {
|
|
|
+ selectedDemandId.value = items[0].id
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+watch([search, gradeFilter, supplyFilter], () => {
|
|
|
+ visibleDemandLimit.value = 80
|
|
|
+})
|
|
|
+
|
|
|
+watch(filteredVideos, (videos) => {
|
|
|
+ if (!videos.length) {
|
|
|
+ selectedVid.value = null
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!videos.some((video) => video.vid === selectedVid.value)) {
|
|
|
+ selectedVid.value = videos[0].vid
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+function gradeRank(grade: string): number {
|
|
|
+ const index = GRADE_ORDER.indexOf(grade as (typeof GRADE_ORDER)[number])
|
|
|
+ return index < 0 ? GRADE_ORDER.length : index
|
|
|
+}
|
|
|
+
|
|
|
+function gradeClass(grade: string): string {
|
|
|
+ return `grade-${grade || 'none'}`
|
|
|
+}
|
|
|
+
|
|
|
+function formatDate(raw: string | null): string {
|
|
|
+ if (!raw || raw.length !== 8) return raw || '—'
|
|
|
+ return `${raw.slice(0, 4)}.${raw.slice(4, 6)}.${raw.slice(6)}`
|
|
|
+}
|
|
|
+
|
|
|
+function formatScore(value: number | null): string {
|
|
|
+ if (value == null) return '—'
|
|
|
+ return value.toFixed(value >= 10 ? 0 : 2)
|
|
|
+}
|
|
|
+
|
|
|
+function pointMeta(type: string) {
|
|
|
+ return (
|
|
|
+ POINT_META[type as keyof typeof POINT_META] ?? {
|
|
|
+ label: type || '其他点',
|
|
|
+ short: type || '其他',
|
|
|
+ symbol: '•',
|
|
|
+ }
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function videoPointCount(video: VideoDiscoveryVideo, type: string): number {
|
|
|
+ return video.points.filter((point) => point.point_type === type).length
|
|
|
+}
|
|
|
+
|
|
|
+async function copyText(text: string, key: string) {
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(text)
|
|
|
+ copiedKey.value = key
|
|
|
+ window.clearTimeout(copiedTimer)
|
|
|
+ copiedTimer = window.setTimeout(() => {
|
|
|
+ copiedKey.value = null
|
|
|
+ }, 1600)
|
|
|
+ } catch {
|
|
|
+ copiedKey.value = null
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="video-discovery">
|
|
|
+ <header class="workspace-header">
|
|
|
+ <div>
|
|
|
+ <div class="eyebrow">
|
|
|
+ <span class="live-dot" />
|
|
|
+ 内容寻找工作台
|
|
|
+ </div>
|
|
|
+ <div class="title-row">
|
|
|
+ <h1>需求汇总</h1>
|
|
|
+ <span class="date-pill">{{ formatDate(bizDt) }}</span>
|
|
|
+ </div>
|
|
|
+ <p>视频列表与内容命中来自需求拓展结果。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="summary-strip" aria-label="数据概览">
|
|
|
+ <div class="summary-item">
|
|
|
+ <span class="summary-value">{{ demands.length }}</span>
|
|
|
+ <span class="summary-label">今日需求</span>
|
|
|
+ </div>
|
|
|
+ <div class="summary-divider" />
|
|
|
+ <div class="summary-item">
|
|
|
+ <span class="summary-value accent">{{ stats.withVideo }}</span>
|
|
|
+ <span class="summary-label">有命中视频</span>
|
|
|
+ </div>
|
|
|
+ <div class="summary-divider" />
|
|
|
+ <div class="summary-item">
|
|
|
+ <span class="summary-value">{{ stats.videoTotal }}</span>
|
|
|
+ <span class="summary-label">命中视频</span>
|
|
|
+ </div>
|
|
|
+ <div class="summary-divider" />
|
|
|
+ <div class="summary-item">
|
|
|
+ <span class="summary-value">{{ stats.posterior }}</span>
|
|
|
+ <span class="summary-label">有后验数据</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div v-if="loading" class="page-state">
|
|
|
+ <span class="state-spinner" />
|
|
|
+ 正在整理今日需求与视频…
|
|
|
+ </div>
|
|
|
+ <div v-else-if="error" class="page-state error-state">
|
|
|
+ <strong>数据加载失败</strong>
|
|
|
+ <span>{{ error }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <main v-else class="workspace-grid">
|
|
|
+ <section class="panel demand-panel" aria-label="需求队列">
|
|
|
+ <div class="panel-head">
|
|
|
+ <div>
|
|
|
+ <span class="step-index">01</span>
|
|
|
+ <h2>选择需求</h2>
|
|
|
+ </div>
|
|
|
+ <span class="result-count">{{ filteredDemands.length }} 条</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="demand-tools">
|
|
|
+ <label class="search-box">
|
|
|
+ <span class="search-icon" aria-hidden="true" />
|
|
|
+ <input
|
|
|
+ v-model="search"
|
|
|
+ type="search"
|
|
|
+ placeholder="搜索需求、分类或策略"
|
|
|
+ aria-label="搜索需求、分类或策略"
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ v-if="search"
|
|
|
+ type="button"
|
|
|
+ class="clear-search"
|
|
|
+ aria-label="清空搜索"
|
|
|
+ @click="search = ''"
|
|
|
+ >
|
|
|
+ ×
|
|
|
+ </button>
|
|
|
+ </label>
|
|
|
+
|
|
|
+ <div class="grade-filter" aria-label="按等级筛选">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ :class="{ active: gradeFilter === 'all' }"
|
|
|
+ @click="gradeFilter = 'all'"
|
|
|
+ >
|
|
|
+ 全部 <span>{{ gradeCounts.all }}</span>
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ v-for="grade in GRADE_ORDER"
|
|
|
+ :key="grade"
|
|
|
+ type="button"
|
|
|
+ :class="{ active: gradeFilter === grade }"
|
|
|
+ @click="gradeFilter = grade"
|
|
|
+ >
|
|
|
+ {{ grade }} <span>{{ gradeCounts[grade] }}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <select v-model="supplyFilter" class="supply-filter" aria-label="按线索状态筛选">
|
|
|
+ <option value="all">全部线索状态</option>
|
|
|
+ <option value="with-video">有命中视频</option>
|
|
|
+ <option value="posterior">有后验数据</option>
|
|
|
+ <option value="no-video">等待寻找视频</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="filteredDemands.length" class="demand-list">
|
|
|
+ <button
|
|
|
+ v-for="item in visibleDemands"
|
|
|
+ :key="item.id"
|
|
|
+ type="button"
|
|
|
+ class="demand-card"
|
|
|
+ :class="{ selected: item.id === selectedDemandId }"
|
|
|
+ @click="selectedDemandId = item.id"
|
|
|
+ >
|
|
|
+ <div class="demand-card-top">
|
|
|
+ <span class="grade-badge" :class="gradeClass(item.grade)">{{ item.grade }}</span>
|
|
|
+ <span v-if="item.category_names[0]" class="category-name">
|
|
|
+ {{ item.category_names[0] }}
|
|
|
+ </span>
|
|
|
+ <span v-if="item.has_posterior" class="posterior-tag">有后验</span>
|
|
|
+ </div>
|
|
|
+ <strong>{{ item.demand_name }}</strong>
|
|
|
+ <div class="demand-card-bottom">
|
|
|
+ <span>需求分 {{ formatScore(item.score) }}</span>
|
|
|
+ <span class="dot-sep">·</span>
|
|
|
+ <span :class="{ muted: item.video_count === 0 }">
|
|
|
+ {{ item.video_count ? `${item.video_count} 条命中视频` : '暂无命中' }}
|
|
|
+ </span>
|
|
|
+ <span class="card-arrow">›</span>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ v-if="visibleDemands.length < filteredDemands.length"
|
|
|
+ type="button"
|
|
|
+ class="load-more"
|
|
|
+ @click="visibleDemandLimit += 80"
|
|
|
+ >
|
|
|
+ 再显示 80 条
|
|
|
+ <span>已显示 {{ visibleDemands.length }} / {{ filteredDemands.length }}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div v-else class="empty-state compact">
|
|
|
+ <span class="empty-mark">⌕</span>
|
|
|
+ <strong>没有符合条件的需求</strong>
|
|
|
+ <span>换一个等级或线索状态试试</span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="panel video-panel" aria-label="候选视频">
|
|
|
+ <div class="panel-head">
|
|
|
+ <div>
|
|
|
+ <span class="step-index">02</span>
|
|
|
+ <h2>需求拓展视频</h2>
|
|
|
+ </div>
|
|
|
+ <span v-if="detail" class="result-count">{{ filteredVideos.length }} 条</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="selectedDemand" class="demand-brief">
|
|
|
+ <div class="brief-title-row">
|
|
|
+ <span class="grade-badge large" :class="gradeClass(selectedDemand.grade)">
|
|
|
+ {{ selectedDemand.grade }}
|
|
|
+ </span>
|
|
|
+ <div>
|
|
|
+ <span class="brief-label">当前寻找需求</span>
|
|
|
+ <h3>{{ selectedDemand.demand_name }}</h3>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="copy-button"
|
|
|
+ @click="copyText(selectedDemand.demand_name, 'demand')"
|
|
|
+ >
|
|
|
+ {{ copiedKey === 'demand' ? '已复制' : '复制需求' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p v-if="selectedDemand.reason" class="brief-reason">
|
|
|
+ {{ selectedDemand.reason }}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <div class="brief-metrics">
|
|
|
+ <div>
|
|
|
+ <span>需求得分</span>
|
|
|
+ <strong>{{ formatScore(selectedDemand.score) }}</strong>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>先验热度</span>
|
|
|
+ <strong>{{ formatScore(selectedDemand.prior_total_score) }}</strong>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>后验 ROV diff</span>
|
|
|
+ <strong :class="{ pending: !selectedDemand.has_posterior }">
|
|
|
+ {{ formatPosteriorDiff(selectedDemand.posterior_rov_avg) }}
|
|
|
+ </strong>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>样本数</span>
|
|
|
+ <strong>{{ selectedDemand.posterior_rov_count || '—' }}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="selectedDemand.strategies.length" class="strategy-row">
|
|
|
+ <span class="strategy-label">来源</span>
|
|
|
+ <span
|
|
|
+ v-for="strategy in selectedDemand.strategies"
|
|
|
+ :key="strategy"
|
|
|
+ class="strategy-chip"
|
|
|
+ >
|
|
|
+ {{ strategy }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="detailLoading" class="inline-state">
|
|
|
+ <span class="state-spinner" />
|
|
|
+ 正在关联视频与内容点位…
|
|
|
+ </div>
|
|
|
+ <div v-else-if="detailError" class="inline-state error-state">
|
|
|
+ {{ detailError }}
|
|
|
+ </div>
|
|
|
+ <template v-else-if="detail">
|
|
|
+ <div class="video-toolbar">
|
|
|
+ <label class="search-box small">
|
|
|
+ <span class="search-icon" aria-hidden="true" />
|
|
|
+ <input
|
|
|
+ v-model="videoSearch"
|
|
|
+ type="search"
|
|
|
+ placeholder="搜索视频标题、ID 或内容点"
|
|
|
+ aria-label="搜索候选视频"
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ <div v-if="detail.point_count > 0" class="point-filter">
|
|
|
+ <button
|
|
|
+ v-for="filter in availablePointFilters"
|
|
|
+ :key="filter"
|
|
|
+ type="button"
|
|
|
+ :class="{ active: pointFilter === filter }"
|
|
|
+ @click="pointFilter = filter"
|
|
|
+ >
|
|
|
+ {{ filter === 'all' ? '全部点位' : pointMeta(filter).label }}
|
|
|
+ <span>{{ pointTypeCounts[filter] }}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="filteredVideos.length" class="video-list">
|
|
|
+ <button
|
|
|
+ v-for="(video, index) in filteredVideos"
|
|
|
+ :key="video.vid"
|
|
|
+ type="button"
|
|
|
+ class="video-card"
|
|
|
+ :class="{ selected: video.vid === selectedVid }"
|
|
|
+ @click="selectedVid = video.vid"
|
|
|
+ >
|
|
|
+ <span class="video-index">{{ String(index + 1).padStart(2, '0') }}</span>
|
|
|
+ <div class="video-main">
|
|
|
+ <div class="video-title-row">
|
|
|
+ <strong>{{ video.title || `视频 ${video.vid}` }}</strong>
|
|
|
+ <span>{{ video.point_count }} 个内容点</span>
|
|
|
+ </div>
|
|
|
+ <code>{{ video.vid }}</code>
|
|
|
+ <div class="video-point-badges">
|
|
|
+ <span v-if="videoPointCount(video, 'purpose')" class="point-badge purpose">
|
|
|
+ ◎ 目的 {{ videoPointCount(video, 'purpose') }}
|
|
|
+ </span>
|
|
|
+ <span v-if="videoPointCount(video, 'key')" class="point-badge key">
|
|
|
+ ◆ 关键 {{ videoPointCount(video, 'key') }}
|
|
|
+ </span>
|
|
|
+ <span
|
|
|
+ v-if="videoPointCount(video, 'inspiration')"
|
|
|
+ class="point-badge inspiration"
|
|
|
+ >
|
|
|
+ ✦ 灵感 {{ videoPointCount(video, 'inspiration') }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <span class="video-arrow">›</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div v-else class="empty-state">
|
|
|
+ <span class="empty-mark">○</span>
|
|
|
+ <strong>{{ detail.videos.length ? '没有匹配的视频' : '这个需求暂无命中视频' }}</strong>
|
|
|
+ <span>
|
|
|
+ {{
|
|
|
+ detail.videos.length
|
|
|
+ ? '清空搜索或切换点位类型'
|
|
|
+ : 'demand_video_expansion 中暂无命中记录'
|
|
|
+ }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <aside class="panel evidence-panel" aria-label="视频内容点位">
|
|
|
+ <div class="panel-head">
|
|
|
+ <div>
|
|
|
+ <span class="step-index">03</span>
|
|
|
+ <h2>命中视频内容</h2>
|
|
|
+ </div>
|
|
|
+ <span v-if="selectedVideo" class="source-label">EXPANSION</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-if="selectedVideo">
|
|
|
+ <div class="selected-video-head">
|
|
|
+ <div class="video-kicker">当前视频</div>
|
|
|
+ <h3>{{ selectedVideo.title || `视频 ${selectedVideo.vid}` }}</h3>
|
|
|
+ <div class="video-id-row">
|
|
|
+ <code>{{ selectedVideo.vid }}</code>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ @click="copyText(selectedVideo.vid, `video-${selectedVideo.vid}`)"
|
|
|
+ >
|
|
|
+ {{ copiedKey === `video-${selectedVideo.vid}` ? '已复制' : '复制 ID' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="coverage-card">
|
|
|
+ <div class="coverage-head">
|
|
|
+ <span>点位覆盖</span>
|
|
|
+ <strong>{{ selectedVideo.point_count }} 个</strong>
|
|
|
+ </div>
|
|
|
+ <div class="coverage-track">
|
|
|
+ <span :style="{ width: progressWidth }" />
|
|
|
+ </div>
|
|
|
+ <p>结合目的、关键和灵感点判断该视频能否承接当前需求。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="selectedPointGroups.length" class="point-groups">
|
|
|
+ <section
|
|
|
+ v-for="group in selectedPointGroups"
|
|
|
+ :key="group.type"
|
|
|
+ class="point-group"
|
|
|
+ :class="`type-${group.type}`"
|
|
|
+ >
|
|
|
+ <header>
|
|
|
+ <span class="point-symbol">{{ group.meta.symbol }}</span>
|
|
|
+ <div>
|
|
|
+ <h4>{{ group.meta.label }}</h4>
|
|
|
+ <p>
|
|
|
+ {{
|
|
|
+ group.type === 'purpose'
|
|
|
+ ? '回答内容为什么值得看'
|
|
|
+ : group.type === 'key'
|
|
|
+ ? '回答内容讲清了什么'
|
|
|
+ : '回答还能向哪里延展'
|
|
|
+ }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <span class="point-count">{{ group.points.length }}</span>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <article v-for="(point, index) in group.points" :key="index" class="point-card">
|
|
|
+ <div class="point-number">{{ index + 1 }}</div>
|
|
|
+ <div>
|
|
|
+ <strong>{{ point.expanded_text }}</strong>
|
|
|
+ <p v-if="point.point_desc">{{ point.point_desc }}</p>
|
|
|
+ <p v-if="point.reason" class="point-reason">
|
|
|
+ <span>命中原因</span>{{ point.reason }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ title="复制内容点"
|
|
|
+ :aria-label="`复制内容点:${point.expanded_text}`"
|
|
|
+ @click="
|
|
|
+ copyText(
|
|
|
+ point.expanded_text,
|
|
|
+ `${group.type}-${index}`,
|
|
|
+ )
|
|
|
+ "
|
|
|
+ >
|
|
|
+ {{ copiedKey === `${group.type}-${index}` ? '✓' : '+' }}
|
|
|
+ </button>
|
|
|
+ </article>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ <div v-else class="empty-state evidence-empty">
|
|
|
+ <span class="empty-mark">◇</span>
|
|
|
+ <strong>暂无内容点位</strong>
|
|
|
+ <span>该视频没有可展示的目的点、关键点或灵感点</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div v-else class="empty-state evidence-empty">
|
|
|
+ <span class="empty-mark">→</span>
|
|
|
+ <strong>选择一条候选视频</strong>
|
|
|
+ <span>这里只展示 demand_video_expansion 中实际存在的命中视频和三类拓展点</span>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.video-discovery {
|
|
|
+ --ink: #17211b;
|
|
|
+ --muted: #6f7a73;
|
|
|
+ --line: #dfe5df;
|
|
|
+ --paper: #ffffff;
|
|
|
+ --canvas: #f3f5f1;
|
|
|
+ --green: #286448;
|
|
|
+ --green-soft: #e7f0ea;
|
|
|
+ --orange: #c86639;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16px;
|
|
|
+ color: var(--ink);
|
|
|
+}
|
|
|
+
|
|
|
+.workspace-header {
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 32px;
|
|
|
+ padding: 4px 2px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.eyebrow {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 7px;
|
|
|
+ color: var(--green);
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 800;
|
|
|
+ letter-spacing: 0.16em;
|
|
|
+}
|
|
|
+
|
|
|
+.live-dot {
|
|
|
+ width: 7px;
|
|
|
+ height: 7px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #39a16c;
|
|
|
+ box-shadow: 0 0 0 4px rgba(57, 161, 108, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+.title-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.title-row h1 {
|
|
|
+ margin: 0;
|
|
|
+ color: #16231b;
|
|
|
+ font-family: Georgia, 'Songti SC', serif;
|
|
|
+ font-size: clamp(26px, 2.2vw, 36px);
|
|
|
+ line-height: 1.08;
|
|
|
+ letter-spacing: -0.035em;
|
|
|
+}
|
|
|
+
|
|
|
+.date-pill {
|
|
|
+ padding: 5px 9px 4px;
|
|
|
+ border: 1px solid #cad5cc;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: rgba(255, 255, 255, 0.72);
|
|
|
+ color: #5d6a62;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 0.08em;
|
|
|
+}
|
|
|
+
|
|
|
+.workspace-header p {
|
|
|
+ margin: 7px 0 0;
|
|
|
+ color: var(--muted);
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.summary-strip {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ min-width: 470px;
|
|
|
+ padding: 10px 14px;
|
|
|
+ border: 1px solid rgba(205, 214, 206, 0.9);
|
|
|
+ border-radius: 14px;
|
|
|
+ background: rgba(255, 255, 255, 0.72);
|
|
|
+ box-shadow: 0 8px 28px rgba(32, 52, 40, 0.06);
|
|
|
+ backdrop-filter: blur(10px);
|
|
|
+}
|
|
|
+
|
|
|
+.summary-item {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+.summary-value {
|
|
|
+ color: #253229;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 700;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+}
|
|
|
+
|
|
|
+.summary-value.accent {
|
|
|
+ color: var(--green);
|
|
|
+}
|
|
|
+
|
|
|
+.summary-label {
|
|
|
+ color: #7d887f;
|
|
|
+ font-size: 10px;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.summary-divider {
|
|
|
+ width: 1px;
|
|
|
+ height: 27px;
|
|
|
+ background: #dde3dd;
|
|
|
+}
|
|
|
+
|
|
|
+.workspace-grid {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: minmax(280px, 0.82fr) minmax(420px, 1.34fr) minmax(320px, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.panel {
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 1px solid var(--line);
|
|
|
+ border-radius: 16px;
|
|
|
+ background: var(--paper);
|
|
|
+ box-shadow: 0 12px 32px rgba(24, 45, 31, 0.055);
|
|
|
+}
|
|
|
+
|
|
|
+.panel-head {
|
|
|
+ min-height: 53px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 0 16px;
|
|
|
+ border-bottom: 1px solid #e7ebe7;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-head > div {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 9px;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-head h2 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 800;
|
|
|
+ letter-spacing: 0.01em;
|
|
|
+}
|
|
|
+
|
|
|
+.step-index {
|
|
|
+ color: #9aa49d;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 11px;
|
|
|
+ font-style: italic;
|
|
|
+}
|
|
|
+
|
|
|
+.result-count,
|
|
|
+.source-label {
|
|
|
+ color: #7c887f;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 0.06em;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-tools {
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 9px;
|
|
|
+ padding: 12px 13px;
|
|
|
+ border-bottom: 1px solid #edf0ed;
|
|
|
+ background: #fbfcfa;
|
|
|
+}
|
|
|
+
|
|
|
+.search-box {
|
|
|
+ height: 36px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 9px;
|
|
|
+ padding: 0 11px;
|
|
|
+ border: 1px solid #d7ded8;
|
|
|
+ border-radius: 9px;
|
|
|
+ background: #fff;
|
|
|
+ transition: border-color 0.15s, box-shadow 0.15s;
|
|
|
+}
|
|
|
+
|
|
|
+.search-box:focus-within {
|
|
|
+ border-color: #6b957c;
|
|
|
+ box-shadow: 0 0 0 3px rgba(53, 110, 78, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.search-icon {
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+ position: relative;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ border: 1.5px solid #78827b;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+
|
|
|
+.search-icon::after {
|
|
|
+ content: '';
|
|
|
+ width: 5px;
|
|
|
+ height: 1.5px;
|
|
|
+ position: absolute;
|
|
|
+ right: -4px;
|
|
|
+ bottom: -2px;
|
|
|
+ transform: rotate(45deg);
|
|
|
+ border-radius: 2px;
|
|
|
+ background: #78827b;
|
|
|
+}
|
|
|
+
|
|
|
+.search-box input {
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
+ border: 0;
|
|
|
+ outline: 0;
|
|
|
+ background: transparent;
|
|
|
+ color: var(--ink);
|
|
|
+ font: inherit;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.search-box input::placeholder {
|
|
|
+ color: #a2aaa4;
|
|
|
+}
|
|
|
+
|
|
|
+.clear-search {
|
|
|
+ border: 0;
|
|
|
+ background: transparent;
|
|
|
+ color: #9aa39c;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-filter {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
|
+ gap: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-filter button,
|
|
|
+.point-filter button {
|
|
|
+ border: 1px solid #dce2dc;
|
|
|
+ border-radius: 7px;
|
|
|
+ background: #fff;
|
|
|
+ color: #5c675f;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: 0.15s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-filter button {
|
|
|
+ height: 29px;
|
|
|
+ padding: 0 3px;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-filter button span,
|
|
|
+.point-filter button span {
|
|
|
+ color: #a0a9a2;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-filter button:hover,
|
|
|
+.point-filter button:hover {
|
|
|
+ border-color: #9bb2a3;
|
|
|
+ color: var(--green);
|
|
|
+}
|
|
|
+
|
|
|
+.grade-filter button.active,
|
|
|
+.point-filter button.active {
|
|
|
+ border-color: #315f47;
|
|
|
+ background: #315f47;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-filter button.active span,
|
|
|
+.point-filter button.active span {
|
|
|
+ color: rgba(255, 255, 255, 0.7);
|
|
|
+}
|
|
|
+
|
|
|
+.supply-filter {
|
|
|
+ width: 100%;
|
|
|
+ height: 31px;
|
|
|
+ padding: 0 9px;
|
|
|
+ border: 1px solid #dce2dc;
|
|
|
+ border-radius: 7px;
|
|
|
+ outline: 0;
|
|
|
+ background: #fff;
|
|
|
+ color: #5d685f;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-list,
|
|
|
+.video-list,
|
|
|
+.point-groups {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+ overscroll-behavior: contain;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-list {
|
|
|
+ padding: 7px;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-card {
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 7px;
|
|
|
+ padding: 11px 11px 10px;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ border-bottom-color: #edf0ed;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: transparent;
|
|
|
+ color: inherit;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: 0.16s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.load-more {
|
|
|
+ width: calc(100% - 8px);
|
|
|
+ height: 35px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin: 7px 4px 3px;
|
|
|
+ border: 1px dashed #cfd8d1;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fafbfa;
|
|
|
+ color: #53705d;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.load-more:hover {
|
|
|
+ border-color: #94ad9b;
|
|
|
+ background: #f2f6f3;
|
|
|
+}
|
|
|
+
|
|
|
+.load-more span {
|
|
|
+ color: #9aa39c;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-card:hover {
|
|
|
+ border-color: #dce5de;
|
|
|
+ background: #f8faf7;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-card.selected {
|
|
|
+ border-color: #b7cbbb;
|
|
|
+ background: var(--green-soft);
|
|
|
+ box-shadow: inset 3px 0 #3b7856;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-card-top,
|
|
|
+.demand-card-bottom {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 7px;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-card strong {
|
|
|
+ color: #202b24;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-badge {
|
|
|
+ min-width: 22px;
|
|
|
+ height: 20px;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 0 6px;
|
|
|
+ border-radius: 5px;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 900;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-badge.large {
|
|
|
+ min-width: 31px;
|
|
|
+ height: 31px;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-S {
|
|
|
+ background: #f6dfd6;
|
|
|
+ color: #a84726;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-A {
|
|
|
+ background: #faead5;
|
|
|
+ color: #a86120;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-B {
|
|
|
+ background: #ece8c9;
|
|
|
+ color: #756917;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-C {
|
|
|
+ background: #dcebe3;
|
|
|
+ color: #2d7150;
|
|
|
+}
|
|
|
+
|
|
|
+.grade-D,
|
|
|
+.grade-none {
|
|
|
+ background: #e9ece9;
|
|
|
+ color: #6f7771;
|
|
|
+}
|
|
|
+
|
|
|
+.category-name {
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #748077;
|
|
|
+ font-size: 10px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.posterior-tag {
|
|
|
+ margin-left: auto;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #dcece2;
|
|
|
+ color: #2f7151;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-card-bottom {
|
|
|
+ color: #7b857e;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-card-bottom .muted {
|
|
|
+ color: #a48b78;
|
|
|
+}
|
|
|
+
|
|
|
+.dot-sep {
|
|
|
+ color: #bbc1bc;
|
|
|
+}
|
|
|
+
|
|
|
+.card-arrow {
|
|
|
+ margin-left: auto;
|
|
|
+ color: #97a199;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.demand-brief {
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 15px 17px 13px;
|
|
|
+ border-bottom: 1px solid #e6ebe6;
|
|
|
+ background:
|
|
|
+ radial-gradient(circle at 100% 0, rgba(190, 215, 198, 0.38), transparent 42%),
|
|
|
+ #f5f8f4;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-title-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-title-row > div {
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-label {
|
|
|
+ display: block;
|
|
|
+ margin-bottom: 2px;
|
|
|
+ color: #7e8981;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 0.12em;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-title-row h3 {
|
|
|
+ margin: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #1e2b23;
|
|
|
+ font-family: Georgia, 'Songti SC', serif;
|
|
|
+ font-size: 17px;
|
|
|
+ line-height: 1.25;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.copy-button {
|
|
|
+ height: 29px;
|
|
|
+ margin-left: auto;
|
|
|
+ padding: 0 9px;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ border: 1px solid #cbd6cd;
|
|
|
+ border-radius: 7px;
|
|
|
+ background: rgba(255, 255, 255, 0.76);
|
|
|
+ color: #42634e;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-reason {
|
|
|
+ display: -webkit-box;
|
|
|
+ margin: 10px 0;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #647169;
|
|
|
+ font-size: 11px;
|
|
|
+ line-height: 1.55;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-metrics {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-metrics > div {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 1px;
|
|
|
+ padding: 7px 8px;
|
|
|
+ border: 1px solid rgba(207, 218, 209, 0.9);
|
|
|
+ border-radius: 7px;
|
|
|
+ background: rgba(255, 255, 255, 0.7);
|
|
|
+}
|
|
|
+
|
|
|
+.brief-metrics span {
|
|
|
+ color: #879189;
|
|
|
+ font-size: 9px;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-metrics strong {
|
|
|
+ color: #2d3d32;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 13px;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+}
|
|
|
+
|
|
|
+.brief-metrics strong.pending {
|
|
|
+ color: #9c836e;
|
|
|
+ font-family: inherit;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+
|
|
|
+.strategy-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 5px;
|
|
|
+ margin-top: 9px;
|
|
|
+}
|
|
|
+
|
|
|
+.strategy-label {
|
|
|
+ color: #879189;
|
|
|
+ font-size: 9px;
|
|
|
+}
|
|
|
+
|
|
|
+.strategy-chip {
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #e4ebe5;
|
|
|
+ color: #56675a;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.video-toolbar {
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 10px 12px 9px;
|
|
|
+ border-bottom: 1px solid #edf0ed;
|
|
|
+}
|
|
|
+
|
|
|
+.search-box.small {
|
|
|
+ height: 33px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-filter {
|
|
|
+ display: flex;
|
|
|
+ gap: 5px;
|
|
|
+ margin-top: 7px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-filter button {
|
|
|
+ height: 27px;
|
|
|
+ padding: 0 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-list {
|
|
|
+ padding: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-card {
|
|
|
+ width: 100%;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 28px minmax(0, 1fr) 16px;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 11px 10px;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ border-bottom-color: #ebefeb;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: transparent;
|
|
|
+ color: inherit;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: 0.15s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.video-card:hover {
|
|
|
+ border-color: #dfe7e0;
|
|
|
+ background: #fafbfa;
|
|
|
+}
|
|
|
+
|
|
|
+.video-card.selected {
|
|
|
+ border-color: #b8ccbd;
|
|
|
+ background: #edf4ef;
|
|
|
+}
|
|
|
+
|
|
|
+.video-index {
|
|
|
+ width: 27px;
|
|
|
+ height: 27px;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ border: 1px solid #d8dfd9;
|
|
|
+ border-radius: 50%;
|
|
|
+ color: #8a948c;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-card.selected .video-index {
|
|
|
+ border-color: #57906e;
|
|
|
+ background: #397354;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.video-main {
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.video-title-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: baseline;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-title-row strong {
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #243028;
|
|
|
+ font-size: 12px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.video-title-row > span {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ color: #879189;
|
|
|
+ font-size: 9px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-main code,
|
|
|
+.video-id-row code {
|
|
|
+ display: block;
|
|
|
+ margin: 3px 0 7px;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #919a93;
|
|
|
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
|
+ font-size: 9px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.video-point-badges {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-badge {
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 5px;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.point-badge.purpose {
|
|
|
+ background: #dfe9f1;
|
|
|
+ color: #3c6682;
|
|
|
+}
|
|
|
+
|
|
|
+.point-badge.key {
|
|
|
+ background: #e6e0ef;
|
|
|
+ color: #665083;
|
|
|
+}
|
|
|
+
|
|
|
+.point-badge.inspiration {
|
|
|
+ background: #f5e5d9;
|
|
|
+ color: #a1532e;
|
|
|
+}
|
|
|
+
|
|
|
+.video-arrow {
|
|
|
+ color: #9ba49d;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 19px;
|
|
|
+}
|
|
|
+
|
|
|
+.evidence-panel {
|
|
|
+ background:
|
|
|
+ linear-gradient(180deg, rgba(248, 250, 247, 0.96), #fff 150px),
|
|
|
+ #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.selected-video-head {
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 16px 17px 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-kicker {
|
|
|
+ margin-bottom: 5px;
|
|
|
+ color: #758078;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 800;
|
|
|
+ letter-spacing: 0.13em;
|
|
|
+}
|
|
|
+
|
|
|
+.selected-video-head h3 {
|
|
|
+ margin: 0 0 9px;
|
|
|
+ color: #202c24;
|
|
|
+ font-family: Georgia, 'Songti SC', serif;
|
|
|
+ font-size: 16px;
|
|
|
+ line-height: 1.42;
|
|
|
+}
|
|
|
+
|
|
|
+.video-id-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.video-id-row code {
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.video-id-row button {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ padding: 3px 7px;
|
|
|
+ border: 1px solid #d9dfda;
|
|
|
+ border-radius: 5px;
|
|
|
+ background: #fff;
|
|
|
+ color: #68736b;
|
|
|
+ font-size: 9px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.coverage-card {
|
|
|
+ flex-shrink: 0;
|
|
|
+ margin: 0 12px 11px;
|
|
|
+ padding: 10px 11px;
|
|
|
+ border: 1px solid #dce4dd;
|
|
|
+ border-radius: 9px;
|
|
|
+ background: #f7f9f6;
|
|
|
+}
|
|
|
+
|
|
|
+.coverage-head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ color: #69756d;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.coverage-head strong {
|
|
|
+ color: #315f47;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+
|
|
|
+.coverage-track {
|
|
|
+ height: 4px;
|
|
|
+ margin: 7px 0;
|
|
|
+ overflow: hidden;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #dfe5df;
|
|
|
+}
|
|
|
+
|
|
|
+.coverage-track span {
|
|
|
+ display: block;
|
|
|
+ height: 100%;
|
|
|
+ border-radius: inherit;
|
|
|
+ background: linear-gradient(90deg, #315f47, #82aa8f);
|
|
|
+}
|
|
|
+
|
|
|
+.coverage-card p {
|
|
|
+ margin: 0;
|
|
|
+ color: #8a948c;
|
|
|
+ font-size: 9px;
|
|
|
+ line-height: 1.45;
|
|
|
+}
|
|
|
+
|
|
|
+.point-groups {
|
|
|
+ padding: 0 12px 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-group + .point-group {
|
|
|
+ margin-top: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-group > header {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 26px minmax(0, 1fr) auto;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 7px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-symbol {
|
|
|
+ width: 25px;
|
|
|
+ height: 25px;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ border-radius: 7px;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-group h4 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-group header p {
|
|
|
+ margin: 1px 0 0;
|
|
|
+ color: #929a94;
|
|
|
+ font-size: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-count {
|
|
|
+ min-width: 19px;
|
|
|
+ height: 19px;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #eef1ee;
|
|
|
+ color: #788179;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 800;
|
|
|
+}
|
|
|
+
|
|
|
+.type-purpose .point-symbol {
|
|
|
+ background: #dfe9f1;
|
|
|
+ color: #3c6682;
|
|
|
+}
|
|
|
+
|
|
|
+.type-key .point-symbol {
|
|
|
+ background: #e6e0ef;
|
|
|
+ color: #665083;
|
|
|
+}
|
|
|
+
|
|
|
+.type-inspiration .point-symbol {
|
|
|
+ background: #f5e5d9;
|
|
|
+ color: #a1532e;
|
|
|
+}
|
|
|
+
|
|
|
+.point-card {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 20px minmax(0, 1fr) 23px;
|
|
|
+ align-items: start;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 9px 7px;
|
|
|
+ border-top: 1px solid #edf0ed;
|
|
|
+}
|
|
|
+
|
|
|
+.point-number {
|
|
|
+ padding-top: 1px;
|
|
|
+ color: #a0a8a2;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 10px;
|
|
|
+ font-style: italic;
|
|
|
+}
|
|
|
+
|
|
|
+.point-card strong {
|
|
|
+ display: block;
|
|
|
+ color: #29352d;
|
|
|
+ font-size: 11px;
|
|
|
+ line-height: 1.45;
|
|
|
+}
|
|
|
+
|
|
|
+.point-card p {
|
|
|
+ margin: 4px 0 0;
|
|
|
+ color: #78827b;
|
|
|
+ font-size: 10px;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+
|
|
|
+.point-card .point-reason {
|
|
|
+ padding-top: 6px;
|
|
|
+ border-top: 1px dashed #e4e9e5;
|
|
|
+ color: #66736a;
|
|
|
+}
|
|
|
+
|
|
|
+.point-reason span {
|
|
|
+ display: inline-block;
|
|
|
+ margin-right: 5px;
|
|
|
+ color: #3e6d52;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 800;
|
|
|
+}
|
|
|
+
|
|
|
+.point-card button {
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ padding: 0;
|
|
|
+ border: 1px solid #dbe1dc;
|
|
|
+ border-radius: 5px;
|
|
|
+ background: #fff;
|
|
|
+ color: #738078;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.page-state,
|
|
|
+.inline-state,
|
|
|
+.empty-state {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 9px;
|
|
|
+ color: #7b867e;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.page-state {
|
|
|
+ flex-direction: column;
|
|
|
+ border: 1px solid var(--line);
|
|
|
+ border-radius: 16px;
|
|
|
+ background: rgba(255, 255, 255, 0.72);
|
|
|
+}
|
|
|
+
|
|
|
+.inline-state {
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.error-state {
|
|
|
+ color: #a04a35;
|
|
|
+}
|
|
|
+
|
|
|
+.error-state span {
|
|
|
+ color: #8b756d;
|
|
|
+}
|
|
|
+
|
|
|
+.state-spinner {
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ border: 2px solid #dce3dd;
|
|
|
+ border-top-color: #3d7556;
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin 0.8s linear infinite;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state {
|
|
|
+ flex-direction: column;
|
|
|
+ padding: 26px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state.compact {
|
|
|
+ padding: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state strong {
|
|
|
+ color: #657069;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state span:not(.empty-mark) {
|
|
|
+ max-width: 260px;
|
|
|
+ color: #9aa29c;
|
|
|
+ font-size: 10px;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-mark {
|
|
|
+ width: 35px;
|
|
|
+ height: 35px;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ border: 1px solid #dfe5e0;
|
|
|
+ border-radius: 50%;
|
|
|
+ color: #879189;
|
|
|
+ font-family: Georgia, serif;
|
|
|
+ font-size: 17px;
|
|
|
+}
|
|
|
+
|
|
|
+.evidence-empty {
|
|
|
+ margin: 10px;
|
|
|
+ border: 1px dashed #dce3dd;
|
|
|
+ border-radius: 12px;
|
|
|
+ background: rgba(249, 250, 248, 0.72);
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes spin {
|
|
|
+ to {
|
|
|
+ transform: rotate(360deg);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 1180px) {
|
|
|
+ .workspace-grid {
|
|
|
+ grid-template-columns: minmax(260px, 0.8fr) minmax(410px, 1.25fr);
|
|
|
+ }
|
|
|
+
|
|
|
+ .evidence-panel {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 820px) {
|
|
|
+ .video-discovery {
|
|
|
+ height: auto;
|
|
|
+ min-height: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .workspace-header {
|
|
|
+ align-items: stretch;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .summary-strip {
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .workspace-grid {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ }
|
|
|
+
|
|
|
+ .panel {
|
|
|
+ min-height: 520px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .evidence-panel {
|
|
|
+ display: flex;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|