258 lines
9.7 KiB
Vue
258 lines
9.7 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<div class="flex justify-between items-center">
|
||
<h2 class="text-2xl font-bold">Face Traces</h2>
|
||
<button
|
||
@click="loadTraces"
|
||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
|
||
>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
|
||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 space-y-4">
|
||
<div class="grid grid-cols-4 gap-4">
|
||
<div>
|
||
<label class="text-gray-400 text-sm mb-1">Filter by File (必選)</label>
|
||
<select
|
||
v-model="selectedFileUuid"
|
||
@change="loadTraces"
|
||
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||
>
|
||
<option value="">-- 選擇檔案 --</option>
|
||
<option v-for="f in files" :key="f.file_uuid" :value="f.file_uuid">
|
||
{{ f.file_name?.substring(0, 50) || f.file_uuid }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-gray-400 text-sm mb-1">Sort By</label>
|
||
<select
|
||
v-model="sortBy"
|
||
@change="loadTraces"
|
||
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||
>
|
||
<option value="face_count">Face Count</option>
|
||
<option value="duration">Duration</option>
|
||
<option value="first_appearance">First Appearance</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-gray-400 text-sm mb-1">Min Faces</label>
|
||
<input
|
||
v-model.number="minFaces"
|
||
@change="loadTraces"
|
||
type="number"
|
||
min="1"
|
||
max="1000"
|
||
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="text-gray-400 text-sm mb-1">Binding Status</label>
|
||
<select
|
||
v-model="bindingFilter"
|
||
@change="loadTraces"
|
||
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||
>
|
||
<option value="all">全部</option>
|
||
<option value="registered">已綁定</option>
|
||
<option value="unregistered">未綁定</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!selectedFileUuid" class="text-center py-12 text-gray-500">
|
||
請選擇一個檔案來檢視 Face Traces
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div v-if="loading && traces.length === 0" class="text-center py-12 text-gray-500">
|
||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||
<span>Loading...</span>
|
||
</div>
|
||
|
||
<div v-if="traces.length > 0">
|
||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-4 flex justify-between items-center">
|
||
<div class="text-gray-400">
|
||
Showing {{ paginatedTraces.length }} of {{ traces.length }} traces
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-xs text-gray-500">每頁</span>
|
||
<select v-model.number="pageSize" @change="page=1"
|
||
class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white text-xs">
|
||
<option :value="20">20</option>
|
||
<option :value="50">50</option>
|
||
<option :value="100">100</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pagination top -->
|
||
<div v-if="totalPages > 1" class="flex justify-center mb-4 space-x-2">
|
||
<button @click="page = 1" :disabled="page === 1"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">«</button>
|
||
<button @click="page--" :disabled="page === 1"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">‹</button>
|
||
<span class="text-gray-400 text-sm py-1">{{ page }} / {{ totalPages }}</span>
|
||
<button @click="page++" :disabled="page >= totalPages"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">›</button>
|
||
<button @click="page = totalPages" :disabled="page >= totalPages"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">»</button>
|
||
</div>
|
||
|
||
<!-- Loading overlay during pagination -->
|
||
<div v-if="loading" class="text-center py-4 text-gray-500 mb-2">
|
||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||
<div
|
||
v-for="trace in paginatedTraces"
|
||
:key="trace.trace_id"
|
||
@click="viewTrace(trace)"
|
||
class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden cursor-pointer hover:border-blue-500 transition"
|
||
>
|
||
<div class="aspect-square bg-gray-700 flex items-center justify-center overflow-hidden">
|
||
<img
|
||
:src="getThumbnailUrl(trace)"
|
||
alt="Trace thumbnail"
|
||
class="w-full h-full object-cover"
|
||
loading="lazy"
|
||
@error="onThumbnailError(trace.trace_id)"
|
||
/>
|
||
</div>
|
||
<div class="p-3 space-y-1">
|
||
<div class="flex items-center justify-between">
|
||
<div class="text-sm font-semibold text-blue-300">Trace #{{ trace.trace_id }}</div>
|
||
<span class="text-xs px-1.5 py-0.5 rounded"
|
||
:class="trace.face_count > 5 ? 'bg-green-900 text-green-300' : 'bg-gray-700 text-gray-400'">
|
||
{{ trace.face_count > 5 ? '多' : '少' }}
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400">{{ trace.face_count }} faces, {{ trace.duration_sec?.toFixed(1) || '?' }}s</div>
|
||
<div class="text-xs font-mono" :class="getConfidenceColor(trace.avg_confidence)">
|
||
{{ (trace.avg_confidence * 100).toFixed(0) }}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pagination bottom -->
|
||
<div v-if="totalPages > 1" class="flex justify-center mt-6 space-x-2">
|
||
<button @click="page = 1" :disabled="page === 1"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">«</button>
|
||
<button @click="page--" :disabled="page === 1"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">‹</button>
|
||
<span class="text-gray-400 text-sm py-1">{{ page }} / {{ totalPages }}</span>
|
||
<button @click="page++" :disabled="page >= totalPages"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">›</button>
|
||
<button @click="page = totalPages" :disabled="page >= totalPages"
|
||
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">»</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!loading && traces.length === 0" class="text-center py-12 text-gray-500">
|
||
No traces found for this file
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { getVideos, getCurrentConfig } from '@/api/client'
|
||
|
||
const router = useRouter()
|
||
|
||
interface TraceInfo {
|
||
trace_id: number
|
||
face_count: number
|
||
first_frame: number
|
||
last_frame: number
|
||
first_sec: number
|
||
last_sec: number
|
||
duration_sec: number
|
||
avg_confidence: number
|
||
sample_face_id?: string | null
|
||
}
|
||
|
||
const traces = ref<TraceInfo[]>([])
|
||
const loading = ref(false)
|
||
const totalTraces = ref(0)
|
||
const selectedFileUuid = ref('')
|
||
const files = ref<any[]>([])
|
||
const sortBy = ref('face_count')
|
||
const minFaces = ref(1)
|
||
const bindingFilter = ref('all')
|
||
const page = ref(1)
|
||
const pageSize = ref(50)
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(traces.value.length / pageSize.value)))
|
||
|
||
const paginatedTraces = computed(() => {
|
||
const start = (page.value - 1) * pageSize.value
|
||
return traces.value.slice(start, start + pageSize.value)
|
||
})
|
||
|
||
const failedThumbnails = ref<Set<number>>(new Set())
|
||
|
||
const getThumbnailUrl = (trace: TraceInfo): string => {
|
||
const config = getCurrentConfig()
|
||
return `${config.api_base_url}/api/v1/file/${selectedFileUuid.value}/thumbnail?frame=${trace.first_frame}`
|
||
}
|
||
|
||
const onThumbnailError = (traceId: number) => {
|
||
failedThumbnails.value = new Set([...failedThumbnails.value, traceId])
|
||
}
|
||
|
||
const getConfidenceColor = (conf: number): string => {
|
||
if (conf >= 0.8) return 'text-green-400'
|
||
if (conf >= 0.5) return 'text-yellow-400'
|
||
return 'text-red-400'
|
||
}
|
||
|
||
const viewTrace = (trace: TraceInfo) => {
|
||
router.push(`/traces/${selectedFileUuid.value}/${trace.trace_id}`)
|
||
}
|
||
|
||
const loadTraces = async () => {
|
||
if (!selectedFileUuid.value) return
|
||
loading.value = true
|
||
page.value = 1
|
||
try {
|
||
const config = getCurrentConfig()
|
||
const result = await fetch(
|
||
`${config.api_base_url}/api/v1/file/${selectedFileUuid.value}/face_trace/sortby`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(config.api_key ? { 'X-API-Key': config.api_key } : {})
|
||
},
|
||
body: JSON.stringify({
|
||
sort_by: sortBy.value,
|
||
limit: 200,
|
||
min_faces: minFaces.value
|
||
})
|
||
}
|
||
)
|
||
const data = await result.json()
|
||
traces.value = data.traces || []
|
||
totalTraces.value = data.total_traces || 0
|
||
} catch (e) {
|
||
console.error('Failed to load traces:', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const result = await getVideos()
|
||
files.value = result.data || result.files || []
|
||
} catch { /* ignore */ }
|
||
})
|
||
</script> |