Initial commit: Momentry Portal v0.1.0

This commit is contained in:
Warren
2026-05-20 08:29:37 +08:00
commit 0da7dd17af
62 changed files with 16788 additions and 0 deletions

View 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">此片段尚無視覺分析數據 (YOLOPoseFaceOCR)</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>

View 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
View 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>

View 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
View 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">&times;</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>

View 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 Mesh468 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
View 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>

View 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
View 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>

View 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
View 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">&times;</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
View 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>

View 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>

View 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">&larr; 返回</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 = 時間 - 顏色漸層藍&rarr;</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>

View 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>