feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system

This commit is contained in:
Accusys
2026-06-02 07:13:23 +08:00
parent e3066c3f49
commit e1572907ae
198 changed files with 43705 additions and 8910 deletions
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
const mockPush = vi.fn()
const mockReplace = vi.fn()
const mockUseRoute = vi.fn(() => ({
query: { username: '', password: '' },
path: '/login',
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useRoute: mockUseRoute,
}))
const mockHttpFetch = vi.fn()
vi.mock('@/api/client', () => ({
httpFetch: mockHttpFetch,
getCurrentConfig: () => ({
api_base_url: 'http://localhost:3003',
api_key: '',
timeout_secs: 30,
}),
saveConfig: vi.fn(),
}))
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockUseRoute.mockReturnValue({
query: { username: '', password: '' },
path: '/login',
})
})
async function createWrapper() {
const { default: LoginView } = await import('./LoginView.vue')
return mount(LoginView, {
attachTo: document.body,
})
}
describe('LoginView', () => {
it('renders login form', async () => {
const wrapper = await createWrapper()
expect(wrapper.find('h1').text()).toBe('Momentry')
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Login')
})
it('updates username and password on input', async () => {
const wrapper = await createWrapper()
const usernameInput = wrapper.find('input[type="text"]')
const passwordInput = wrapper.find('input[type="password"]')
await usernameInput.setValue('demo')
await passwordInput.setValue('secret')
expect((usernameInput.element as HTMLInputElement).value).toBe('demo')
expect((passwordInput.element as HTMLInputElement).value).toBe('secret')
})
it('toggles password visibility', async () => {
const wrapper = await createWrapper()
const toggleBtn = wrapper.find('button[type="button"]')
const passwordInput = wrapper.find('input[type="password"]')
expect(passwordInput.attributes('type')).toBe('password')
await toggleBtn.trigger('click')
expect(passwordInput.attributes('type')).toBe('text')
await toggleBtn.trigger('click')
expect(passwordInput.attributes('type')).toBe('password')
})
it('shows loading state on submit', async () => {
mockHttpFetch.mockImplementation(() => new Promise(() => {}))
const wrapper = await createWrapper()
await wrapper.find('input[type="text"]').setValue('demo')
await wrapper.find('input[type="password"]').setValue('demo')
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
expect(wrapper.find('button[type="submit"]').text()).toBe('Logging in...')
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined()
})
it('shows error on login failure with message', async () => {
mockHttpFetch.mockResolvedValue({ success: false, message: 'Account disabled' })
const wrapper = await createWrapper()
await wrapper.find('input[type="text"]').setValue('demo')
await wrapper.find('input[type="password"]').setValue('demo')
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
expect(wrapper.text()).toContain('Account disabled')
})
it('shows generic error for 401', async () => {
mockHttpFetch.mockRejectedValue(new Error('401 Unauthorized'))
const wrapper = await createWrapper()
await wrapper.find('input[type="text"]').setValue('bad')
await wrapper.find('input[type="password"]').setValue('creds')
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
expect(wrapper.text()).toContain('Invalid username or password')
})
it('shows connection error on network failure', async () => {
mockHttpFetch.mockRejectedValue(new Error('NetworkError'))
const wrapper = await createWrapper()
await wrapper.find('input[type="text"]').setValue('demo')
await wrapper.find('input[type="password"]').setValue('demo')
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
expect(wrapper.text()).toContain('Connection error')
})
it('redirects on successful login', async () => {
mockHttpFetch.mockResolvedValue({
success: true,
api_key: 'test_key_123',
user: { name: 'demo', role: 'admin' },
})
const wrapper = await createWrapper()
await wrapper.find('input[type="text"]').setValue('admin')
await wrapper.find('input[type="password"]').setValue('admin')
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
expect(localStorage.getItem('momentry_user')).toBe(
JSON.stringify({ name: 'demo', role: 'admin' }),
)
expect(localStorage.getItem('momentry_api_key')).toBe('test_key_123')
expect(mockPush).toHaveBeenCalledWith('/home')
})
it('redirects to original redirect path after login', async () => {
mockUseRoute.mockReturnValue({
query: { redirect: '/search?q=test' },
path: '/login',
})
mockHttpFetch.mockResolvedValue({
success: true,
api_key: 'key',
user: { name: 'demo' },
})
const wrapper = await createWrapper()
await wrapper.find('input[type="text"]').setValue('demo')
await wrapper.find('input[type="password"]').setValue('demo')
await wrapper.find('form').trigger('submit.prevent')
await nextTick()
expect(mockPush).toHaveBeenCalledWith('/search?q=test')
})
it('auto-submits when query params are present', async () => {
mockUseRoute.mockReturnValue({
query: { username: 'auto', password: 'login' },
path: '/login',
})
mockHttpFetch.mockResolvedValue({
success: true,
api_key: 'auto_key',
user: { name: 'auto_user' },
})
await createWrapper()
await nextTick()
await nextTick()
expect(mockHttpFetch).toHaveBeenCalled()
expect(localStorage.getItem('momentry_api_key')).toBe('auto_key')
})
})
+11
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>
+370
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>
+85
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>
+64
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>