333 lines
14 KiB
Vue
333 lines
14 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center space-x-4">
|
|
<button @click="goBack" class="text-gray-400 hover:text-white">
|
|
← 返回
|
|
</button>
|
|
<div>
|
|
<h2 class="text-2xl font-bold">Chunk Detail</h2>
|
|
<p class="text-sm text-gray-400">{{ chunkId }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="text-center py-12 text-gray-500">載入中...</div>
|
|
|
|
<!-- Content -->
|
|
<div v-else-if="detail" class="grid gap-6">
|
|
<!-- Basic Info Card -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold text-blue-400 mb-4">基本資訊</h3>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
<div>
|
|
<span class="text-gray-500">Chunk Type</span>
|
|
<p class="text-white">{{ detail.chunk_type }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">Parent ID</span>
|
|
<p class="text-white">{{ detail.parent_id || '-' }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">Duration</span>
|
|
<p class="text-white">{{ detail.frame_range.duration_frames }} frames</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">FPS</span>
|
|
<p class="text-white">{{ detail.frame_range.fps }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Timecode Card -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold text-green-400 mb-4">時間軸</h3>
|
|
<div class="grid grid-cols-2 gap-6">
|
|
<!-- Frame Range -->
|
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Frame Range (精確)</h4>
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<span class="text-xs text-gray-500">Start</span>
|
|
<p class="text-xl font-mono text-white">{{ detail.frame_range.start_frame }}</p>
|
|
</div>
|
|
<div class="text-gray-600">→</div>
|
|
<div class="text-right">
|
|
<span class="text-xs text-gray-500">End</span>
|
|
<p class="text-xl font-mono text-white">{{ detail.frame_range.end_frame }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reference Time -->
|
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Time (參考)</h4>
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<span class="text-xs text-gray-500">Start</span>
|
|
<p class="text-xl font-mono text-white">{{ detail.reference_time.start.toFixed(2) }}s</p>
|
|
</div>
|
|
<div class="text-gray-600">→</div>
|
|
<div class="text-right">
|
|
<span class="text-xs text-gray-500">End</span>
|
|
<p class="text-xl font-mono text-white">{{ detail.reference_time.end.toFixed(2) }}s</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Text Content Card -->
|
|
<div v-if="detail.text_content" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold text-purple-400 mb-4">文字內容</h3>
|
|
<p class="text-lg leading-relaxed whitespace-pre-wrap">{{ detail.text_content }}</p>
|
|
<TranslatableText :text="detail.text_content" />
|
|
</div>
|
|
|
|
<!-- Summary Text Card -->
|
|
<div v-if="detail.summary_text" class="bg-gray-800 rounded-lg p-6 border border-green-800">
|
|
<h3 class="text-lg font-semibold text-green-400 mb-4">區塊摘要 (Summary)</h3>
|
|
<p class="text-lg leading-relaxed text-white italic">"{{ detail.summary_text }}"</p>
|
|
<TranslatableText :text="detail.summary_text" />
|
|
</div>
|
|
|
|
<!-- 5W1H Metadata Card -->
|
|
<div v-if="detail.metadata?.structured_summary" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold text-yellow-400 mb-4">5W1H 分析結果</h3>
|
|
|
|
<!-- Meta info (compact row) -->
|
|
<div class="flex flex-wrap gap-4 mb-4 text-sm">
|
|
<div v-if="detail.metadata.auto_generated_by" class="flex items-center space-x-2 bg-gray-900 px-3 py-1 rounded">
|
|
<span class="text-gray-500">Generated by</span>
|
|
<span class="text-blue-400 font-medium">{{ detail.metadata.auto_generated_by }}</span>
|
|
</div>
|
|
<div v-if="detail.metadata.chunk_count" class="flex items-center space-x-2 bg-gray-900 px-3 py-1 rounded">
|
|
<span class="text-gray-500">Chunk count</span>
|
|
<span class="text-green-400 font-medium">{{ detail.metadata.chunk_count }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 5W1H Grid (main analysis from structured_summary) -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div v-for="(value, key) in structuredSummary" :key="key" class="bg-gray-900 p-4 rounded border border-gray-600">
|
|
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider">{{ formatKey(key) }}</span>
|
|
<p class="text-white mt-1">{{ formatMetadataValue(value) }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary 5 lines (if exists) -->
|
|
<div v-if="detail.metadata.structured_summary?.summary_5lines" class="mt-4 bg-gray-900 p-4 rounded border border-gray-600">
|
|
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider">Summary</span>
|
|
<p class="text-white mt-2 whitespace-pre-line">{{ detail.metadata.structured_summary.summary_5lines }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="detail.metadata" class="bg-gray-800 rounded-lg p-6 border border-gray-700 opacity-60">
|
|
<h3 class="text-lg font-semibold text-gray-400 mb-2">5W1H 分析結果</h3>
|
|
<p class="text-gray-500 text-sm">此片段已有 metadata 但缺少 structured_summary。</p>
|
|
</div>
|
|
<div v-else class="bg-gray-800 rounded-lg p-6 border border-gray-700 opacity-60">
|
|
<h3 class="text-lg font-semibold text-gray-400 mb-2">5W1H 分析結果</h3>
|
|
<p class="text-gray-500 text-sm">此片段尚未關聯到 5W1H 分析區塊 (Parent Chunk)。</p>
|
|
</div>
|
|
|
|
<!-- Visual Stats Card -->
|
|
<div v-if="hasVisualStats" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold text-cyan-400 mb-4">視覺分析 (Visual Stats)</h3>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<!-- YOLO Objects -->
|
|
<div v-if="visualStats.yolo" class="bg-gray-900 p-4 rounded border border-gray-600">
|
|
<div class="flex items-center space-x-2 mb-2">
|
|
<span class="text-cyan-400">📦</span>
|
|
<span class="text-sm font-semibold text-gray-300">YOLO Objects</span>
|
|
</div>
|
|
<div v-if="visualStats.yolo.objects?.length" class="space-y-1">
|
|
<div v-for="obj in visualStats.yolo.objects.slice(0, 5)" :key="obj.class" class="flex justify-between text-sm">
|
|
<span class="text-gray-400">{{ obj.class }}</span>
|
|
<span class="text-white">{{ obj.count }}</span>
|
|
</div>
|
|
<div v-if="visualStats.yolo.objects.length > 5" class="text-xs text-gray-500">
|
|
+{{ visualStats.yolo.objects.length - 5 }} more
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-sm text-gray-500">無物件數據</div>
|
|
</div>
|
|
|
|
<!-- Pose Results -->
|
|
<div v-if="visualStats.pose" class="bg-gray-900 p-4 rounded border border-gray-600">
|
|
<div class="flex items-center space-x-2 mb-2">
|
|
<span class="text-purple-400">🧍</span>
|
|
<span class="text-sm font-semibold text-gray-300">Pose</span>
|
|
</div>
|
|
<div v-if="visualStats.pose.persons?.length" class="space-y-1">
|
|
<div class="text-sm text-white">{{ visualStats.pose.persons.length }} persons detected</div>
|
|
<div v-if="visualStats.pose.keypoints" class="text-xs text-gray-400">
|
|
{{ visualStats.pose.keypoints }} keypoints
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-sm text-gray-500">無姿態數據</div>
|
|
</div>
|
|
|
|
<!-- Face Results -->
|
|
<div v-if="visualStats.face" class="bg-gray-900 p-4 rounded border border-gray-600">
|
|
<div class="flex items-center space-x-2 mb-2">
|
|
<span class="text-yellow-400">👤</span>
|
|
<span class="text-sm font-semibold text-gray-300">Faces</span>
|
|
</div>
|
|
<div v-if="visualStats.face.faces?.length" class="space-y-1">
|
|
<div class="text-sm text-white">{{ visualStats.face.faces.length }} faces detected</div>
|
|
<div v-if="visualStats.face.identities" class="text-xs text-gray-400">
|
|
{{ visualStats.face.identities }} identified
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-sm text-gray-500">無面部數據</div>
|
|
</div>
|
|
|
|
<!-- OCR Results -->
|
|
<div v-if="visualStats.ocr" class="bg-gray-900 p-4 rounded border border-gray-600">
|
|
<div class="flex items-center space-x-2 mb-2">
|
|
<span class="text-green-400">📝</span>
|
|
<span class="text-sm font-semibold text-gray-300">OCR</span>
|
|
</div>
|
|
<div v-if="visualStats.ocr.texts?.length" class="space-y-1">
|
|
<div v-for="text in visualStats.ocr.texts.slice(0, 3)" :key="text" class="text-sm text-gray-300 truncate">
|
|
"{{ text }}"
|
|
</div>
|
|
<div v-if="visualStats.ocr.texts.length > 3" class="text-xs text-gray-500">
|
|
+{{ visualStats.ocr.texts.length - 3 }} more
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-sm text-gray-500">無文字數據</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="bg-gray-800 rounded-lg p-6 border border-gray-700 opacity-60">
|
|
<h3 class="text-lg font-semibold text-gray-400 mb-2">視覺分析 (Visual Stats)</h3>
|
|
<p class="text-gray-500 text-sm">此片段尚無視覺分析數據 (YOLO、Pose、Face、OCR)。</p>
|
|
</div>
|
|
|
|
<!-- Raw Content Card -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold text-gray-400 mb-4">原始內容 (Raw Content)</h3>
|
|
<pre class="bg-gray-900 p-4 rounded overflow-x-auto text-xs text-gray-300">{{ JSON.stringify(detail.content, null, 2) }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-else-if="error" class="text-center py-12 text-red-400">
|
|
{{ error }}
|
|
</div>
|
|
<div v-else class="text-center py-12 text-gray-500">
|
|
無法載入詳情
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import TranslatableText from '@/components/TranslatableText.vue'
|
|
import { httpFetch, getCurrentConfig } from '@/api/client'
|
|
|
|
const route = useRoute()
|
|
const chunkId = ref('')
|
|
const detail = ref<any>(null)
|
|
const error = ref('')
|
|
const loading = ref(false)
|
|
|
|
const loadDetail = async () => {
|
|
const uuid = route.params.file_uuid as string
|
|
chunkId.value = route.params.chunk_id as string
|
|
loading.value = true
|
|
|
|
try {
|
|
const config = getCurrentConfig()
|
|
const url = `${config.api_base_url}/api/v1/file/${uuid}/chunk/${chunkId.value}`
|
|
|
|
const res = await httpFetch<any>(url)
|
|
|
|
if (res && res.chunk_id) {
|
|
detail.value = res
|
|
} else {
|
|
detail.value = null
|
|
}
|
|
} catch (err) {
|
|
error.value = '載入失敗: ' + (err as any)?.message || String(err)
|
|
console.error('Failed to load chunk detail:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const structuredSummary = computed(() => {
|
|
if (!detail.value?.metadata?.structured_summary) return {}
|
|
const excludedKeys = ['summary_5lines']
|
|
const result: Record<string, any> = {}
|
|
for (const [key, value] of Object.entries(detail.value.metadata.structured_summary)) {
|
|
if (!excludedKeys.includes(key)) {
|
|
result[key] = value
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
|
|
const visualStats = computed(() => {
|
|
if (!detail.value?.visual_stats) return {}
|
|
return detail.value.visual_stats
|
|
})
|
|
|
|
const hasVisualStats = computed(() => {
|
|
if (!detail.value?.visual_stats) return false
|
|
const vs = detail.value.visual_stats
|
|
return vs.yolo || vs.pose || vs.face || vs.ocr ||
|
|
(vs.objects && vs.objects.length > 0) ||
|
|
(vs.persons && vs.persons.length > 0) ||
|
|
(vs.faces && vs.faces.length > 0) ||
|
|
(vs.texts && vs.texts.length > 0)
|
|
})
|
|
|
|
const formatKey = (key: string): string => {
|
|
const keyMap: Record<string, string> = {
|
|
who: 'Who (人物)',
|
|
what: 'What (事件)',
|
|
when: 'When (時間)',
|
|
where: 'Where (地點)',
|
|
why: 'Why (原因)',
|
|
how: 'How (方式)',
|
|
tone: 'Tone (語氣)',
|
|
characters: 'Characters',
|
|
key_events: 'Key Events'
|
|
}
|
|
return keyMap[key] || key
|
|
}
|
|
|
|
const formatMetadataValue = (value: any): string => {
|
|
if (Array.isArray(value)) {
|
|
return value.join(', ')
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
return JSON.stringify(value)
|
|
}
|
|
return String(value || '-')
|
|
}
|
|
|
|
const router = useRouter()
|
|
|
|
const goBack = () => {
|
|
const saved = localStorage.getItem('searchState')
|
|
if (saved) {
|
|
try {
|
|
const data = JSON.parse(saved)
|
|
localStorage.removeItem('searchState')
|
|
router.push({ name: 'search', query: { q: data.query } })
|
|
return
|
|
} catch { /* ignore */ }
|
|
}
|
|
router.push('/files')
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadDetail()
|
|
})
|
|
</script>
|