86 lines
3.7 KiB
Vue
86 lines
3.7 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<div class="flex items-center space-x-4">
|
|
<button @click="$router.back()" class="text-gray-400 hover:text-white text-lg">← 返回</button>
|
|
<h2 class="text-2xl font-bold">Trace #{{ traceId }}</h2>
|
|
<span class="text-gray-400 text-sm">{{ fileUuid?.substring(0, 12) }}...</span>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-center py-12"><div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500 mx-auto"></div></div>
|
|
|
|
<div v-else-if="trace" class="grid gap-6">
|
|
<!-- Summary -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div><span class="text-xs text-gray-500">DETECTIONS</span><p class="text-white text-lg font-semibold">{{ trace.face_count }}</p></div>
|
|
<div><span class="text-xs text-gray-500">DURATION</span><p class="text-white text-lg font-semibold">{{ trace.duration_sec?.toFixed(1) }}s</p></div>
|
|
<div><span class="text-xs text-gray-500">CONFIDENCE</span><p class="text-white text-lg font-semibold">{{ (trace.avg_confidence * 100).toFixed(0) }}%</p></div>
|
|
<div><span class="text-xs text-gray-500">TIME</span><p class="text-white text-lg font-semibold">{{ trace.first_sec?.toFixed(0) }}s - {{ trace.last_sec?.toFixed(0) }}s</p></div>
|
|
</div>
|
|
|
|
<!-- Video -->
|
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
|
<video controls autoplay class="w-full" @error="videoError = true">
|
|
<source :src="videoUrl" type="video/mp4" />
|
|
</video>
|
|
<div v-if="videoError" class="p-4 text-center text-gray-500">Video unavailable</div>
|
|
</div>
|
|
|
|
<!-- Recent Faces -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">Recent Detections</h3>
|
|
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
|
<div v-for="f in recentFaces" :key="f.id" class="bg-gray-900 rounded overflow-hidden">
|
|
<img :src="thumbUrl(f)" class="w-full aspect-square object-cover" loading="lazy" @error="e => (e.target as HTMLElement).style.display='none'" />
|
|
<div class="p-1 text-[9px] text-gray-400 truncate">#{{ f.start_frame }}<br/>{{ (f.confidence * 100).toFixed(0) }}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { getCurrentConfig, httpFetch } from '@/api/client'
|
|
|
|
const route = useRoute()
|
|
const fileUuid = route.params.file_uuid as string
|
|
const traceId = route.params.trace_id as string
|
|
const config = getCurrentConfig()
|
|
|
|
const trace = ref<any>(null)
|
|
const faces = ref<any[]>([])
|
|
const loading = ref(true)
|
|
const videoError = ref(false)
|
|
|
|
const videoUrl = computed(() =>
|
|
`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/video?padding=1`
|
|
)
|
|
|
|
const recentFaces = computed(() => faces.value.slice(0, 40))
|
|
|
|
function thumbUrl(f: any): string {
|
|
return `${config.api_base_url}/api/v1/file/${fileUuid}/thumbnail?frame=${f.start_frame}&x=${f.x}&y=${f.y}&w=${f.width}&h=${f.height}`
|
|
}
|
|
|
|
async function loadData() {
|
|
try {
|
|
const traces = await httpFetch<any>(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ limit: 500 })
|
|
})
|
|
trace.value = (traces.traces || []).find((t: any) => String(t.trace_id) === traceId)
|
|
|
|
const faceData = await httpFetch<any>(`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/faces?limit=50`)
|
|
faces.value = faceData.faces || []
|
|
} catch (e) {
|
|
console.error('Failed to load trace:', e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => loadData())
|
|
</script>
|