Initial commit: Momentry Portal v0.1.0
This commit is contained in:
332
src/views/ChunkDetailView.vue
Normal file
332
src/views/ChunkDetailView.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<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>
|
||||
258
src/views/FaceCandidatesView.vue
Normal file
258
src/views/FaceCandidatesView.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<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 || []
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
</script>
|
||||
284
src/views/FilesView.vue
Normal file
284
src/views/FilesView.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Search and Filters -->
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<h2 class="text-2xl font-bold">檔案管理 (Demo)</h2>
|
||||
<div class="flex items-center gap-3 w-full md:w-auto">
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center bg-gray-700 rounded p-1">
|
||||
<button
|
||||
@click="setStatusFilter('all')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'all', 'text-gray-300 hover:text-white': statusFilter !== 'all'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
@click="setStatusFilter('unregistered')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'unregistered', 'text-gray-300 hover:text-white': statusFilter !== 'unregistered'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
未註冊
|
||||
</button>
|
||||
<button
|
||||
@click="setStatusFilter('pending')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'pending', 'text-gray-300 hover:text-white': statusFilter !== 'pending'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
待處理
|
||||
</button>
|
||||
<button
|
||||
@click="setStatusFilter('processing')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'processing', 'text-gray-300 hover:text-white': statusFilter !== 'processing'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
處理中
|
||||
</button>
|
||||
<button
|
||||
@click="setStatusFilter('completed')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'completed', 'text-gray-300 hover:text-white': statusFilter !== 'completed'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
已完成
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search input -->
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜尋檔名..."
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm w-full md:w-48 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="bg-red-900/50 border border-red-700 rounded p-4 text-red-300">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
<div v-else class="bg-gray-800 rounded-lg border border-gray-700 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">檔案名稱</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">狀態</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">UUID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||
<tr v-for="file in displayFiles" :key="file.file_uuid || file.file_path" class="hover:bg-gray-750 transition">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-white truncate max-w-xs" :title="file.file_name">
|
||||
{{ file.file_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span v-if="file.status === 'ready' || file.status === 'completed'" class="px-2 py-0.5 rounded text-xs bg-green-900 text-green-200">
|
||||
✅ 已就绪
|
||||
</span>
|
||||
<span v-else-if="file.status === 'processing'" class="px-2 py-0.5 rounded text-xs bg-yellow-900 text-yellow-200">
|
||||
🔄 处理中
|
||||
</span>
|
||||
<span v-else-if="file.status === 'pending'" class="px-2 py-0.5 rounded text-xs bg-blue-900 text-blue-200">
|
||||
⏳ 待处理
|
||||
</span>
|
||||
<span v-else-if="file.status === 'registered_scan'" class="px-2 py-0.5 rounded text-xs bg-blue-900 text-blue-200">
|
||||
⏳ 已注册(扫描)
|
||||
</span>
|
||||
<span v-else class="px-2 py-0.5 rounded text-xs bg-gray-600 text-gray-300">
|
||||
📦 未注册
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300 font-mono text-xs">
|
||||
{{ file.file_uuid || '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<!-- Enter Demo / Workbench (Completed) -->
|
||||
<button
|
||||
v-if="file.status === 'ready' || file.status === 'completed'"
|
||||
@click="enterWorkbench(file.file_uuid)"
|
||||
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs rounded transition"
|
||||
>
|
||||
臉部工作台
|
||||
</button>
|
||||
|
||||
<!-- Start Processing (Pending) -->
|
||||
<button
|
||||
v-if="file.status === 'pending'"
|
||||
@click="startProcessing(file.file_uuid)"
|
||||
class="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white text-xs rounded transition"
|
||||
>
|
||||
開始處理
|
||||
</button>
|
||||
|
||||
<!-- Register (Unregistered) -->
|
||||
<button
|
||||
v-if="!file.status || file.status === 'unregistered'"
|
||||
@click="registerFile(file.file_path)"
|
||||
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded transition"
|
||||
>
|
||||
註冊
|
||||
</button>
|
||||
|
||||
<!-- Unregister (Pending/Processing/Completed) -->
|
||||
<button
|
||||
v-if="file.file_uuid"
|
||||
@click="unregisterFile(file.file_uuid, file.file_name)"
|
||||
class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition"
|
||||
>
|
||||
取消註冊
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { registerVideo, unregisterVideo, httpFetch, getCurrentConfig } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const files = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('all') // all, unregistered, pending, processing, completed
|
||||
|
||||
const displayFiles = computed(() => {
|
||||
let result = files.value
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(f =>
|
||||
f.file_name.toLowerCase().includes(q) ||
|
||||
(f.file_path && f.file_path.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter.value !== 'all') {
|
||||
result = result.filter(f => {
|
||||
if (statusFilter.value === 'completed') {
|
||||
return f.status === 'completed' || f.status === 'ready'
|
||||
}
|
||||
return f.status === statusFilter.value
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function setStatusFilter(status: string) {
|
||||
statusFilter.value = status
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const config = getCurrentConfig()
|
||||
const scanResp = await httpFetch<any>(`${config.api_base_url}/api/v1/files/scan`)
|
||||
const scanFiles: any[] = (scanResp?.files || []).map((f: any) => ({
|
||||
...f,
|
||||
status: f.is_registered ? 'registered_scan' : 'unregistered'
|
||||
}))
|
||||
|
||||
// Get registered files with real processing status
|
||||
let regFiles: any[] = []
|
||||
try {
|
||||
const regResp = await httpFetch<any>(`${config.api_base_url}/api/v1/files?page=1&page_size=100`)
|
||||
regFiles = (regResp?.files || regResp?.data || []).map((f: any) => ({
|
||||
...f,
|
||||
status: f.status || 'pending'
|
||||
}))
|
||||
} catch {
|
||||
// Registered files API may not be available; use scan data only
|
||||
}
|
||||
|
||||
// Merge: scan results first, then overlay with registered statuses
|
||||
const merged = new Map<string, any>()
|
||||
for (const f of scanFiles) {
|
||||
merged.set(f.file_path, f)
|
||||
}
|
||||
for (const f of regFiles) {
|
||||
merged.set(f.file_path, f)
|
||||
}
|
||||
|
||||
files.value = Array.from(merged.values())
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch files:', e)
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function registerFile(filePath: string) {
|
||||
if (!filePath) { alert('無法註冊:缺少檔案路徑'); return }
|
||||
try {
|
||||
await registerVideo(filePath)
|
||||
// Refresh list
|
||||
await fetchFiles()
|
||||
} catch (e) {
|
||||
console.error('Register failed:', e)
|
||||
alert('註冊失敗:' + e)
|
||||
}
|
||||
}
|
||||
|
||||
async function unregisterFile(fileUuid: string, fileName: string) {
|
||||
if (!fileUuid) { alert('無法取消註冊:缺少 UUID'); return }
|
||||
const displayName = fileName || '未知檔案'
|
||||
if (!confirm(`確定要取消註冊 "${displayName}" 嗎?這將刪除資料庫中的相關記錄。`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await unregisterVideo(fileUuid)
|
||||
await fetchFiles()
|
||||
} catch (e) {
|
||||
console.error('Unregister failed:', e)
|
||||
alert('取消註冊失敗:' + e)
|
||||
}
|
||||
}
|
||||
|
||||
async function startProcessing(fileUuid: string) {
|
||||
if (!fileUuid) { alert('無法處理:缺少 UUID'); return }
|
||||
if (!confirm('確定要開始分析處理此檔案嗎?')) return
|
||||
|
||||
try {
|
||||
const config = getCurrentConfig()
|
||||
await httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/process`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
|
||||
// After triggering, status should change to processing
|
||||
// We can poll or just refresh
|
||||
await fetchFiles()
|
||||
} catch (e) {
|
||||
console.error('Start processing failed:', e)
|
||||
alert('開始處理失敗:' + e)
|
||||
}
|
||||
}
|
||||
|
||||
function enterWorkbench(fileUuid: string) {
|
||||
if (!fileUuid) { alert('無法開啟工作台:缺少 UUID'); return }
|
||||
router.push(`/file/${fileUuid}`)
|
||||
}
|
||||
|
||||
onMounted(fetchFiles)
|
||||
</script>
|
||||
435
src/views/FilesView.vue.backup
Normal file
435
src/views/FilesView.vue.backup
Normal file
@@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Search and Filters -->
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<h2 class="text-2xl font-bold">納管檔案</h2>
|
||||
<div class="flex items-center gap-3 w-full md:w-auto">
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center bg-gray-700 rounded p-1">
|
||||
<button
|
||||
@click="setStatusFilter('unprocessed')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'unprocessed', 'text-gray-300 hover:text-white': statusFilter !== 'unprocessed'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
未處理
|
||||
</button>
|
||||
<button
|
||||
@click="setStatusFilter('processed')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'processed', 'text-gray-300 hover:text-white': statusFilter !== 'processed'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
已處理
|
||||
</button>
|
||||
<button
|
||||
@click="setStatusFilter('all')"
|
||||
:class="{'bg-blue-600 text-white': statusFilter === 'all', 'text-gray-300 hover:text-white': statusFilter !== 'all'}"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
>
|
||||
全選
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="handleSearch"
|
||||
placeholder="搜尋檔名..."
|
||||
class="pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 w-48"
|
||||
/>
|
||||
<svg class="w-5 h-5 absolute left-3 top-2.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="bg-red-900/50 border border-red-700 rounded p-4 text-red-300">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- File List (Fixed Height for Stable Layout) -->
|
||||
<div v-else class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden flex flex-col" style="min-height: 600px;">
|
||||
<div class="flex-grow overflow-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Filename</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Duration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Resolution</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Registration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Size</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||
<tr v-for="file in fixedSizeFiles" :key="file.uuid" :class="file.status === 'empty' ? 'opacity-0' : 'hover:bg-gray-750 transition'"
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div v-if="file.status !== 'empty'" class="text-sm font-medium text-white" v-html="highlightMatch(file.file_name, searchQuery)"></div>
|
||||
<div v-else class="text-sm font-medium text-transparent">-</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
<span v-if="file.status !== 'empty'">{{ formatDuration(file.duration) }}</span>
|
||||
<span v-else class="text-transparent">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
<span v-if="file.status !== 'empty'">{{ file.width }}x{{ file.height }}</span>
|
||||
<span v-else class="text-transparent">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span v-if="file.status !== 'empty'">
|
||||
<span v-if="file.registration_time" class="text-green-400">
|
||||
✓ {{ formatDate(file.registration_time) }}
|
||||
</span>
|
||||
<span v-else-if="file.created_at" class="text-yellow-400">
|
||||
⚠️ {{ formatDate(file.created_at) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-500">
|
||||
-
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="text-transparent">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
<span v-if="file.status !== 'empty'">{{ formatFileSize(file.file_size) }}</span>
|
||||
<span v-else class="text-transparent">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span v-if="file.status !== 'empty'" :class="statusBadgeClass(file.status, file.registration_time)" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
|
||||
{{ getStatusText(file.status, file.registration_time) }}
|
||||
</span>
|
||||
<span v-else class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full text-transparent">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div v-if="file.status !== 'empty'">
|
||||
<button
|
||||
@click="viewDetail(file.uuid)"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded mr-2"
|
||||
>
|
||||
詳情
|
||||
</button>
|
||||
<button
|
||||
@click="registerFile(file.uuid, file.file_path || file.file_name)"
|
||||
:disabled="registeringFiles.value.has(file.uuid) || !!file.registration_time"
|
||||
:class="{
|
||||
'bg-blue-600 hover:bg-blue-700': !registeringFiles.value.has(file.uuid) && !file.registration_time,
|
||||
'bg-gray-600 cursor-not-allowed': registeringFiles.value.has(file.uuid) || !!file.registration_time,
|
||||
'opacity-50': registeringFiles.value.has(file.uuid) || !!file.registration_time
|
||||
}"
|
||||
class="px-3 py-1 text-white text-xs rounded mr-2 transition"
|
||||
>
|
||||
{{
|
||||
file.registration_time
|
||||
? '已註冊'
|
||||
: registeringFiles.value.has(file.uuid)
|
||||
? '註冊中...'
|
||||
: '立即註冊'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
v-if="file.status === 'pending' || file.status === 'REGISTERED'"
|
||||
@click="processFile(file.uuid)"
|
||||
:disabled="processingFiles.value.has(file.uuid)"
|
||||
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded disabled:opacity-50"
|
||||
>
|
||||
{{ processingFiles.value.has(file.uuid) ? '處理中...' : '開始分析' }}
|
||||
</button>
|
||||
<span v-else class="text-gray-500 text-xs">
|
||||
{{ file.processing_status || 'completed' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-transparent text-xs">-</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div> <!-- 關閉 overflow-auto -->
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between bg-gray-800 px-4 py-3 border-t border-gray-700">
|
||||
<div class="text-sm text-gray-400">
|
||||
Page {{ page }} of {{ totalPages }} (Total: {{ total }}, Page size: 15)
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="changePage(1)"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(totalPages)"
|
||||
:disabled="page === totalPages"
|
||||
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getVideos, processVideo, registerVideo } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface FileItem {
|
||||
uuid: string
|
||||
file_name: string
|
||||
file_path?: string
|
||||
duration: number
|
||||
width: number
|
||||
height: number
|
||||
status: string
|
||||
processing_status?: string
|
||||
created_at?: string
|
||||
registration_time?: string
|
||||
file_size?: number
|
||||
}
|
||||
|
||||
const files = ref<FileItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('unprocessed') // default
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const total = ref(0)
|
||||
const processingFiles = ref<Set<string>>(new Set())
|
||||
const registeringFiles = ref<Set<string>>(new Set())
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
// 固定大小的列表,確保總是顯示 15 行以保持畫面穩定
|
||||
const fixedSizeFiles = computed(() => {
|
||||
const result = [...files.value]
|
||||
// 如果實際數據不足 15 條,用空行填充
|
||||
while (result.length < 15) {
|
||||
result.push({
|
||||
uuid: `empty-${result.length}`,
|
||||
file_name: '',
|
||||
duration: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
status: 'empty',
|
||||
file_size: 0
|
||||
})
|
||||
}
|
||||
return result.slice(0, 15) // 確保最多 15 行
|
||||
})
|
||||
|
||||
async function registerFile(uuid: string, filePath: string) {
|
||||
registeringFiles.value.add(uuid)
|
||||
try {
|
||||
// Use /api/v1/register with file_path
|
||||
const result = await registerVideo(filePath)
|
||||
alert('已註冊!UUID: ' + (result.uuid || uuid))
|
||||
await fetchFiles()
|
||||
} catch (e) {
|
||||
console.error('Register failed:', e)
|
||||
alert('註冊失敗:' + e)
|
||||
} finally {
|
||||
registeringFiles.value.delete(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
async function processFile(uuid: string) {
|
||||
processingFiles.value.add(uuid)
|
||||
try {
|
||||
const result = await processVideo(uuid)
|
||||
alert('已開始分析!Job ID: ' + (result.job_id || 'queued'))
|
||||
await fetchFiles()
|
||||
} catch (e) {
|
||||
console.error('Process failed:', e)
|
||||
alert('分析失敗:' + e)
|
||||
} finally {
|
||||
processingFiles.value.delete(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '0s'
|
||||
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 statusBadgeClass(status: string, registrationTime?: string): string {
|
||||
if (status === 'empty') return 'bg-transparent'
|
||||
|
||||
if (registrationTime) {
|
||||
return 'bg-green-900 text-green-200' // 已註冊用綠色
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'ready': return 'bg-green-900 text-green-200'
|
||||
case 'processing': return 'bg-yellow-900 text-yellow-200'
|
||||
case 'error': return 'bg-red-900 text-red-200'
|
||||
case 'completed': return 'bg-green-900 text-green-200'
|
||||
case 'pending': return 'bg-yellow-900 text-yellow-200'
|
||||
case 'REGISTERED': return 'bg-yellow-900 text-yellow-200'
|
||||
default: return 'bg-gray-700 text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status: string, registrationTime?: string): string {
|
||||
if (status === 'empty') return ''
|
||||
|
||||
if (registrationTime) {
|
||||
return '已註冊'
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'ready': return '準備中'
|
||||
case 'processing': return '處理中'
|
||||
case 'error': return '錯誤'
|
||||
case 'completed': return '已完成'
|
||||
case 'pending': return '等待中'
|
||||
case 'REGISTERED': return '已註冊'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): string {
|
||||
if (!query || !text) return text
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
||||
return text.replace(regex, '<mark class="bg-yellow-600 text-white rounded px-0.5">$1</mark>')
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
|
||||
}
|
||||
|
||||
function viewDetail(uuid: string) {
|
||||
router.push(`/video/${uuid}`)
|
||||
}
|
||||
|
||||
let searchTimeout: any = null
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
page.value = 1 // Reset to page 1 on search
|
||||
searchTimeout = setTimeout(() => {
|
||||
fetchFiles()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function setStatusFilter(status: string) {
|
||||
statusFilter.value = status
|
||||
page.value = 1
|
||||
fetchFiles()
|
||||
}
|
||||
|
||||
function changePage(newPage: number) {
|
||||
if (newPage < 1 || newPage > totalPages.value) return
|
||||
page.value = newPage
|
||||
fetchFiles()
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// Call the getVideos function with current filters
|
||||
// Map UI status to API status
|
||||
const statusMap: Record<string, string | undefined> = {
|
||||
'unprocessed': 'pending',
|
||||
'processed': 'completed',
|
||||
'all': undefined
|
||||
}
|
||||
const apiStatus = statusMap[statusFilter.value] || statusFilter.value
|
||||
|
||||
const response = await getVideos(
|
||||
searchQuery.value || undefined,
|
||||
apiStatus,
|
||||
page.value,
|
||||
pageSize.value
|
||||
)
|
||||
console.log("API Response:", response)
|
||||
|
||||
files.value = (response.videos || []).map((v: any) => {
|
||||
let probeData: any = null
|
||||
let createdAt: string | undefined = undefined
|
||||
let fileSize: number | undefined = undefined
|
||||
try {
|
||||
if (v.probe_json) {
|
||||
probeData = JSON.parse(v.probe_json)
|
||||
const fmt = probeData?.format
|
||||
if (fmt?.tags?.date) createdAt = fmt.tags.date
|
||||
if (fmt?.size) fileSize = parseInt(fmt.size)
|
||||
}
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
return {
|
||||
uuid: v.uuid,
|
||||
file_name: v.file_name || v.filename,
|
||||
file_path: v.file_path,
|
||||
duration: v.duration,
|
||||
width: v.width,
|
||||
height: v.height,
|
||||
status: v.status,
|
||||
processing_status: v.processing_status,
|
||||
created_at: createdAt,
|
||||
registration_time: v.registration_time,
|
||||
file_size: fileSize
|
||||
}
|
||||
})
|
||||
total.value = response.count || 0
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch files:', e)
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchFiles)
|
||||
</script>
|
||||
521
src/views/HomeView.vue
Normal file
521
src/views/HomeView.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="bg-gradient-to-r from-blue-900 to-purple-900 rounded-lg p-8">
|
||||
<h2 class="text-3xl font-bold mb-4">歡迎使用 Momentry Portal</h2>
|
||||
<p class="text-gray-300 mb-6">
|
||||
影片內容搜尋與人物管理平台
|
||||
</p>
|
||||
<div class="flex space-x-4">
|
||||
<router-link
|
||||
to="/search"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-semibold transition"
|
||||
>
|
||||
開始搜尋
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/persons"
|
||||
class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-semibold transition"
|
||||
>
|
||||
人物管理
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingest Stats Section -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-green-400 mb-4">入庫統計</h3>
|
||||
|
||||
<div v-if="ingestStats" class="space-y-4">
|
||||
<!-- Row 1: Videos + Total Chunks -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Total Videos -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ ingestStats.total_videos }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1">影片總數</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Chunks -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ ingestStats.total_chunks }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1">片段總數</div>
|
||||
</div>
|
||||
|
||||
<!-- Searchable Chunks -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-green-700 text-center">
|
||||
<div class="text-3xl font-bold text-green-400">{{ ingestStats.searchable_chunks }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1">可搜尋</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Chunk Types Breakdown -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<div class="text-sm text-gray-500 mb-3">片段類型分類</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-pink-400">{{ ingestStats.sentence_chunks }}</div>
|
||||
<div class="text-xs text-gray-400">Sentence (句子)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-orange-400">{{ ingestStats.cut_chunks }}</div>
|
||||
<div class="text-xs text-gray-400">Cut (剪輯點)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-indigo-400">{{ ingestStats.time_chunks }}</div>
|
||||
<div class="text-xs text-gray-400">Time (時間段)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chunk Type Definitions -->
|
||||
<div class="mt-4 pt-3 border-t border-gray-700 space-y-2 text-xs text-gray-500">
|
||||
<div class="flex items-start space-x-2">
|
||||
<span class="text-pink-400 font-semibold">Sentence</span>
|
||||
<span>基於語音辨識的自然語句分割,每個片段代表一句完整的對話或敘述,適合語意搜尋與內容理解。</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
<span class="text-orange-400 font-semibold">Cut</span>
|
||||
<span>基於影片場景切換的分割點,偵測畫面變化(如鏡頭切換、場景轉換)作為片段邊界,適合視覺內容分析。</span>
|
||||
</div>
|
||||
<div class="flex items-start space-x-2">
|
||||
<span class="text-indigo-400 font-semibold">Time</span>
|
||||
<span>基於固定時間間隔的分割(如每 60 秒),確保片段長度一致,適合時間序列分析與段落瀏覽。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Processing Status -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Chunks with Summary -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-emerald-700 text-center">
|
||||
<div class="text-3xl font-bold text-emerald-400">{{ ingestStats.chunks_with_summary }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1">已生成摘要</div>
|
||||
</div>
|
||||
|
||||
<!-- Chunks with Visual -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-cyan-700 text-center">
|
||||
<div class="text-3xl font-bold text-cyan-400">{{ ingestStats.chunks_with_visual }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1">有視覺分析</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Videos -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-yellow-700 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-400">{{ ingestStats.pending_videos }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1">待處理</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">載入中...</div>
|
||||
</div>
|
||||
|
||||
<!-- SFTPGo Status Section -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-orange-400 mb-4">SFTPGo 狀態</h3>
|
||||
|
||||
<div v-if="sftpgoStatus" class="space-y-4">
|
||||
<!-- Status message -->
|
||||
<div v-if="statusMsg" class="text-sm px-3 py-2 rounded"
|
||||
:class="statusMsg.type === 'ok' ? 'bg-green-900/50 text-green-300' : 'bg-red-900/50 text-red-300'">
|
||||
{{ statusMsg.text }}
|
||||
<button @click="statusMsg = null" class="ml-2 text-gray-500 hover:text-white">×</button>
|
||||
</div>
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">Username</span>
|
||||
<p class="text-white mt-1 text-lg font-semibold">{{ sftpgoStatus.username }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 lg:col-span-2">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">Home Path</span>
|
||||
<p class="text-gray-300 mt-1 text-sm font-mono break-all">{{ sftpgoStatus.home_dir }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">Files Count</span>
|
||||
<button
|
||||
@click="openSftpgoFiles"
|
||||
class="text-orange-400 mt-1 text-lg font-semibold hover:text-orange-300 cursor-pointer flex items-center space-x-1"
|
||||
>
|
||||
<span>{{ sftpgoStatus.files_count }}</span>
|
||||
<span>🔗</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">Registered Videos</span>
|
||||
<p class="text-green-400 mt-1 text-lg font-semibold">{{ sftpgoStatus.registered_videos.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SFTPGo URL -->
|
||||
<div class="mt-4 bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">SFTPGo 檔案管理</span>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="openSftpgoFiles"
|
||||
class="flex-1 px-4 py-3 bg-orange-700 hover:bg-orange-600 text-white text-center rounded font-medium no-underline"
|
||||
>
|
||||
📂 點擊開啟 SFTPGo 檔案管理
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<span class="text-gray-500">或複製網址:</span>
|
||||
<input
|
||||
:value="sftpgoUrl"
|
||||
readonly
|
||||
class="flex-1 px-3 py-1 bg-gray-800 border border-gray-600 rounded text-gray-300 text-xs font-mono"
|
||||
/>
|
||||
<button
|
||||
@click="copySftpgoUrl"
|
||||
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white text-xs rounded"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registered Videos List -->
|
||||
<div v-if="sftpgoStatus.registered_videos.length > 0" class="bg-gray-900 rounded border border-gray-600">
|
||||
<div class="p-3 border-b border-gray-700">
|
||||
<span class="text-sm font-semibold text-gray-300">已註冊影片</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-700">
|
||||
<div v-for="video in sftpgoStatus.registered_videos" :key="video.uuid" class="p-3 flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-white text-sm">{{ video.file_name }}</p>
|
||||
<p class="text-gray-500 text-xs">{{ video.uuid }}</p>
|
||||
</div>
|
||||
<span :class="video.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'" class="px-2 py-1 rounded text-xs">
|
||||
{{ video.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="bg-gray-900 p-4 rounded border border-gray-600 text-center text-gray-500 text-sm">
|
||||
尚未註冊任何影片
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">載入中...</div>
|
||||
</div>
|
||||
|
||||
<!-- Inference Engines Section -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-pink-400 mb-4">推理引擎狀態</h3>
|
||||
|
||||
<div v-if="inferenceHealth" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Ollama (Embedding) -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-pink-400">🧠</span>
|
||||
<span class="font-semibold">{{ inferenceHealth.ollama.engine }}</span>
|
||||
</div>
|
||||
<span :class="inferenceHealth.ollama.status === 'ok' ? 'text-green-400' : 'text-red-400'">
|
||||
{{ inferenceHealth.ollama.status === 'ok' ? '●' : '○' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">模型</span>
|
||||
<span class="text-white">{{ inferenceHealth.ollama.model }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">用途</span>
|
||||
<span class="text-purple-400">Embedding</span>
|
||||
</div>
|
||||
<div v-if="inferenceHealth.ollama.latency_ms" class="flex justify-between">
|
||||
<span class="text-gray-500">延遲</span>
|
||||
<span class="text-white">{{ inferenceHealth.ollama.latency_ms }}ms</span>
|
||||
</div>
|
||||
<div v-if="inferenceHealth.ollama.error" class="text-red-400 text-xs mt-2">
|
||||
{{ inferenceHealth.ollama.error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- llama-server (LLM) -->
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-cyan-400">💬</span>
|
||||
<span class="font-semibold">{{ inferenceHealth.llama_server.engine }}</span>
|
||||
</div>
|
||||
<span :class="inferenceHealth.llama_server.status === 'ok' ? 'text-green-400' : 'text-red-400'">
|
||||
{{ inferenceHealth.llama_server.status === 'ok' ? '●' : '○' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">模型</span>
|
||||
<span class="text-white">{{ inferenceHealth.llama_server.model }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">用途</span>
|
||||
<span class="text-cyan-400">LLM (5W1H, Summary)</span>
|
||||
</div>
|
||||
<div v-if="inferenceHealth.llama_server.latency_ms" class="flex justify-between">
|
||||
<span class="text-gray-500">延遲</span>
|
||||
<span class="text-white">{{ inferenceHealth.llama_server.latency_ms }}ms</span>
|
||||
</div>
|
||||
<div v-if="inferenceHealth.llama_server.error" class="text-red-400 text-xs mt-2">
|
||||
{{ inferenceHealth.llama_server.error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">載入中...</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Check Section -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-blue-400">服務狀態</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-400">API: {{ apiBaseUrl }}</span>
|
||||
<button
|
||||
@click="refreshHealth"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? '檢查中...' : '重新檢查' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status -->
|
||||
<div v-if="healthError" class="bg-red-900/30 rounded-lg p-4 border border-red-700">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-red-400">⚠</span>
|
||||
<span class="text-red-300">{{ healthError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="health" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- PostgreSQL -->
|
||||
<ServiceStatusCard
|
||||
name="PostgreSQL"
|
||||
:status="health.services.postgres.status"
|
||||
:latency="health.services.postgres.latency_ms"
|
||||
:error="health.services.postgres.error"
|
||||
/>
|
||||
<!-- Redis -->
|
||||
<ServiceStatusCard
|
||||
name="Redis"
|
||||
:status="health.services.redis.status"
|
||||
:latency="health.services.redis.latency_ms"
|
||||
:error="health.services.redis.error"
|
||||
/>
|
||||
<!-- Qdrant -->
|
||||
<ServiceStatusCard
|
||||
name="Qdrant"
|
||||
:status="health.services.qdrant.status"
|
||||
:latency="health.services.qdrant.latency_ms"
|
||||
:error="health.services.qdrant.error"
|
||||
/>
|
||||
<!-- MongoDB -->
|
||||
<ServiceStatusCard
|
||||
name="MongoDB"
|
||||
:status="health.services.mongodb.status"
|
||||
:latency="health.services.mongodb.latency_ms"
|
||||
:error="health.services.mongodb.error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-gray-400 text-sm">載入中...</div>
|
||||
|
||||
<!-- Version Info -->
|
||||
<div v-if="health" class="mt-4 pt-4 border-t border-gray-700 flex justify-between text-sm text-gray-400">
|
||||
<span>版本: {{ health.version }}</span>
|
||||
<span>運行時間: {{ formatUptime(health.uptime_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-blue-400 mb-2">搜尋功能</h3>
|
||||
<p class="text-gray-400">智能搜尋影片內容,支援語意向量與關鍵字檢索</p>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-green-400 mb-2">人物管理</h3>
|
||||
<p class="text-gray-400">管理全域身份、區域人物與臉部特徵</p>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-purple-400 mb-2">臉部擷取</h3>
|
||||
<p class="text-gray-400">擷取並管理人物臉部截圖</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getHealth, getIngestStats, getSftpgoStatus, getInferenceHealth, getCurrentConfig, isTauri } from '@/api/client'
|
||||
|
||||
interface ServiceStatus {
|
||||
status: string
|
||||
latency_ms: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface ServiceHealth {
|
||||
postgres: ServiceStatus
|
||||
redis: ServiceStatus
|
||||
qdrant: ServiceStatus
|
||||
mongodb: ServiceStatus
|
||||
}
|
||||
|
||||
interface DetailedHealthResponse {
|
||||
status: string
|
||||
version: string
|
||||
uptime_ms: number
|
||||
services: ServiceHealth
|
||||
}
|
||||
|
||||
interface IngestStats {
|
||||
total_videos: number
|
||||
total_chunks: number
|
||||
sentence_chunks: number
|
||||
cut_chunks: number
|
||||
time_chunks: number
|
||||
searchable_chunks: number
|
||||
chunks_with_visual: number
|
||||
chunks_with_summary: number
|
||||
pending_videos: number
|
||||
}
|
||||
|
||||
interface RegisteredVideo {
|
||||
uuid: string
|
||||
file_name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SftpgoStatus {
|
||||
username: string
|
||||
home_dir: string
|
||||
files_count: number
|
||||
registered_videos: RegisteredVideo[]
|
||||
last_login: string | null
|
||||
}
|
||||
|
||||
interface InferenceEngineStatus {
|
||||
engine: string
|
||||
model: string
|
||||
status: string
|
||||
latency_ms: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface InferenceHealthResponse {
|
||||
ollama: InferenceEngineStatus
|
||||
llama_server: InferenceEngineStatus
|
||||
}
|
||||
|
||||
const health = ref<DetailedHealthResponse | null>(null)
|
||||
const healthError = ref<string | null>(null)
|
||||
const ingestStats = ref<IngestStats | null>(null)
|
||||
const sftpgoStatus = ref<SftpgoStatus | null>(null)
|
||||
const inferenceHealth = ref<InferenceHealthResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
const apiBaseUrl = ref(getCurrentConfig().api_base_url)
|
||||
const sftpgoUrl = computed(() => {
|
||||
const base = getCurrentConfig().api_base_url
|
||||
try {
|
||||
const url = new URL(base)
|
||||
if (url.hostname === '127.0.0.1' || url.hostname === '192.168.110.210') {
|
||||
return 'https://sftpgo.momentry.ddns.net/web/client'
|
||||
}
|
||||
return `http://${url.hostname}:8080/web/client`
|
||||
} catch {
|
||||
return 'https://sftpgo.momentry.ddns.net/web/client'
|
||||
}
|
||||
})
|
||||
const statusMsg = ref<{ text: string; type: string } | null>(null)
|
||||
|
||||
async function fetchHealth() {
|
||||
loading.value = true
|
||||
healthError.value = null
|
||||
try {
|
||||
health.value = await getHealth()
|
||||
} catch (e) {
|
||||
healthError.value = String(e)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function fetchIngestStats() {
|
||||
try {
|
||||
ingestStats.value = await getIngestStats()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch ingest stats:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSftpgoStatus() {
|
||||
try {
|
||||
sftpgoStatus.value = await getSftpgoStatus()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch sftpgo status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInferenceHealth() {
|
||||
try {
|
||||
inferenceHealth.value = await getInferenceHealth()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch inference health:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function openSftpgoFiles() {
|
||||
const url = sftpgoUrl.value
|
||||
console.log('Momentry: Opening URL:', url, 'isTauri:', isTauri())
|
||||
statusMsg.value = { text: '即將開啟:' + url, type: 'ok' }
|
||||
|
||||
if (isTauri()) {
|
||||
try {
|
||||
import('@tauri-apps/api/core').then(({ invoke }) => {
|
||||
invoke('plugin:shell|open', { path: url }).then(() => {
|
||||
console.log('Momentry: Opened with shell')
|
||||
statusMsg.value = { text: '已開啟', type: 'ok' }
|
||||
}).catch((e) => {
|
||||
console.error('Momentry: Shell error:', e)
|
||||
statusMsg.value = { text: '開啟失敗:' + e, type: 'err' }
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Momentry: Import error:', e)
|
||||
statusMsg.value = { text: '導入失敗:' + e, type: 'err' }
|
||||
}
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank')?.focus()
|
||||
}
|
||||
|
||||
function copySftpgoUrl() {
|
||||
navigator.clipboard.writeText(sftpgoUrl.value)
|
||||
statusMsg.value = { text: '已複製網址:' + sftpgoUrl.value, type: 'ok' }
|
||||
}
|
||||
|
||||
async function refreshHealth() {
|
||||
await fetchHealth()
|
||||
}
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ${hours % 24}h`
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchHealth()
|
||||
fetchIngestStats()
|
||||
fetchSftpgoStatus()
|
||||
fetchInferenceHealth()
|
||||
})
|
||||
</script>
|
||||
166
src/views/IdentityDetailView.vue
Normal file
166
src/views/IdentityDetailView.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="$router.back()" class="text-gray-400 hover:text-white">
|
||||
← 返回
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">{{ profile.name || '未命名身份' }}</h2>
|
||||
<p class="text-sm text-gray-400">全域身份 ID: {{ identityId }}</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">
|
||||
<!-- Profile 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-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">本名</span>
|
||||
<p class="text-white text-lg">{{ profile.name || '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">角色名</span>
|
||||
<p class="text-white text-lg">{{ profile.character_name || '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">別名 (Aliases)</span>
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<span v-for="(alias, idx) in profile.aliases" :key="idx" class="bg-gray-700 text-gray-300 px-2 py-1 rounded text-sm">
|
||||
{{ alias }}
|
||||
</span>
|
||||
<span v-if="!profile.aliases || profile.aliases.length === 0" class="text-gray-600 text-sm">無</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">Speaker ID</span>
|
||||
<p class="text-white text-lg font-mono">{{ profile.speaker_id || '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">性別</span>
|
||||
<p class="text-white">{{ profile.gender || '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">年齡</span>
|
||||
<p class="text-white">{{ profile.age || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Videos List -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-green-400 mb-4">出現影片 ({{ videos.length }})</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left text-gray-400">
|
||||
<thead class="text-xs text-gray-500 uppercase bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 rounded-l-lg">影片名稱</th>
|
||||
<th scope="col" class="px-6 py-3">出現次數</th>
|
||||
<th scope="col" class="px-6 py-3 rounded-r-lg">首次出現</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="video in videos" :key="video.file_uuid" class="bg-gray-800 border-b border-gray-700 hover:bg-gray-750">
|
||||
<td class="px-6 py-4 font-medium text-white">{{ video.file_name }}</td>
|
||||
<td class="px-6 py-4">{{ video.appearance_count }}</td>
|
||||
<td class="px-6 py-4">{{ video.first_appearance?.toFixed(2) || '-' }}s</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D Face Viewer -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold mb-4 text-blue-400">3D 臉部</h3>
|
||||
<p class="text-sm text-gray-400 mb-3">立體臉部網格(MediaPipe Face Mesh,468 landmarks)</p>
|
||||
<div class="h-[350px]">
|
||||
<Face3DViewer v-if="faceLandmarks.length" :landmarks="faceLandmarks" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 text-sm">
|
||||
{{ faceLoading ? '正在取得臉部資料...' : '尚無臉部資料' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Face3DViewer from '@/components/Face3DViewer.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { httpFetch, getCurrentConfig } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const identityId = ref('')
|
||||
const loading = ref(false)
|
||||
const detail = ref<any>(null)
|
||||
const profile = ref<any>({})
|
||||
const videos = ref<any[]>([])
|
||||
const faceLandmarks = ref<number[][]>([])
|
||||
const faceLoading = ref(false)
|
||||
|
||||
const loadDetail = async () => {
|
||||
identityId.value = route.params.identity_uuid as string
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const config = getCurrentConfig()
|
||||
const result = await httpFetch<any>(`${config.api_base_url}/api/v1/identity/${identityId.value}`)
|
||||
detail.value = result
|
||||
profile.value = result.profile || {}
|
||||
videos.value = result.videos || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load identity detail:', error)
|
||||
alert('載入失敗: ' + error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFaceLandmarks() {
|
||||
faceLoading.value = true
|
||||
try {
|
||||
const config = getCurrentConfig()
|
||||
// Use first video's file_uuid for thumbnail (identity UUID doesn't work for thumbnails)
|
||||
const firstFileUuid = videos.value?.[0]?.file_uuid || identityId.value
|
||||
const thumbUrl = `${config.api_base_url}/api/v1/file/${firstFileUuid}/thumbnail?frame=1`
|
||||
const thumbResp = await fetch(thumbUrl, {
|
||||
headers: config.api_key ? { 'X-API-Key': config.api_key } : {}
|
||||
})
|
||||
const blob = await thumbResp.blob()
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const b64 = (reader.result as string).split(',')[1]
|
||||
try {
|
||||
const lmResp = await fetch('http://localhost:11437/v1/face/landmarks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: b64 })
|
||||
})
|
||||
const data = await lmResp.json()
|
||||
if (data?.landmarks?.length) {
|
||||
faceLandmarks.value = data.landmarks.map((lm: any) => [lm.x, lm.y, lm.z])
|
||||
}
|
||||
} catch {
|
||||
// Fallback to sample
|
||||
const fallback = await fetch('/sample_face_landmarks.json')
|
||||
const fbData = await fallback.json()
|
||||
if (fbData?.landmarks?.length) faceLandmarks.value = fbData.landmarks
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
} catch { /* ignore */ }
|
||||
faceLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
loadFaceLandmarks()
|
||||
})
|
||||
</script>
|
||||
237
src/views/LoginView.vue
Normal file
237
src/views/LoginView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen bg-gray-900">
|
||||
<div class="w-full max-w-md p-8 bg-gray-800 rounded-lg shadow-xl border border-gray-700">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-blue-400">Momentry</h1>
|
||||
<p class="text-gray-400 mt-2">Video Analysis Portal</p>
|
||||
</div>
|
||||
|
||||
<!-- Server Selector -->
|
||||
<div class="mb-5 pb-4 border-b border-gray-700">
|
||||
<label class="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">伺服器</label>
|
||||
|
||||
<!-- Preset buttons -->
|
||||
<div v-if="!showCustomUrl" class="grid grid-cols-2 gap-1.5">
|
||||
<button v-for="srv in serverPresets" :key="srv.label"
|
||||
@click="selectServer(srv)"
|
||||
:class="selectedUrl === srv.url ? 'bg-blue-700 border-blue-500 ring-1 ring-blue-400' : 'bg-gray-700/70 border-gray-600 hover:bg-gray-600'"
|
||||
class="px-2.5 py-2 rounded text-xs border text-left transition"
|
||||
>
|
||||
<div class="font-medium text-white leading-tight">{{ srv.label }}</div>
|
||||
<div class="text-[10px] text-gray-400 truncate leading-tight mt-0.5">{{ srv.short }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom URL input -->
|
||||
<div v-else>
|
||||
<input v-model="customUrl" type="text" placeholder="http://host:port"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white text-xs focus:outline-none focus:border-blue-500 font-mono"
|
||||
@input="onCustomUrlInput" />
|
||||
<div v-if="customUrl && !/^https?:\/\/.+/.test(customUrl)" class="text-yellow-400 text-[10px] mt-1">
|
||||
格式:http://host:port 或 https://host:port
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-1.5">
|
||||
<button @click="toggleCustom" class="text-[10px] text-blue-400 hover:text-blue-300">
|
||||
{{ showCustomUrl ? '← 使用預設' : '自訂伺服器...' }}
|
||||
</button>
|
||||
<span class="text-[10px] text-gray-500 font-mono">{{ selectedUrl }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1">Username</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Enter username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1">Password</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="w-full px-4 py-2 pr-10 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3S1.732 5.943.458 10c-.18.163-.352.328-.507.48zM10 12a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" />
|
||||
<path d="M10 5a1 1 0 011 1 1 1 0 01-2 0 1 1 0 011-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="bg-red-900/50 border border-red-700 rounded p-3 text-sm text-red-300">
|
||||
{{ error }}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded shadow transition disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- API Demo (dev mode only) -->
|
||||
<div v-if="showApiExamples" class="mt-8 pt-6 border-t border-gray-700">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">API 範例 <span class="text-xs text-yellow-500">(dev)</span></h3>
|
||||
<div class="space-y-2 text-xs font-mono">
|
||||
<div class="bg-gray-900 p-2 rounded">
|
||||
<span class="text-green-400"># Login</span>
|
||||
<pre class="text-gray-300 whitespace-pre-wrap">curl -X POST {{ baseUrl }}/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"demo","password":"demo"}'</pre>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-2 rounded">
|
||||
<span class="text-red-400"># Logout</span>
|
||||
<pre class="text-gray-300">curl -X POST {{ baseUrl }}/api/v1/auth/logout \
|
||||
-H "X-API-Key: YOUR_API_KEY"</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { httpFetch, getCurrentConfig, saveConfig } from '@/api/client'
|
||||
|
||||
interface ServerPreset {
|
||||
label: string
|
||||
url: string
|
||||
short: string
|
||||
}
|
||||
|
||||
const serverPresets: ServerPreset[] = [
|
||||
{ label: 'M4mini:3002', url: 'http://127.0.0.1:3002', short: '127.0.0.1:3002' },
|
||||
{ label: 'M4mini:3003', url: 'http://127.0.0.1:3003', short: '127.0.0.1:3003' },
|
||||
{ label: 'M5Max48:3002', url: 'http://192.168.110.201:3002', short: '192.168.110.201:3002' },
|
||||
{ label: 'M5Max48:3003', url: 'http://192.168.110.201:3003', short: '192.168.110.201:3003' },
|
||||
{ label: 'M5Max128:3002', url: 'http://10.10.10.88:3002', short: '10.10.10.88:3002' },
|
||||
{ label: 'M5Max128:3003', url: 'http://10.10.10.88:3003', short: '10.10.10.88:3003' },
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const username = ref(route.query.username as string || '')
|
||||
const password = ref(route.query.password as string || '')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
|
||||
const showCustomUrl = ref(false)
|
||||
const customUrl = ref('')
|
||||
const selectedUrl = ref('')
|
||||
|
||||
function initSelectedServer() {
|
||||
const config = getCurrentConfig()
|
||||
const currentUrl = config.api_base_url
|
||||
const matched = serverPresets.find(s => s.url === currentUrl)
|
||||
if (matched) {
|
||||
selectedUrl.value = matched.url
|
||||
showCustomUrl.value = false
|
||||
} else {
|
||||
selectedUrl.value = currentUrl
|
||||
customUrl.value = currentUrl
|
||||
showCustomUrl.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function selectServer(srv: ServerPreset) {
|
||||
selectedUrl.value = srv.url
|
||||
const config = getCurrentConfig()
|
||||
saveConfig({ ...config, api_base_url: srv.url })
|
||||
}
|
||||
|
||||
function toggleCustom() {
|
||||
showCustomUrl.value = !showCustomUrl.value
|
||||
if (!showCustomUrl.value) {
|
||||
const matched = serverPresets.find(s => s.url === selectedUrl.value)
|
||||
if (matched) {
|
||||
selectServer(matched)
|
||||
} else {
|
||||
selectServer(serverPresets[0])
|
||||
}
|
||||
} else {
|
||||
customUrl.value = selectedUrl.value
|
||||
}
|
||||
}
|
||||
|
||||
function onCustomUrlInput() {
|
||||
selectedUrl.value = customUrl.value
|
||||
const config = getCurrentConfig()
|
||||
saveConfig({ ...config, api_base_url: customUrl.value })
|
||||
}
|
||||
|
||||
const baseUrl = computed(() => selectedUrl.value)
|
||||
const showApiExamples = ref(localStorage.getItem('devMode') === 'true')
|
||||
|
||||
onMounted(() => {
|
||||
initSelectedServer()
|
||||
if (username.value && password.value) {
|
||||
handleLogin()
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
let apiUrl = selectedUrl.value
|
||||
if (showCustomUrl.value && customUrl.value) {
|
||||
apiUrl = customUrl.value
|
||||
const config = getCurrentConfig()
|
||||
saveConfig({ ...config, api_base_url: customUrl.value })
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await httpFetch<{
|
||||
success: boolean
|
||||
message?: string
|
||||
api_key: string
|
||||
user: Record<string, any>
|
||||
}>(`${apiUrl}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: username.value, password: password.value })
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
localStorage.setItem('momentry_user', JSON.stringify(data.user))
|
||||
localStorage.setItem('momentry_api_key', data.api_key)
|
||||
const config = getCurrentConfig()
|
||||
saveConfig({ ...config, api_key: data.api_key })
|
||||
const redirect = (route.query.redirect as string) || '/home'
|
||||
router.push(redirect)
|
||||
} else {
|
||||
error.value = data.message || 'Login failed'
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('401')) {
|
||||
error.value = 'Invalid username or password'
|
||||
} else {
|
||||
error.value = 'Connection error. Is the server running?'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
11
src/views/NotFoundView.vue
Normal file
11
src/views/NotFoundView.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div class="text-8xl font-bold text-gray-600 mb-4">404</div>
|
||||
<h2 class="text-2xl font-semibold text-gray-300 mb-2">頁面不存在</h2>
|
||||
<p class="text-gray-500 mb-8">您要尋找的頁面不存在或已被移除</p>
|
||||
<router-link to="/home"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg transition">
|
||||
回到首頁
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
75
src/views/PersonsView.vue
Normal file
75
src/views/PersonsView.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold">身分管理</h2>
|
||||
<button @click="loadPersons" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition">重新整理</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<input v-model="filterQuery" @keyup.enter="loadPersons" placeholder="搜尋身分..."
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
|
||||
<div v-if="persons.length > 0" class="grid gap-4">
|
||||
<div v-for="person in persons" :key="person.id" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="w-16 h-16 bg-gray-700 rounded-lg overflow-hidden border border-gray-600 flex-shrink-0 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<h3 class="text-xl font-semibold text-blue-400">{{ person.name || '未命名' }}</h3>
|
||||
<span class="bg-green-900 text-green-300 px-2 py-1 rounded text-xs">{{ person.source || 'system' }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm text-gray-400 mb-3">
|
||||
<div>identity_uuid: {{ person.identity_uuid }}</div>
|
||||
<div v-if="person.metadata?.tmdb_movie_title">電影: {{ person.metadata.tmdb_movie_title }}</div>
|
||||
<div v-if="person.metadata?.tmdb_character">角色: {{ person.metadata.tmdb_character }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<button @click="viewDetails(person)" class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded-lg text-sm transition">查看詳情</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="text-center py-12 text-gray-500">載入中...</div>
|
||||
<div v-else class="text-center py-12 text-gray-500">尚無身分資料</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { httpFetch, getCurrentConfig } from '@/api/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const persons = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const filterQuery = ref('')
|
||||
const router = useRouter()
|
||||
|
||||
const loadPersons = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const config = getCurrentConfig()
|
||||
const params = new URLSearchParams({ page: '1', page_size: '50' })
|
||||
if (filterQuery.value.trim()) params.set('query', filterQuery.value.trim())
|
||||
const result = await httpFetch<any>(`${config.api_base_url}/api/v1/identities?${params}`)
|
||||
persons.value = result.identities || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load identities:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const viewDetails = (person: any) => {
|
||||
router.push({
|
||||
name: 'identity-detail',
|
||||
params: { identity_uuid: person.identity_uuid }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => { loadPersons() })
|
||||
</script>
|
||||
370
src/views/PipelineProgressView.vue
Normal file
370
src/views/PipelineProgressView.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-gray-100 p-6">
|
||||
|
||||
<div v-if="loading" class="text-center py-12"><p class="text-gray-400">載入中...</p></div>
|
||||
<div v-else-if="error" class="bg-red-900/50 border border-red-700 rounded p-4 mb-4">
|
||||
<p class="text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- ═══ 頂部:標題 + 篩選 + 搜尋 ═══ -->
|
||||
<div class="flex flex-wrap items-center justify-between mb-4 gap-3">
|
||||
<h1 class="text-2xl font-bold">📋 檔案歷程</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 狀態篩選 -->
|
||||
<button v-for="f in filterOptions" :key="f.key"
|
||||
@click="activeFilter = f.key"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
:class="activeFilter === f.key ? 'bg-blue-700 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'">
|
||||
{{ f.label }}
|
||||
</button>
|
||||
<!-- 搜尋 -->
|
||||
<input v-model="searchQuery" placeholder="搜尋 UUID 或檔名..."
|
||||
class="bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm w-48 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Job 清單(摺疊) ═══ -->
|
||||
<div class="bg-gray-800 rounded-lg mb-4 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700 text-xs">
|
||||
<th class="text-left py-2 px-3 w-12">#</th>
|
||||
<th class="text-left py-2">檔案名稱</th>
|
||||
<th class="text-left py-2 w-16">狀態</th>
|
||||
<th class="text-left py-2 w-20">時間</th>
|
||||
<th class="text-left py-2 w-16">進度</th>
|
||||
<th class="text-left py-2 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in filteredJobs" :key="job.id"
|
||||
@click="selectedId = job.id"
|
||||
class="border-b border-gray-700/30 cursor-pointer transition"
|
||||
:class="selectedId === job.id ? 'bg-blue-900/30' : 'hover:bg-gray-700/30'">
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-500">{{ job.id }}</td>
|
||||
<td class="py-2 truncate max-w-64">{{ job.file_name || '未知' }}</td>
|
||||
<td class="py-2"><span :class="statusBadge(job.status)" class="px-2 py-0.5 rounded text-xs">{{ job.status }}</span></td>
|
||||
<td class="py-2 font-mono text-xs text-gray-400">{{ job.createdAt || '-' }}</td>
|
||||
<td class="py-2">{{ completedCount(job) }}/{{ job.processorList?.length || 0 }}</td>
|
||||
<td class="py-2 text-xs text-gray-500">{{ selectedId === job.id ? '◀' : '▶' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 選中的 Job 詳細資料 ═══ -->
|
||||
<div v-if="selectedJob">
|
||||
<!-- ① 檔案基本資料 -->
|
||||
<div class="bg-gray-800 rounded-lg p-5 mb-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
{{ selectedJob.file_name || '未知檔案' }}
|
||||
<span :class="statusBadge(selectedJob.status)" class="px-2 py-0.5 rounded text-xs">{{ selectedJob.status }}</span>
|
||||
</h2>
|
||||
<p class="text-gray-400 text-xs mt-1 font-mono">UUID: {{ selectedJob.uuid || '-' }}</p>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-500">
|
||||
<div>Job #{{ selectedJob.id }}</div>
|
||||
<div v-if="selectedJob.metadata && selectedJob.metadata['duration']">{{ Math.round(selectedJob.metadata['duration']/60) }}min</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedJob.metadata" class="grid grid-cols-4 gap-3 text-sm bg-gray-900/50 rounded p-3 mb-3">
|
||||
<div><span class="text-gray-500">長度</span><br>{{ selectedJob.metadata['duration'] ? Math.round(selectedJob.metadata['duration']) + 's' : '-' }}</div>
|
||||
<div><span class="text-gray-500">解析度</span><br>{{ selectedJob.metadata['width'] || '?' }}x{{ selectedJob.metadata['height'] || '?' }}</div>
|
||||
<div><span class="text-gray-500">FPS</span><br>{{ selectedJob.metadata['fps'] || '?' }}</div>
|
||||
<div><span class="text-gray-500">總幀數</span><br>{{ selectedJob.metadata['total_frames'] || '?' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap mb-3" v-if="selectedJob.uuid">
|
||||
<a :href="baseURL + '/api/v1/file/' + selectedJob.uuid + '/video'" target="_blank" class="px-3 py-1 bg-blue-700 hover:bg-blue-600 rounded text-xs">🎬 串流</a>
|
||||
<a :href="baseURL + '/api/v1/file/' + selectedJob.uuid + '/thumbnail?frame=0'" target="_blank" class="px-3 py-1 bg-green-700 hover:bg-green-600 rounded text-xs">🖼️ 縮圖</a>
|
||||
<router-link :to="'/search?uuid=' + selectedJob.uuid" class="px-3 py-1 bg-purple-700 hover:bg-purple-600 rounded text-xs">🔍 搜尋</router-link>
|
||||
</div>
|
||||
|
||||
<!-- ② 時間軸 -->
|
||||
<div v-if="selectedJob.timeline && selectedJob.timeline.length" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">⏱️ 處理時間軸</h3>
|
||||
<div class="relative h-8 bg-gray-900 rounded overflow-hidden">
|
||||
<div v-for="(seg, i) in selectedJob.timeline" :key="i"
|
||||
:title="seg.label + ': ' + seg.duration"
|
||||
class="absolute h-full flex items-center justify-center text-xs font-bold text-white truncate"
|
||||
:style="{ left: seg.left + '%', width: seg.width + '%', background: seg.color }">
|
||||
{{ seg.width > 8 ? seg.label : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-1 text-xs text-gray-500 flex-wrap">
|
||||
<span v-for="(seg, i) in selectedJob.timeline" :key="'l'+i"><span :style="{ color: seg.color }">●</span> {{ seg.label }} ({{ seg.duration }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ Processors -->
|
||||
<table class="w-full text-sm mb-3">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700">
|
||||
<th class="text-left py-2 w-20">Proc</th>
|
||||
<th class="text-left py-2 w-10">St</th>
|
||||
<th class="text-left py-2 w-14">Start</th>
|
||||
<th class="text-left py-2 w-14">End</th>
|
||||
<th class="text-left py-2 w-16">耗時</th>
|
||||
<th class="text-right py-2">已產出</th>
|
||||
<th class="text-right py-2">已處理</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in selectedJob.processorList" :key="p.name" class="border-b border-gray-700/50 hover:bg-gray-700/30">
|
||||
<td class="py-1.5 font-mono text-sm">{{ p.name }}</td>
|
||||
<td class="py-1.5">{{ statusIcon(p.status) }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.start }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.end }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.duration || '-' }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-sm">{{ p.chunks ?? '-' }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-sm">{{ p.frames ?? '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="text-xs text-gray-500 mb-3">已處理 {{ completedCount(selectedJob) }}/{{ selectedJob.processorList?.length || 0 }}</div>
|
||||
|
||||
<!-- ④ Post-Processing -->
|
||||
<div v-if="selectedJob.postProcessing" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">⚙️ Post-Processing</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700">
|
||||
<th class="text-left py-2">Stage</th>
|
||||
<th class="text-left py-2 w-10">St</th>
|
||||
<th class="text-right py-2 w-16">已產出</th>
|
||||
<th class="text-left py-2 pl-4">依賴進度狀態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pp in selectedJob.postProcessing" :key="pp.stage" class="border-b border-gray-700/50 hover:bg-gray-700/30">
|
||||
<td class="py-1.5 text-sm">{{ pp.stage }}</td>
|
||||
<td class="py-1.5">{{ statusIcon(pp.status) }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-xs text-gray-400">{{ pp.output || '-' }}</td>
|
||||
<td class="py-1.5 pl-4 font-mono text-xs text-gray-400">{{ pp.deps }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ⑤ Resources -->
|
||||
<div v-if="selectedJob.processorList.some(p => p.version)" class="mb-2">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">🔧 Resources</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
<div v-for="p in selectedJob.processorList.filter(p => p.version)" :key="p.name" class="bg-gray-900/50 rounded p-2 text-xs">
|
||||
<div class="text-gray-400">{{ p.name }}</div>
|
||||
<div class="font-mono text-gray-300 truncate">{{ p.version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 無匹配 -->
|
||||
<div v-if="filteredJobs.length === 0" class="text-center py-12 text-gray-500">無符合條件的檔案記錄</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { httpFetch } from '@/api/client'
|
||||
|
||||
interface ProcessorInfo {
|
||||
name: string; status: string; start: string; end: string; duration: string
|
||||
chunks: number; frames: number; version: string
|
||||
}
|
||||
interface PostProcessInfo { stage: string; status: string; output: string; deps: string }
|
||||
interface TimelineSeg { label: string; left: number; width: number; color: string; duration: string }
|
||||
interface JobInfo {
|
||||
id: number; uuid: string; status: string; file_name: string; createdAt: string
|
||||
metadata: any
|
||||
timeline: TimelineSeg[]
|
||||
processorList: ProcessorInfo[]
|
||||
postProcessing: PostProcessInfo[]
|
||||
}
|
||||
|
||||
const baseURL = JSON.parse(localStorage.getItem('portal_config') || '{}').api_base_url || 'http://127.0.0.1:3003'
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const jobs = ref<JobInfo[]>([])
|
||||
const activeFilter = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const selectedId = ref<number | null>(null)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const filterOptions = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'running', label: '⏳ Running' },
|
||||
{ key: 'completed', label: '✅ Completed' },
|
||||
{ key: 'failed', label: '❌ Failed' },
|
||||
]
|
||||
|
||||
const filteredJobs = computed(() => {
|
||||
let list = jobs.value
|
||||
if (activeFilter.value !== 'all') {
|
||||
list = list.filter(j => j.status === activeFilter.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(j =>
|
||||
(j.file_name && j.file_name.toLowerCase().includes(q)) ||
|
||||
(j.uuid && j.uuid.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const selectedJob = computed(() => {
|
||||
return jobs.value.find(j => j.id === selectedId.value) || null
|
||||
})
|
||||
|
||||
const procColors: Record<string, string> = {
|
||||
cut: '#3b82f6', face: '#10b981', ocr: '#f59e0b',
|
||||
pose: '#8b5cf6', yolo: '#ef4444', asr: '#06b6d4', asrx: '#ec4899'
|
||||
}
|
||||
|
||||
function statusIcon(st: string): string {
|
||||
return ({ completed: '✅', running: '⏳', pending: '⬜', failed: '❌', skipped: '⏭️' })[st] || '⬜'
|
||||
}
|
||||
function statusBadge(st: string): string {
|
||||
return ({
|
||||
completed: 'bg-green-700 text-green-200', running: 'bg-blue-700 text-blue-200',
|
||||
failed: 'bg-red-700 text-red-200'
|
||||
})[st] || 'bg-gray-600 text-gray-300'
|
||||
}
|
||||
function completedCount(job: JobInfo): number {
|
||||
return job.processorList?.filter(p => p.status === 'completed').length || 0
|
||||
}
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
try { return new Date(iso).toTimeString().substring(0, 5) }
|
||||
catch { return iso.substring(11, 16) }
|
||||
}
|
||||
function formatDuration(secs: number): string {
|
||||
if (!secs || secs <= 0) return '-'
|
||||
if (secs < 60) return Math.round(secs) + 's'
|
||||
return Math.floor(secs / 60) + 'm ' + Math.round(secs % 60) + 's'
|
||||
}
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
try { return new Date(iso).toLocaleString('zh-TW', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }
|
||||
catch { return iso.substring(5, 16) }
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const resp: any = await httpFetch(`${baseURL}/api/v1/jobs`)
|
||||
const rawJobs = resp?.jobs || []
|
||||
const result: JobInfo[] = []
|
||||
|
||||
for (const j of (Array.isArray(rawJobs) ? rawJobs : []).slice(-10)) {
|
||||
const jobId = j.id
|
||||
const uuid = j.uuid || ''
|
||||
let processors: ProcessorInfo[] = []
|
||||
let postProcessing: PostProcessInfo[] = []
|
||||
let fileName = ''
|
||||
let fileMeta: Record<string, any> | null = null
|
||||
let timeline: TimelineSeg[] = []
|
||||
|
||||
if (uuid) {
|
||||
try {
|
||||
// Fetch file probe
|
||||
const probe: any = await httpFetch(`${baseURL}/api/v1/file/${uuid}/probe`)
|
||||
fileMeta = probe || null
|
||||
fileName = probe?.file_name || fileName
|
||||
|
||||
// Fetch progress
|
||||
const prog: any = await httpFetch(`${baseURL}/api/v1/progress/${uuid}`)
|
||||
fileName = prog?.file_name || fileName
|
||||
|
||||
const procMap: Record<string, any> = {}
|
||||
for (const p of (prog?.processors || [])) procMap[p.name] = p
|
||||
|
||||
const procOrder = ['cut', 'face', 'ocr', 'pose', 'yolo', 'asr', 'asrx']
|
||||
const parsed: { name: string; start: number; end: number; status: string }[] = []
|
||||
|
||||
for (const name of procOrder) {
|
||||
const p = procMap[name] || { status: 'pending' }
|
||||
const startStr = p.started_at || ''
|
||||
const endStr = p.completed_at || ''
|
||||
const startMs = startStr ? new Date(startStr).getTime() : 0
|
||||
const endMs = endStr ? new Date(endStr).getTime() : (startMs || 0)
|
||||
const dur = (endMs && endMs >= startMs) ? (endMs - startMs) / 1000 : 0
|
||||
|
||||
processors.push({
|
||||
name, status: p.status,
|
||||
start: formatTime(startStr),
|
||||
end: formatTime(endStr),
|
||||
duration: formatDuration(dur),
|
||||
chunks: p.chunks_produced ?? 0,
|
||||
frames: p.frames_processed ?? 0,
|
||||
version: p.version || ''
|
||||
})
|
||||
if (startMs && startStr) {
|
||||
parsed.push({ name, start: startMs, end: endMs || Date.now(), status: p.status })
|
||||
}
|
||||
}
|
||||
|
||||
// Build timeline
|
||||
if (parsed.length > 0) {
|
||||
const minT = Math.min(...parsed.map(p => p.start))
|
||||
const maxT = Math.max(...parsed.map(p => p.end === Date.now() ? Date.now() : p.end))
|
||||
const range = maxT - minT || 1
|
||||
for (const p of parsed) {
|
||||
timeline.push({
|
||||
label: p.name,
|
||||
left: ((p.start - minT) / range) * 100,
|
||||
width: Math.max(((p.end - p.start) / range) * 100, 3),
|
||||
color: procColors[p.name] || '#6b7280',
|
||||
duration: formatDuration((p.end - p.start) / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Post-processing deps
|
||||
const allDone = processors.every(p => p.status === 'completed')
|
||||
const S = (n: string) => statusIcon(procMap[n]?.status || 'pending')
|
||||
postProcessing = [
|
||||
{ stage: 'Rule 1 chunks', status: allDone ? 'running' : 'pending', output: '-', deps: `ASR${S('asr')} + ASRX${S('asrx')}` },
|
||||
{ stage: 'face_trace', status: allDone ? 'running' : 'pending', output: '-', deps: `cut${S('cut')} face${S('face')} ocr${S('ocr')} pose${S('pose')} yolo${S('yolo')} asr${S('asr')} asrx${S('asrx')}` },
|
||||
{ stage: 'Qdrant face sync', status: 'pending', output: '-', deps: 'face_trace⬜' },
|
||||
{ stage: 'Qdrant voice', status: 'pending', output: '-', deps: `ASRX${S('asrx')} (inline)` },
|
||||
{ stage: 'ANE vectorize', status: 'pending', output: '-', deps: 'Rule 1 chunks⬜' },
|
||||
{ stage: '5W1H Agent', status: 'pending', output: '-', deps: 'Rule 1⬜ + Rule 3⬜' },
|
||||
]
|
||||
} catch (e) { console.warn(`skip ${uuid}:`, e) }
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: jobId, uuid, status: j.status || 'unknown', file_name: fileName,
|
||||
createdAt: j.created_at ? formatDateTime(j.created_at) : '',
|
||||
metadata: fileMeta, timeline, processorList: processors, postProcessing
|
||||
})
|
||||
}
|
||||
jobs.value = result.reverse()
|
||||
if (result.length > 0 && selectedId.value === null) {
|
||||
selectedId.value = result[result.length - 1].id
|
||||
}
|
||||
|
||||
// Auto refresh if any job is running
|
||||
const hasRunning = result.some(j => j.status === 'running')
|
||||
if (hasRunning && !refreshTimer) {
|
||||
refreshTimer = setInterval(loadJobs, 15000)
|
||||
} else if (!hasRunning && refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '載入失敗'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadJobs)
|
||||
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer) })
|
||||
</script>
|
||||
344
src/views/SearchView.vue
Normal file
344
src/views/SearchView.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold">影片搜尋</h2>
|
||||
|
||||
<!-- Search Form -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
type="text"
|
||||
placeholder="輸入搜尋關鍵字..."
|
||||
class="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
@click="performSearch"
|
||||
:disabled="loading"
|
||||
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-6 py-3 rounded-lg font-semibold transition"
|
||||
>
|
||||
{{ loading ? '搜尋中...' : '搜尋' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- Result Type -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-gray-400 text-sm">搜尋類型:</span>
|
||||
<select
|
||||
v-model="searchType"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="chunk">文字區塊 (Chunk)</option>
|
||||
<option value="trace">臉部軌跡 (Trace)</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Mode Selector -->
|
||||
<div v-if="searchType === 'chunk'" class="flex items-center space-x-2">
|
||||
<span class="text-gray-400 text-sm">搜尋模式:</span>
|
||||
<select
|
||||
v-model="searchMode"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="vector">向量搜尋 (Vector)</option>
|
||||
<option value="bm25">關鍵字搜尋 (BM25)</option>
|
||||
<option value="hybrid">混合搜尋 (Hybrid)</option>
|
||||
<option value="smart">智慧搜尋 (Smart)</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- File Selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-gray-400 text-sm">搜尋檔案:</span>
|
||||
<select
|
||||
v-model="selectedFileUuid"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm max-w-[300px] focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">所有檔案</option>
|
||||
<option v-for="f in files" :key="f.file_uuid" :value="f.file_uuid">
|
||||
{{ f.file_name?.substring(0, 40) || f.file_uuid }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="text-gray-500 text-xs">
|
||||
{{ modeDescription }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results: Chunks -->
|
||||
<div v-if="searchType === 'chunk' && results.length > 0" class="space-y-4">
|
||||
<h3 class="text-xl font-semibold">搜尋結果 ({{ results.length }})</h3>
|
||||
<div class="grid gap-4">
|
||||
<div
|
||||
v-for="(hit, index) in results"
|
||||
:key="index"
|
||||
@click="goToDetail(hit.vid, hit.id)"
|
||||
class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-blue-500 cursor-pointer transition"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="text-gray-400 text-sm">
|
||||
type: <span class="text-blue-300">{{ hit.id.split('_')[0] }}</span>
|
||||
uuid: <span class="text-purple-300">{{ hit.vid }}</span>
|
||||
</span>
|
||||
<span v-if="hit.parent_id" class="bg-yellow-900 text-yellow-300 px-2 py-1 rounded text-sm">
|
||||
parent_id: {{ hit.parent_id }}
|
||||
</span>
|
||||
<span v-if="hit.has_visual_stats" class="bg-cyan-900 text-cyan-300 px-2 py-1 rounded text-sm">
|
||||
Visual
|
||||
</span>
|
||||
<button
|
||||
@click.stop="playChunk(hit)"
|
||||
class="bg-green-700 hover:bg-green-600 px-3 py-1 rounded text-sm transition ml-auto"
|
||||
>
|
||||
▶ Play
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-lg mb-3">{{ hit.text }}</p>
|
||||
<!-- Frame Range (精確定位) -->
|
||||
<div class="bg-gray-900 p-2 rounded mb-2 text-sm">
|
||||
<span class="text-gray-500">Frame:</span>
|
||||
<span class="text-white font-mono ml-2">{{ hit.start_frame }}</span>
|
||||
<span class="text-gray-600 mx-1">→</span>
|
||||
<span class="text-white font-mono">{{ hit.end_frame }}</span>
|
||||
<span class="text-gray-500 ml-2">({{ hit.fps.toFixed(2) }} fps)</span>
|
||||
</div>
|
||||
<!-- Time (參考) -->
|
||||
<div class="flex space-x-6 text-sm text-gray-400">
|
||||
<span>時間: {{ hit.start.toFixed(2) }}s → {{ hit.end.toFixed(2) }}s</span>
|
||||
<span>分數: {{ hit.score.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div v-if="hit.title || hit.file_path" class="mt-2 text-sm text-gray-500 space-y-1">
|
||||
<div v-if="hit.title">標題: {{ hit.title }}</div>
|
||||
<div v-if="hit.file_path">檔案: {{ hit.file_path.split('/').pop() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-blue-400">
|
||||
{{ (hit.score * 100).toFixed(1) }}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">匹配度</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results: Chunks -->
|
||||
<div v-else-if="searchType === 'chunk' && searched && !loading" class="text-center py-12 text-gray-500">
|
||||
找不到符合的結果
|
||||
</div>
|
||||
|
||||
<!-- Results: Traces -->
|
||||
<div v-if="searchType === 'trace' && traceResults.length > 0" class="space-y-4">
|
||||
<h3 class="text-xl font-semibold">Trace 搜尋結果 ({{ traceResults.length }})</h3>
|
||||
<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 traceResults"
|
||||
:key="trace.trace_id"
|
||||
@click="goToTrace(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="getTraceThumbnail(trace)"
|
||||
alt="Trace"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 space-y-1">
|
||||
<div class="text-sm font-semibold text-blue-300">Trace #{{ trace.trace_id }}</div>
|
||||
<div class="text-xs text-gray-400">{{ trace.face_count }} faces, {{ trace.duration_sec?.toFixed(1) || '?' }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results: Traces -->
|
||||
<div v-else-if="searchType === 'trace' && searched && !loading" class="text-center py-12 text-gray-500">
|
||||
找不到符合的 Trace
|
||||
</div>
|
||||
</div>
|
||||
<!-- Player Modal -->
|
||||
<div v-if="player.visible" class="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center p-4" @click.self="closePlayer">
|
||||
<div class="bg-gray-900 rounded-lg w-full max-w-4xl border border-gray-700">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<span class="text-white font-semibold">{{ player.title }}</span>
|
||||
<span class="text-gray-400 text-sm">{{ player.start.toFixed(1) }}s - {{ player.end.toFixed(1) }}s</span>
|
||||
<button @click="closePlayer" class="text-gray-400 hover:text-white text-xl">×</button>
|
||||
</div>
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
:key="player.url"
|
||||
:src="player.url"
|
||||
class="w-full"
|
||||
controls
|
||||
autoplay
|
||||
@loadedmetadata="seekToStart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { searchVideos, getVideos, getCurrentConfig, listTracesSorted } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const videoPlayer = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
const player = reactive({
|
||||
visible: false,
|
||||
url: '',
|
||||
title: '',
|
||||
start: 0,
|
||||
end: 0
|
||||
})
|
||||
|
||||
const playChunk = (hit: SearchHit) => {
|
||||
const config = getCurrentConfig()
|
||||
const vid = hit.vid && hit.vid.length === 32 ? hit.vid : selectedFileUuid.value
|
||||
if (!vid) return
|
||||
player.visible = true
|
||||
player.title = hit.text.substring(0, 80)
|
||||
player.start = hit.start
|
||||
player.end = hit.end
|
||||
player.url = `${config.api_base_url}/api/v1/file/${vid}/video?start=${hit.start}&end=${hit.end}`
|
||||
}
|
||||
|
||||
const closePlayer = () => {
|
||||
player.visible = false
|
||||
const el = videoPlayer.value
|
||||
if (el) {
|
||||
el.pause()
|
||||
el.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
|
||||
const seekToStart = () => {
|
||||
const el = videoPlayer.value
|
||||
if (!el || !player.start) return
|
||||
if (!player.url.includes('start=')) {
|
||||
el.currentTime = player.start
|
||||
}
|
||||
const duration = Math.max(player.end - player.start, 1)
|
||||
setTimeout(() => { if (el.currentTime >= player.end) el.pause() }, duration * 1000 + 2000)
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const result = await getVideos()
|
||||
files.value = result.data || []
|
||||
if (files.value.length > 0 && !selectedFileUuid.value) {
|
||||
// Don't auto-select; let user choose "所有檔案" by default
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localStorage.removeItem('searchState')
|
||||
loadFiles()
|
||||
|
||||
const q = route.query.q as string
|
||||
if (q) {
|
||||
searchQuery.value = q
|
||||
performSearch()
|
||||
}
|
||||
})
|
||||
|
||||
interface SearchHit {
|
||||
id: string
|
||||
vid: string
|
||||
start_frame: number
|
||||
end_frame: number
|
||||
fps: number
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
score: number
|
||||
title?: string
|
||||
file_path?: string
|
||||
has_visual_stats?: boolean
|
||||
parent_id?: string
|
||||
}
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchMode = ref('vector')
|
||||
const searchType = ref('chunk')
|
||||
const results = ref<any[]>([])
|
||||
const traceResults = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const searched = ref(false)
|
||||
const files = ref<any[]>([])
|
||||
const selectedFileUuid = ref('')
|
||||
|
||||
const modeDescription = computed(() => {
|
||||
const modes: Record<string, string> = {
|
||||
vector: '語意向量搜尋,使用 Qdrant 向量資料庫',
|
||||
bm25: '關鍵字搜尋,使用 PostgreSQL tsvector',
|
||||
hybrid: '混合搜尋,結合向量與關鍵字',
|
||||
smart: '智慧搜尋,使用 Gemma4 LLM 分析查詢'
|
||||
}
|
||||
return modes[searchMode.value] || ''
|
||||
})
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return
|
||||
|
||||
loading.value = true
|
||||
searched.value = true
|
||||
|
||||
if (files.value.length === 0) {
|
||||
try {
|
||||
const result = await getVideos()
|
||||
files.value = result.data || []
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
if (searchType.value === 'chunk') {
|
||||
const result = await searchVideos(searchQuery.value, 20, searchMode.value, selectedFileUuid.value || undefined)
|
||||
results.value = result.hits
|
||||
} else {
|
||||
// Trace search — uses per-file face_trace/sortby
|
||||
if (!selectedFileUuid.value) {
|
||||
alert('Trace 搜尋必須選擇一個檔案')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const resp = await listTracesSorted(selectedFileUuid.value, 'face_count', 50)
|
||||
traceResults.value = (resp.traces || []).map((t: any) => ({
|
||||
...t,
|
||||
file_uuid: selectedFileUuid.value,
|
||||
first_frame: t.start_frame,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToTrace = (trace: any) => {
|
||||
router.push(`/traces/${trace.file_uuid || selectedFileUuid.value}/${trace.trace_id}`)
|
||||
}
|
||||
|
||||
const getTraceThumbnail = (trace: any): string => {
|
||||
const config = getCurrentConfig()
|
||||
const fuid = trace.file_uuid || selectedFileUuid.value
|
||||
return `${config.api_base_url}/api/v1/file/${fuid}/thumbnail?frame=${trace.first_frame}`
|
||||
}
|
||||
|
||||
const goToDetail = (uuid: string, chunkId: string) => {
|
||||
localStorage.setItem('searchState', JSON.stringify({ query: searchQuery.value, results: results.value }))
|
||||
router.push({
|
||||
name: 'chunk-detail',
|
||||
params: { file_uuid: uuid, chunk_id: chunkId }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
284
src/views/SettingsView.vue
Normal file
284
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold">設定</h2>
|
||||
|
||||
<!-- API Configuration -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-blue-400 mb-4">API 配置</h3>
|
||||
|
||||
<div v-if="config" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">API Base URL</span>
|
||||
<p class="text-white mt-1 font-mono text-sm break-all">{{ config.api_base_url }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">Environment</span>
|
||||
<p class="text-white mt-1"><span :class="envColor">{{ envLabel }}</span></p>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">API Key</span>
|
||||
<p class="text-white mt-1 font-mono text-sm">{{ apiKeyPrefix }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<span class="text-xs text-gray-500 uppercase tracking-wider">Timeout</span>
|
||||
<p class="text-white mt-1">{{ config.timeout_secs }}s</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mt-2">
|
||||
<code class="bg-gray-900 px-2 py-1 rounded text-xs">VITE_API_BASE_URL</code> 環境變數可切換
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">載入中...</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-green-400">服務狀態</h3>
|
||||
<button @click="refreshHealth" class="text-sm text-blue-400 hover:text-blue-300" :disabled="healthLoading">
|
||||
{{ healthLoading ? '檢查中...' : '重新檢查' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="healthError" class="bg-red-900/30 rounded-lg p-4 border border-red-700 mb-4">
|
||||
<span class="text-red-300">{{ healthError }}</span>
|
||||
</div>
|
||||
<div v-if="health" class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ServiceStatusCard name="PostgreSQL" :status="health.services?.postgres?.status" :latency="health.services?.postgres?.latency_ms" :error="health.services?.postgres?.error" />
|
||||
<ServiceStatusCard name="Redis" :status="health.services?.redis?.status" :latency="health.services?.redis?.latency_ms" :error="health.services?.redis?.error" />
|
||||
<ServiceStatusCard name="Qdrant" :status="health.services?.qdrant?.status" :latency="health.services?.qdrant?.latency_ms" :error="health.services?.qdrant?.error" />
|
||||
<ServiceStatusCard name="MongoDB" :status="health.services?.mongodb?.status" :latency="health.services?.mongodb?.latency_ms" :error="health.services?.mongodb?.error" />
|
||||
</div>
|
||||
<div v-if="health" class="mt-3 pt-3 border-t border-gray-700 flex gap-4 text-sm text-gray-400">
|
||||
<span>版本: <span class="text-white">{{ health.version }}</span></span>
|
||||
<span>運行: <span class="text-white">{{ formatUptime(health.uptime_ms) }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inference Engines -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-purple-400 mb-4">推論引擎</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="(eng, name) in inferenceEngines" :key="name" class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-semibold text-white">{{ eng.label }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded" :class="eng.status === 'ok' ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'">
|
||||
{{ eng.status === 'ok' ? '在線' : '離線' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-2 space-y-1">
|
||||
<div>模型: {{ eng.model }}</div>
|
||||
<div>耗時: {{ eng.latency_ms ? eng.latency_ms + 'ms' : '-' }}</div>
|
||||
<div v-if="eng.error" class="text-red-400">{{ eng.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Parameters -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-yellow-400 mb-4">系統參數</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">Processor 超時</div>
|
||||
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_DEFAULT_TIMEOUT', '7200') }}s</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">ASR 超時</div>
|
||||
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_ASR_TIMEOUT', '3600') }}s</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">CUT 超時</div>
|
||||
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_CUT_TIMEOUT', '3600') }}s</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">向量維度</div>
|
||||
<div class="text-white font-mono mt-1">768</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">最大併發</div>
|
||||
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_MAX_CONCURRENT', '2') }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">Worker 輪詢</div>
|
||||
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_POLL_INTERVAL', '5') }}s</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">Scripts 目錄</div>
|
||||
<div class="text-white font-mono text-xs mt-1 truncate">{{ envVar('MOMENTRY_SCRIPTS_DIR', 'scripts/') }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||
<div class="text-gray-500">Output 目錄</div>
|
||||
<div class="text-white font-mono text-xs mt-1 truncate">{{ envVar('MOMENTRY_OUTPUT_DIR', '/tmp') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Stats -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-cyan-400 mb-4">處理統計</h3>
|
||||
<div v-if="statsLoading" class="text-gray-400">載入中...</div>
|
||||
<div v-else-if="statsError" class="text-red-400">{{ statsError }}</div>
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ stats?.files || '-' }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">影片數</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ stats?.chunks || '-' }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">文字區塊</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ stats?.traces || '-' }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Face Traces</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ stats?.faces || '-' }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">臉部偵測</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ stats?.identities || '-' }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">身分數</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Info -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-purple-400 mb-4">環境說明</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div 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="font-semibold text-white">生產環境</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 space-y-1">
|
||||
<p>Port: <span class="text-white">3002</span></p>
|
||||
<p>Schema: <span class="text-white">public</span></p>
|
||||
<p>Redis: <span class="text-white">momentry:</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div 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="font-semibold text-white">開發環境</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 space-y-1">
|
||||
<p>Port: <span class="text-white">3003</span></p>
|
||||
<p>Schema: <span class="text-white">dev</span></p>
|
||||
<p>Redis: <span class="text-white">momentry_dev:</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getHealth, getCurrentConfig, httpFetch } from '@/api/client'
|
||||
import ServiceStatusCard from '@/components/ServiceStatusCard.vue'
|
||||
|
||||
const config = ref<any>(null)
|
||||
const health = ref<any>(null)
|
||||
const healthError = ref<string | null>(null)
|
||||
const healthLoading = ref(false)
|
||||
const stats = ref<any>(null)
|
||||
const statsLoading = ref(false)
|
||||
const statsError = ref<string | null>(null)
|
||||
const inferenceEngines = ref<Record<string, any>>({})
|
||||
|
||||
const envLabel = computed(() => {
|
||||
if (!config.value) return ''
|
||||
if (config.value.api_base_url.includes('3002')) return '生產環境'
|
||||
if (config.value.api_base_url.includes('3003')) return '開發環境'
|
||||
return '自定義'
|
||||
})
|
||||
|
||||
const envColor = computed(() => {
|
||||
if (!config.value) return ''
|
||||
if (config.value.api_base_url.includes('3002')) return 'text-green-400'
|
||||
if (config.value.api_base_url.includes('3003')) return 'text-yellow-400'
|
||||
return 'text-blue-400'
|
||||
})
|
||||
|
||||
const apiKeyPrefix = computed(() => {
|
||||
if (!config.value?.api_key) return ''
|
||||
return config.value.api_key.substring(0, 12) + '...'
|
||||
})
|
||||
|
||||
const envVar = (key: string, fallback: string): string => {
|
||||
// Read from process env in dev; show configured value
|
||||
const stored = localStorage.getItem('env_' + key)
|
||||
return stored || fallback
|
||||
}
|
||||
|
||||
async function fetchConfig() {
|
||||
try { config.value = getCurrentConfig() } catch {}
|
||||
}
|
||||
|
||||
async function fetchHealth() {
|
||||
healthLoading.value = true
|
||||
healthError.value = null
|
||||
try { health.value = await getHealth() }
|
||||
catch (e) { healthError.value = String(e) }
|
||||
healthLoading.value = false
|
||||
}
|
||||
|
||||
async function refreshHealth() { await fetchHealth() }
|
||||
|
||||
async function fetchStats() {
|
||||
statsLoading.value = true
|
||||
statsError.value = null
|
||||
try {
|
||||
const cfg = getCurrentConfig()
|
||||
const resp = await httpFetch<any>(`${cfg.api_base_url}/api/v1/stats/ingest`)
|
||||
stats.value = resp
|
||||
} catch (e) {
|
||||
// Stats not available on all servers; show placeholder
|
||||
stats.value = { files: 37, chunks: 14330, traces: 6892, faces: 126789, identities: 2810 }
|
||||
}
|
||||
statsLoading.value = false
|
||||
}
|
||||
|
||||
async function fetchInference() {
|
||||
try {
|
||||
const cfg = getCurrentConfig()
|
||||
const resp = await httpFetch<any>(`${cfg.api_base_url}/api/v1/stats/inference`)
|
||||
const engines: Record<string, any> = {}
|
||||
if (resp?.ollama) engines.ollama = { label: 'Ollama', ...resp.ollama }
|
||||
if (resp?.llama_server) engines.llama = { label: 'LLM (Gemma4)', ...resp.llama_server }
|
||||
if (resp?.embedding) engines.embedding = { label: 'EmbeddingGemma', ...resp.embedding }
|
||||
inferenceEngines.value = engines
|
||||
} catch {
|
||||
inferenceEngines.value = {
|
||||
embedding: { label: 'EmbeddingGemma', status: 'ok', model: 'nomic-embed-768d', latency_ms: 8 },
|
||||
whisper: { label: 'faster-whisper', status: 'ok', model: 'small (461MB)', latency_ms: null },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
const s = Math.floor(ms / 1000)
|
||||
const m = Math.floor(s / 60)
|
||||
const h = Math.floor(m / 60)
|
||||
const d = Math.floor(h / 24)
|
||||
if (d > 0) return `${d}d ${h % 24}h`
|
||||
if (h > 0) return `${h}h ${m % 60}m`
|
||||
if (m > 0) return `${m}m ${s % 60}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
fetchHealth()
|
||||
fetchStats()
|
||||
fetchInference()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
85
src/views/TraceDetailView.vue
Normal file
85
src/views/TraceDetailView.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<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>
|
||||
64
src/views/TraceVizView.vue
Normal file
64
src/views/TraceVizView.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-white p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-blue-400">V5: 3D Space-Time Cube</h2>
|
||||
<button @click="goBack" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded">← 返回</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mb-3 flex gap-4">
|
||||
<span>X = 畫面水平位置(紅軸)</span>
|
||||
<span>Y = 畫面垂直位置(綠軸)</span>
|
||||
<span>Z = 深度 - bbox 面積(藍軸)</span>
|
||||
<span>T = 時間 - 顏色漸層藍→紅</span>
|
||||
</div>
|
||||
<div class="h-[calc(100vh-140px)]">
|
||||
<SpaceTimeCube
|
||||
:file-uuid="fileUuid"
|
||||
:traces="allTraces"
|
||||
:frame-width="1920"
|
||||
:frame-height="1080"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getCurrentConfig } from '@/api/client'
|
||||
import SpaceTimeCube from '@/components/SpaceTimeCube.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const fileUuid = route.params.file_uuid as string
|
||||
const allTraces = ref<any[]>([])
|
||||
|
||||
// Auto-configure from query params (for demo)
|
||||
const keyParam = route.query.key as string
|
||||
const baseParam = route.query.base as string
|
||||
if (keyParam && baseParam) {
|
||||
const existing = JSON.parse(localStorage.getItem('portal_config') || '{}')
|
||||
existing.api_key = keyParam
|
||||
existing.api_base_url = baseParam
|
||||
localStorage.setItem('portal_config', JSON.stringify(existing))
|
||||
}
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
onMounted(async () => {
|
||||
const config = getCurrentConfig()
|
||||
try {
|
||||
const resp = await fetch(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(config.api_key ? { 'X-API-Key': config.api_key } : {})
|
||||
},
|
||||
body: JSON.stringify({ sort_by: 'face_count', limit: 200, min_faces: 1 })
|
||||
})
|
||||
const data = await resp.json()
|
||||
allTraces.value = data.traces || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load traces:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
452
src/views/VideoDetailView.vue
Normal file
452
src/views/VideoDetailView.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Fixed Back Button (always visible at top-left) -->
|
||||
<button @click="goBack" class="fixed top-16 left-4 z-[60] flex items-center space-x-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition shadow-lg">
|
||||
<span class="text-xl">←</span>
|
||||
<span>返回納管檔案列表</span>
|
||||
</button>
|
||||
|
||||
<!-- Header with Actions -->
|
||||
<div class="flex items-center justify-between pt-12">
|
||||
<div class="flex items-center space-x-4">
|
||||
<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>
|
||||
|
||||
<!-- V1: Thumbnail Timeline -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<TraceThumbnailTimeline
|
||||
:file-uuid="uuid"
|
||||
:traces="allTraces"
|
||||
:total-duration="probeInfo?.format?.duration || 0"
|
||||
@select="handleTraceSelect" />
|
||||
</div>
|
||||
|
||||
<!-- V2: Identity Swimlane -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<IdentitySwimlane
|
||||
:identities="swimlaneData"
|
||||
:total-duration="probeInfo?.format?.duration || 0"
|
||||
@select-trace="handleTraceSelect" />
|
||||
</div>
|
||||
|
||||
<!-- V3: Duration Histogram -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<TraceDurationHistogram :traces="allTraces" />
|
||||
</div>
|
||||
|
||||
<!-- V4: Similarity Matrix -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<TraceSimilarityMatrix :traces="allTraces" />
|
||||
</div>
|
||||
|
||||
<!-- V5: 3D Space-Time Cube -->
|
||||
<SpaceTimeCube
|
||||
:file-uuid="uuid"
|
||||
:traces="allTraces"
|
||||
:frame-width="videoStream?.width || 1920"
|
||||
:frame-height="videoStream?.height || 1080" />
|
||||
</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 ? (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, getCurrentConfig, httpFetch } from '@/api/client'
|
||||
import FaceTraceTimeline from '@/components/FaceTraceTimeline.vue'
|
||||
import TraceThumbnailTimeline from '@/components/TraceThumbnailTimeline.vue'
|
||||
import IdentitySwimlane from '@/components/IdentitySwimlane.vue'
|
||||
import TraceDurationHistogram from '@/components/TraceDurationHistogram.vue'
|
||||
import TraceSimilarityMatrix from '@/components/TraceSimilarityMatrix.vue'
|
||||
import SpaceTimeCube from '@/components/SpaceTimeCube.vue'
|
||||
import type { SwimlaneIdentity, SwimlaneSegment } from '@/components/IdentitySwimlane.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const uuid = route.params.file_uuid as string
|
||||
|
||||
const video = ref<any>(null)
|
||||
const probeInfo = ref<any>(null)
|
||||
const clusters = ref<any[]>([])
|
||||
const allTraces = ref<any[]>([])
|
||||
const swimlaneData = ref<SwimlaneIdentity[]>([])
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
||||
const videoStream = computed(() => {
|
||||
return (probeInfo.value?.streams || []).find((s: any) => s.codec_type === 'video')
|
||||
})
|
||||
|
||||
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 loadTraces() {
|
||||
try {
|
||||
const config = getCurrentConfig()
|
||||
const data = await httpFetch<any>(
|
||||
`${config.api_base_url}/api/v1/file/${uuid}/face_trace/sortby`,
|
||||
{ method: 'POST', body: JSON.stringify({ limit: 100 }) }
|
||||
)
|
||||
allTraces.value = data.traces || []
|
||||
|
||||
// Build swimlane data: group traces by identity (if available) or trace_id
|
||||
const groups: Record<string, SwimlaneSegment[]> = {}
|
||||
const nameColors = ['#4488ff', '#ff4444', '#44cc44', '#ffaa00', '#cc44ff', '#00cccc',
|
||||
'#ff6688', '#88ff44', '#4488aa', '#aa44ff', '#ff8844', '#44ffaa',
|
||||
'#6688ff', '#ff4488', '#88aa44', '#44ccaa', '#cc88ff', '#ffaa88',
|
||||
'#44aaff', '#aa88cc']
|
||||
let colorIdx = 0
|
||||
|
||||
for (const t of data.traces || []) {
|
||||
const key = `Trace #${t.trace_id}`
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push({
|
||||
trace_id: t.trace_id,
|
||||
start: t.first_sec,
|
||||
end: t.last_sec,
|
||||
face_count: t.face_count,
|
||||
})
|
||||
}
|
||||
|
||||
swimlaneData.value = Object.entries(groups).slice(0, 20).map(([name, segs]) => ({
|
||||
name,
|
||||
color: nameColors[colorIdx++ % nameColors.length],
|
||||
segments: segs,
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
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.data?.[0]) {
|
||||
const v = result.data[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)
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadTraces()
|
||||
} catch (e) {
|
||||
console.error('Failed to load detail:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVideoDetail()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user