|
|
@@ -14,6 +14,27 @@ import type {
|
|
|
} from '../types/videoDiscoveryRecords'
|
|
|
|
|
|
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 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 detailPage = computed(() => tab.value === 'searches' ? searchPage.value : candidatePage.value)
|
|
|
const detailPages = computed(() => Math.max(1, Math.ceil(detailTotal.value / PAGE_SIZE)))
|
|
|
+const pointContext = computed(() => buildPointContext(selected.value?.relevant_points))
|
|
|
|
|
|
onMounted(() => loadRuns())
|
|
|
|
|
|
@@ -262,6 +284,79 @@ function formatScore(value: number | null): string {
|
|
|
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: {
|
|
|
like_count: number | null
|
|
|
comment_count: number | null
|
|
|
@@ -401,9 +496,66 @@ function openCandidate(candidate: VideoDiscoveryCandidateRecord) {
|
|
|
<strong>停止原因</strong>
|
|
|
<p>{{ selected.stop_reason }}</p>
|
|
|
</div>
|
|
|
- <div v-if="selected.relevant_points">
|
|
|
+ <div v-if="pointContext">
|
|
|
<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>
|
|
|
</details>
|
|
|
</div>
|
|
|
@@ -982,6 +1134,136 @@ button, input, select { font: inherit; }
|
|
|
.run-context summary { color: #6262cc; cursor: pointer; }
|
|
|
.run-context strong { display: block; margin-top: 8px; color: #606878; }
|
|
|
.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 {
|
|
|
max-height: 210px;
|
|
|
margin: 6px 0 0;
|