367 lines
13 KiB
Vue
367 lines
13 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<!-- Header with Actions -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center space-x-4">
|
|
<button @click="goBack" class="flex items-center space-x-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition">
|
|
<span class="text-xl">←</span>
|
|
<span>返回納管檔案列表</span>
|
|
</button>
|
|
<h2 class="text-2xl font-bold">
|
|
{{ video?.file_name || '檔案詳情' }}
|
|
<span v-if="video?.file_type" class="ml-2 text-sm px-2 py-1 bg-blue-900 text-blue-200 rounded uppercase">
|
|
{{ video.file_type }}
|
|
</span>
|
|
</h2>
|
|
</div>
|
|
<div class="flex items-center space-x-3">
|
|
<button
|
|
v-if="video && !video.registration_time"
|
|
@click="handleRegister"
|
|
:disabled="actionLoading"
|
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition"
|
|
>
|
|
{{ actionLoading ? '處理中...' : '立即註冊' }}
|
|
</button>
|
|
<button
|
|
v-if="video && video.registration_time"
|
|
@click="handleUnregister"
|
|
:disabled="actionLoading"
|
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition"
|
|
>
|
|
{{ actionLoading ? '處理中...' : '取消註冊' }}
|
|
</button>
|
|
<button
|
|
v-if="video && video.registration_time"
|
|
@click="handleProcess"
|
|
:disabled="actionLoading || video.status === 'processing'"
|
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded transition disabled:opacity-50"
|
|
>
|
|
{{ actionLoading ? '處理中...' : '分析處理' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="flex justify-center py-12">
|
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500"></div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div v-else-if="video" class="space-y-6">
|
|
|
|
<!-- 1. Common Info Card -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">基本檔案資訊</h3>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">File UUID</span>
|
|
<p class="text-sm font-mono text-gray-300 truncate">{{ video.file_uuid }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">狀態</span>
|
|
<p class="text-white">
|
|
<span :class="getStatusColor(video.status)" class="px-2 py-1 rounded text-sm">
|
|
{{ video.status }}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">註冊時間</span>
|
|
<p class="text-sm text-gray-300">{{ formatTimestamp(video.registration_time) || '-' }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">檔案大小</span>
|
|
<p class="text-sm text-gray-300">{{ formatFileSize(probeInfo?.format?.size) }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 2. Video Specific Sections -->
|
|
<template v-if="video.file_type === 'video'">
|
|
|
|
<!-- Processing Status -->
|
|
<div v-if="video.processing_status" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">處理狀態 (Processing Status)</h3>
|
|
<div class="bg-gray-900 p-4 rounded space-y-3">
|
|
<!-- Phase -->
|
|
<div class="flex items-center space-x-3">
|
|
<span class="text-gray-500 w-20">階段:</span>
|
|
<span class="text-white font-semibold">{{ video.processing_status.phase || '-' }}</span>
|
|
</div>
|
|
|
|
<!-- Active Processors -->
|
|
<div v-if="video.processing_status.active_processors?.length && video.processing_status.phase !== 'COMPLETED'" class="flex items-start space-x-3">
|
|
<span class="text-gray-500 w-20">處理器:</span>
|
|
<div class="flex flex-wrap gap-2">
|
|
<span v-for="p in video.processing_status.active_processors" :key="p"
|
|
class="px-2 py-1 bg-blue-900 text-blue-300 rounded text-xs">
|
|
{{ p }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress by Processor -->
|
|
<div v-if="video.processing_status.progress && video.processing_status.phase !== 'COMPLETED'" class="space-y-2">
|
|
<span class="text-gray-500">進度:</span>
|
|
<div v-for="(progress, processor) in video.processing_status.progress" :key="processor"
|
|
class="flex items-center space-x-3 bg-gray-800 p-2 rounded">
|
|
<span class="text-gray-400 w-16">{{ processor }}</span>
|
|
<div class="flex-1">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="flex-1 bg-gray-700 rounded-full h-2">
|
|
<div :class="getProgressColor(progress.status)"
|
|
class="h-2 rounded-full transition-all"
|
|
:style="{ width: `${progress.percentage || 0}%` }">
|
|
</div>
|
|
</div>
|
|
<span class="text-xs text-gray-400">{{ (progress.percentage || 0).toFixed(1) }}%</span>
|
|
</div>
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
{{ progress.current_frame || 0 }} / {{ progress.total_frames || 0 }} frames
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Face Clusters -->
|
|
<div v-if="clusters.length > 0" class="space-y-4">
|
|
<h3 class="text-xl font-semibold">臉部群組 ({{ clusters.length }})</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div v-for="cluster in clusters" :key="cluster.cluster_id"
|
|
class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<h4 class="font-semibold">{{ cluster.cluster_id }}</h4>
|
|
<span :class="cluster.status === 'registered' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'"
|
|
class="px-2 py-1 rounded text-xs">
|
|
{{ cluster.status === 'registered' ? '已註冊' : '未註冊' }}
|
|
</span>
|
|
</div>
|
|
<div class="space-y-2 text-sm text-gray-400 mb-3">
|
|
<div>臉孔數: {{ cluster.face_count }}</div>
|
|
<div v-if="cluster.identity?.name">姓名: {{ cluster.identity.name }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Face Traces -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<FaceTraceTimeline
|
|
:file-uuid="uuid"
|
|
:total-duration="probeInfo?.format?.duration || 0"
|
|
@select="handleTraceSelect" />
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 3. Generic Probe Info -->
|
|
<div v-if="probeInfo" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">Probe 訊息 (ffprobe)</h3>
|
|
|
|
<!-- Basic Info Grid -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">Duration</span>
|
|
<p class="text-white">{{ formatDuration(probeInfo.format?.duration) }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">Bitrate</span>
|
|
<p class="text-white">{{ (probeInfo.format?.bit_rate / 1000).toFixed(0) }} kbps</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">Format</span>
|
|
<p class="text-white">{{ probeInfo.format?.format_long_name || '-' }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-xs text-gray-500 uppercase">Size</span>
|
|
<p class="text-white">{{ formatFileSize(probeInfo.format?.size) }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Full Probe JSON (Lazy Loaded via Computed) -->
|
|
<details class="mt-4">
|
|
<summary class="text-sm text-gray-400 cursor-pointer hover:text-white flex items-center space-x-2">
|
|
<span>完整 Probe JSON</span>
|
|
<span class="text-xs bg-blue-900 text-blue-300 px-2 py-1 rounded">詳細</span>
|
|
</summary>
|
|
<div class="bg-gray-900 p-3 rounded text-xs font-mono text-gray-300 overflow-x-auto max-h-96 mt-2">
|
|
<pre>{{ probeJsonString }}</pre>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { getVideos, registerVideo, unregisterVideo, processVideo } from '@/api/client'
|
|
import FaceTraceTimeline from '@/components/FaceTraceTimeline.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const uuid = route.params.uuid as string
|
|
|
|
const video = ref<any>(null)
|
|
const probeInfo = ref<any>(null)
|
|
const clusters = ref<any[]>([])
|
|
const loading = ref(false)
|
|
const actionLoading = ref(false)
|
|
|
|
// Computed for safe JSON string rendering
|
|
const probeJsonString = computed(() => {
|
|
if (!probeInfo.value) return ''
|
|
try {
|
|
return JSON.stringify(probeInfo.value, null, 2)
|
|
} catch {
|
|
return 'Error parsing JSON'
|
|
}
|
|
})
|
|
|
|
function goBack() {
|
|
router.push('/files')
|
|
}
|
|
|
|
function formatDuration(seconds: number | undefined): string {
|
|
if (!seconds) return '-'
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
if (m > 60) {
|
|
const h = Math.floor(m / 60)
|
|
return `${h}h ${m % 60}m ${s}s`
|
|
}
|
|
return `${m}m ${s}s`
|
|
}
|
|
|
|
function formatFileSize(bytes: number | undefined): string {
|
|
if (!bytes) return '-'
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
function getStatusColor(status: string): string {
|
|
switch (status) {
|
|
case 'completed': return 'bg-green-500'
|
|
case 'processing': return 'bg-yellow-500'
|
|
case 'pending': return 'bg-gray-500'
|
|
case 'failed': return 'bg-red-500'
|
|
default: return 'bg-gray-500'
|
|
}
|
|
}
|
|
|
|
function formatTimestamp(timestamp: string | undefined): string {
|
|
if (!timestamp) return '-'
|
|
try {
|
|
const date = new Date(timestamp)
|
|
return date.toLocaleString('zh-TW', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
} catch {
|
|
return timestamp
|
|
}
|
|
}
|
|
|
|
function getProgressColor(status: string): string {
|
|
switch (status) {
|
|
case 'completed': return 'bg-green-500'
|
|
case 'running': return 'bg-blue-500'
|
|
case 'pending': return 'bg-gray-500'
|
|
case 'failed': return 'bg-red-500'
|
|
default: return 'bg-gray-500'
|
|
}
|
|
}
|
|
|
|
async function handleRegister() {
|
|
actionLoading.value = true
|
|
try {
|
|
if (!video.value?.file_path) return
|
|
await registerVideo(video.value.file_path)
|
|
await loadVideoDetail()
|
|
} catch (e) {
|
|
console.error('Registration failed:', e)
|
|
} finally {
|
|
actionLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleUnregister() {
|
|
if (!confirm('確定要取消註冊嗎?這將刪除相關數據。')) return
|
|
actionLoading.value = true
|
|
try {
|
|
if (!video.value?.file_uuid) return
|
|
await unregisterVideo(video.value.file_uuid)
|
|
await loadVideoDetail()
|
|
} catch (e) {
|
|
console.error('Unregistration failed:', e)
|
|
} finally {
|
|
actionLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleProcess() {
|
|
actionLoading.value = true
|
|
try {
|
|
if (!video.value?.file_uuid) return
|
|
await processVideo(video.value.file_uuid)
|
|
await loadVideoDetail()
|
|
} catch (e) {
|
|
console.error('Processing failed:', e)
|
|
} finally {
|
|
actionLoading.value = false
|
|
}
|
|
}
|
|
|
|
function handleTraceSelect(traceId: number) {
|
|
// Navigate to face candidates filtered by this trace
|
|
router.push(`/faces/candidates?trace_id=${traceId}&file_uuid=${uuid}`)
|
|
}
|
|
|
|
async function loadVideoDetail() {
|
|
loading.value = true
|
|
try {
|
|
// Use getVideos and extract from files array
|
|
const result = await getVideos(undefined, undefined, 1, 1, uuid)
|
|
|
|
if (result.files?.[0]) {
|
|
const v = result.files[0]
|
|
video.value = v
|
|
|
|
// Parse processing_status
|
|
if (v.processing_status) {
|
|
try {
|
|
video.value.processing_status = typeof v.processing_status === 'string'
|
|
? JSON.parse(v.processing_status)
|
|
: v.processing_status
|
|
} catch (e) {
|
|
console.error('Failed to parse processing_status:', e)
|
|
}
|
|
}
|
|
|
|
// Parse probe_json
|
|
if (v.probe_json) {
|
|
try {
|
|
probeInfo.value = JSON.parse(v.probe_json)
|
|
} catch (e) {
|
|
console.error('Failed to parse probe_json:', e)
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load detail:', e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadVideoDetail()
|
|
})
|
|
</script> |