Files
momentry_core/portal/src/views/FaceCandidatesView.vue
T

258 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>