feat: add migrations, test scripts, and utility tools

- Add database migrations (006-028) for face recognition, identity, file_uuid
- Add test scripts for ASR, face, search, processing
- Add portal frontend (Tauri)
- Add config, benchmark, and monitoring utilities
- Add model checkpoints and pretrained model references
This commit is contained in:
Warren
2026-04-30 15:11:53 +08:00
parent 4d75b2e251
commit b54c2def30
192 changed files with 46721 additions and 0 deletions

View File

@@ -0,0 +1,344 @@
<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 class="text-center py-12 text-red-400">
無法載入詳情
</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'
const API_BASE = 'http://localhost:3003'
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
const route = useRoute()
const chunkId = ref('')
const detail = ref<any>(null)
const loading = ref(false)
const loadDetail = async () => {
const uuid = route.params.uuid as string
chunkId.value = route.params.chunkId as string
loading.value = true
console.log('=== loadDetail START ===')
console.log('uuid:', uuid, 'chunkId:', chunkId.value)
try {
const url = `${API_BASE}/api/v1/videos/${uuid}/details?chunk_id=${chunkId.value}`
console.log('Fetching URL:', url)
const res = await fetch(url, {
headers: { 'X-API-Key': API_KEY }
})
console.log('Response status:', res.status, res.statusText)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
const result = await res.json()
console.log('Result keys:', Object.keys(result))
detail.value = result
console.log('detail.value set')
} catch (error) {
console.error('ERROR:', error)
alert('載入失敗: ' + error)
} finally {
loading.value = false
console.log('=== loadDetail END ===')
}
}
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 }
})
} catch {
router.back()
}
} else {
router.back()
}
}
onMounted(() => {
loadDetail()
})
</script>

View File

@@ -0,0 +1,195 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">Face Candidates</h2>
<button
@click="loadCandidates"
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-2 gap-4">
<div>
<label class="text-gray-400 text-sm mb-1">Min Confidence</label>
<input
v-model.number="minConfidence"
@change="loadCandidates"
type="number"
step="0.1"
min="0"
max="1"
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">Page Size</label>
<input
v-model.number="pageSize"
@change="loadCandidates"
type="number"
min="1"
max="100"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
/>
</div>
</div>
</div>
<div v-if="loading" class="text-center py-12 text-gray-500">
Loading...
</div>
<div v-else-if="candidates.length > 0">
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-4">
<div class="text-gray-400">
Showing {{ candidates.length }} of {{ total }} candidates
<span v-if="selectedFaces.length > 0" class="ml-4 text-green-400">
{{ selectedFaces.length }} selected
</span>
</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="face in candidates"
:key="face.id"
@click="toggleSelection(face)"
:class="[
'bg-gray-800 rounded-lg border overflow-hidden cursor-pointer transition',
selectedFaces.includes(face.id) ? 'border-green-500 bg-green-900/20' : 'border-gray-700 hover:border-gray-500'
]"
>
<div class="aspect-square bg-gray-700 flex items-center justify-center overflow-hidden">
<img
:src="getThumbnailUrl(face.id)"
alt="Face thumbnail"
class="w-full h-full object-cover"
loading="lazy"
@error="onThumbnailError"
/>
</div>
<div class="p-3">
<div class="flex justify-between items-center mb-1">
<span class="text-xs text-gray-400">Conf:</span>
<span class="text-sm font-mono" :class="getConfidenceColor(face.confidence)">
{{ face.confidence.toFixed(2) }}
</span>
</div>
<div v-if="face.attributes" class="text-xs text-gray-500">
<div v-if="face.attributes.gender">{{ face.attributes.gender }}</div>
<div v-if="face.attributes.age">Age: {{ face.attributes.age }}</div>
</div>
</div>
</div>
</div>
<div v-if="total > pageSize" class="flex justify-center mt-6 space-x-2">
<button
v-if="page > 1"
@click="prevPage"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded"
>
Previous
</button>
<span class="text-gray-400 py-2">
Page {{ page }} of {{ Math.ceil(total / pageSize) }}
</span>
<button
v-if="page * pageSize < total"
@click="nextPage"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded"
>
Next
</button>
</div>
</div>
<div v-else class="text-center py-12 text-gray-500">
No candidates found
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listFaceCandidates, getCurrentConfig } from '@/api/client'
interface FaceCandidate {
id: number
face_id: string | null
file_uuid: string
frame_number: number
confidence: number
bbox: any
attributes: any
}
const candidates = ref<FaceCandidate[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const minConfidence = ref(0.8)
const selectedFaces = ref<number[]>([])
const getThumbnailUrl = (faceId: number): string => {
const config = getCurrentConfig()
return `${config.api_base_url}/api/v1/faces/${faceId}/thumbnail`
}
const onThumbnailError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
const parent = img.parentElement
if (parent) {
parent.innerHTML = '<div class="text-center p-4"><div class="text-2xl">👤</div></div>'
}
}
const loadCandidates = async () => {
loading.value = true
try {
const result = await listFaceCandidates(undefined, minConfidence.value, page.value, pageSize.value)
candidates.value = result.candidates || []
total.value = result.total || 0
} catch (error) {
console.error('Failed to load candidates:', error)
alert('Load failed: ' + error)
} finally {
loading.value = false
}
}
const toggleSelection = (face: FaceCandidate) => {
const idx = selectedFaces.value.indexOf(face.id)
if (idx >= 0) {
selectedFaces.value.splice(idx, 1)
} else {
selectedFaces.value.push(face.id)
}
}
const nextPage = () => {
page.value++
loadCandidates()
}
const prevPage = () => {
page.value--
loadCandidates()
}
const getConfidenceColor = (conf: number): string => {
if (conf >= 0.9) return 'text-green-400'
if (conf >= 0.8) return 'text-blue-400'
if (conf >= 0.7) return 'text-yellow-400'
return 'text-gray-400'
}
onMounted(() => {
loadCandidates()
})
</script>

View File

@@ -0,0 +1,255 @@
<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('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('registered')"
:class="{'bg-blue-600 text-white': statusFilter === 'registered', 'text-gray-300 hover:text-white': statusFilter !== 'registered'}"
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>
</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 -->
<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>
<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-right 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_path" :class="!file.file_name ? 'opacity-0' : '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_path">
{{ file.file_name }}
</div>
<div class="text-xs text-gray-500 truncate max-w-xs">{{ file.relative_path }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span v-if="file.is_registered" class="px-2 py-0.5 rounded text-xs bg-green-900 text-green-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-sm text-gray-300">
{{ formatFileSize(file.file_size) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{{ formatTimestamp(file.modified_time) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex justify-end gap-2">
<!-- Detail Button (Registered only) -->
<button
v-if="file.is_registered"
@click="viewDetail(file.file_uuid)"
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition"
>
詳情
</button>
<!-- Register Button (Unregistered only) -->
<button
v-if="!file.is_registered"
@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 Button (Registered only) -->
<button
v-if="file.is_registered"
@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>
<!-- Stats -->
<div class="flex justify-between text-sm text-gray-400">
<span> {{ totalCount }} 個檔案</span>
<span>已註冊: {{ registeredCount }} | 未註冊: {{ unregisteredCount }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { registerVideo, unregisterVideo } from '@/api/client'
import { getCurrentConfig, httpFetch } 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, registered, unregistered
const totalCount = computed(() => files.value.length)
const registeredCount = computed(() => files.value.filter(f => f.is_registered).length)
const unregisteredCount = computed(() => files.value.filter(f => !f.is_registered).length)
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 === 'registered') {
result = result.filter(f => f.is_registered)
} else if (statusFilter.value === 'unregistered') {
result = result.filter(f => !f.is_registered)
}
return result
})
function setStatusFilter(status: string) {
statusFilter.value = status
}
function handleSearch() {
// Filter is reactive via computed property
}
async function fetchFiles() {
loading.value = true
error.value = null
try {
const config = getCurrentConfig()
// Call the new scan endpoint
const response: any = await httpFetch(`${config.api_base_url}/api/v1/files/scan`)
files.value = response.files || []
} catch (e) {
console.error('Failed to fetch files:', e)
error.value = String(e)
} finally {
loading.value = false
}
}
async function registerFile(filePath: string) {
try {
const result = await registerVideo(filePath)
const typeTag = result.file_type ? `[${result.file_type.toUpperCase()}]` : ''
alert(`已註冊! ${typeTag} File UUID: ${result.file_uuid}`)
await fetchFiles()
} catch (e) {
console.error('Register failed:', e)
alert('註冊失敗:' + e)
}
}
async function unregisterFile(fileUuid: string, fileName: string) {
if (!confirm(`確定要取消註冊 "${fileName}" 嗎?這將刪除資料庫中的相關記錄,但保留原始檔案。`)) {
return
}
try {
const result = await unregisterVideo(fileUuid)
alert('已取消註冊!' + result.message)
await fetchFiles()
} catch (e) {
console.error('Unregister failed:', e)
alert('取消註冊失敗:' + e)
}
}
function viewDetail(fileUuid: string) {
router.push(`/videos/${fileUuid}`)
}
function formatFileSize(bytes: number): 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 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 '-'
}
}
onMounted(fetchFiles)
</script>

View File

@@ -0,0 +1,550 @@
<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">
<!-- 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, onMounted } from 'vue'
import { getHealth, getIngestStats, getSftpgoStatus, getInferenceHealth } from '@/api/client'
const isTauri = () => {
return (window as any).__TAURI__ !== undefined
}
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('http://127.0.0.1:3003 (dev)')
const sftpgoUrl = ref('https://sftpgo.momentry.ddns.net/web/client')
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())
alert('即將開啟:' + url)
if (isTauri()) {
// Use Tauri invoke in app mode
try {
import('@tauri-apps/api/core').then(({ invoke }) => {
invoke('plugin:shell|open', { path: url }).then(() => {
console.log('Momentry: Opened with shell')
alert('已開啟')
}).catch((e) => {
console.error('Momentry: Shell error:', e)
alert('開啟失敗:' + e)
})
})
} catch (e) {
console.error('Momentry: Import error:', e)
alert('導入失敗:' + e)
}
return
}
// Use browser open in web mode
window.open(url, '_blank')?.focus()
}
function copySftpgoUrl() {
navigator.clipboard.writeText(sftpgoUrl.value)
alert('已複製網址:' + sftpgoUrl.value)
}
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>
<script lang="ts">
import { defineComponent, h } from 'vue'
const ServiceStatusCard = defineComponent({
props: {
name: String,
status: String,
latency: [Number, null],
error: [String, null]
},
setup(props) {
const statusColor = () => {
if (props.status === 'ok') return 'text-green-400'
if (props.status === 'degraded') return 'text-yellow-400'
return 'text-red-400'
}
const bgColor = () => {
if (props.status === 'ok') return 'bg-green-900/20 border-green-700'
if (props.status === 'degraded') return 'bg-yellow-900/20 border-yellow-700'
return 'bg-red-900/20 border-red-700'
}
return () => h('div', {
class: `rounded-lg p-3 border ${bgColor()}`
}, [
h('div', { class: 'flex items-center justify-between' }, [
h('span', { class: 'font-semibold' }, props.name),
h('span', { class: statusColor() }, props.status === 'ok' ? '●' : '○')
]),
props.latency ? h('div', { class: 'text-xs text-gray-400 mt-1' }, `${props.latency}ms`) : null,
props.error ? h('div', { class: 'text-xs text-red-400 mt-1 truncate' }, props.error) : null
])
}
})
export default {
components: { ServiceStatusCard }
}
</script>

View File

@@ -0,0 +1,165 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">身份管理</h2>
<button
@click="loadIdentities"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
>
重新整理
</button>
</div>
<!-- Search Filter -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<input
v-model="filterQuery"
@keyup.enter="loadIdentities"
type="text"
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>
<!-- Identity List -->
<div v-if="identities.length > 0" class="grid gap-4">
<div
v-for="identity in identities"
:key="identity.id"
class="bg-gray-800 rounded-lg p-6 border border-gray-700"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<h3 class="text-xl font-semibold text-blue-400">
{{ identity.profile.name || '未命名' }}
</h3>
<span
v-if="identity.face_identity_id"
class="bg-green-900 text-green-300 px-2 py-1 rounded text-xs"
>
已註冊
</span>
<span
v-else
class="bg-yellow-900 text-yellow-300 px-2 py-1 rounded text-xs"
>
未註冊
</span>
</div>
<div class="grid grid-cols-2 gap-4 text-sm text-gray-400 mb-3">
<div>角色: {{ identity.profile.character_name || '-' }}</div>
<div>Speaker: {{ identity.profile.speaker_id || '-' }}</div>
<div>影片: {{ identity.file_uuid }}</div>
<div>出現次數: {{ identity.stats.appearance_count }}</div>
</div>
<div v-if="identity.profile.original_name" class="text-sm text-gray-500">
原始名稱: {{ identity.profile.original_name }}
</div>
</div>
<!-- Actions -->
<div class="flex space-x-2">
<button
v-if="!identity.face_identity_id"
@click="registerIdentity(identity.person_id, identity.file_uuid)"
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg text-sm transition"
>
註冊為全域身份
</button>
<button
@click="viewDetails(identity)"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded-lg text-sm transition"
>
查看詳情
</button>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-else-if="loading" class="text-center py-12 text-gray-500">
載入中...
</div>
<!-- Empty State -->
<div v-else class="text-center py-12 text-gray-500">
尚無身份資料
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listIdentities } from '@/api/client'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
interface IdentityProfile {
name: string | null
original_name: string | null
character_name: string | null
speaker_id: string | null
}
interface IdentityStats {
appearance_count: number
total_duration: number
first_appearance: number | null
last_appearance: number | null
}
interface Identity {
id: number
person_id: string
face_identity_id: number | null
file_uuid: string
profile: IdentityProfile
stats: IdentityStats
is_confirmed: boolean
}
const identities = ref<Identity[]>([])
const loading = ref(false)
const filterQuery = ref('')
const router = useRouter()
const loadIdentities = async () => {
loading.value = true
try {
const result = await listIdentities()
identities.value = result.identities
} catch (error) {
console.error('Failed to load identities:', error)
alert('載入失敗: ' + error)
} finally {
loading.value = false
}
}
const registerIdentity = async (personId: string, videoUuid: string) => {
try {
await invoke('register_identity', {
personId,
videoUuid
})
alert('註冊成功!')
await loadIdentities()
} catch (error) {
console.error('Registration failed:', error)
alert('註冊失敗: ' + error)
}
}
const viewDetails = (identity: Identity) => {
router.push({
name: 'video-detail',
params: { uuid: identity.file_uuid }
})
}
onMounted(() => {
loadIdentities()
})
</script>

View File

@@ -0,0 +1,119 @@
<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">區域 ID</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 font-mono text-blue-300">{{ video.person_id }}</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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const identityId = ref('')
const loading = ref(false)
const detail = ref<any>(null)
const profile = ref<any>({})
const videos = ref<any[]>([])
const loadDetail = async () => {
identityId.value = route.params.id as string
loading.value = true
try {
const config = JSON.parse(localStorage.getItem('portal_config') || '{}')
const baseUrl = config.api_base_url || 'http://127.0.0.1:3003'
const resp = await fetch(`${baseUrl}/api/v1/identities/${identityId.value}`, {
headers: { 'X-API-Key': config.api_key || '' }
})
const result = await resp.json()
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
}
}
onMounted(() => {
loadDetail()
})
</script>

View File

@@ -0,0 +1,134 @@
<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-8">
<h1 class="text-3xl font-bold text-blue-400">Momentry</h1>
<p class="text-gray-400 mt-2">Video Analysis Portal</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-6">
<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 -->
<div class="mt-8 pt-6 border-t border-gray-700">
<h3 class="text-sm font-medium text-gray-400 mb-3">API 範例</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 http://127.0.0.1:3003/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 http://127.0.0.1:3003/api/v1/auth/logout</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const showPassword = ref(false)
const router = useRouter()
const handleLogin = async () => {
error.value = ''
loading.value = true
try {
const response = await fetch('http://127.0.0.1:3003/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
password: password.value
})
});
if (response.ok) {
const data = await response.json();
if (data.success) {
// Save User Info
localStorage.setItem('momentry_user', JSON.stringify(data.user));
// Save API Key
localStorage.setItem('momentry_api_key', data.api_key);
// Update Config
const config = {
api_base_url: 'http://127.0.0.1:3003',
api_key: data.api_key,
timeout_secs: 30,
};
localStorage.setItem('portal_config', JSON.stringify(config));
router.push('/home');
} else {
error.value = data.message || 'Login failed';
}
} else if (response.status === 401) {
error.value = 'Invalid username or password';
} else {
error.value = 'Server error';
}
} catch (e) {
error.value = 'Connection error. Is the server running?';
} finally {
loading.value = false;
}
}
</script>

View File

@@ -0,0 +1,176 @@
<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>
<!-- Search Filter -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<input
v-model="filterQuery"
@keyup.enter="loadPersons"
type="text"
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>
<!-- Person List -->
<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">
<!-- Thumbnail -->
<PersonThumbnail :personId="person.person_id" :videoUuid="person.file_uuid" />
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<h3 class="text-xl font-semibold text-blue-400">
{{ person.profile.name || '未命名' }}
</h3>
<span
v-if="person.face_identity_id"
class="bg-green-900 text-green-300 px-2 py-1 rounded text-xs"
>
已註冊
</span>
<span
v-else
class="bg-yellow-900 text-yellow-300 px-2 py-1 rounded text-xs"
>
未註冊
</span>
</div>
<div class="grid grid-cols-2 gap-4 text-sm text-gray-400 mb-3">
<div>角色: {{ person.profile.character_name || '-' }}</div>
<div>Speaker: {{ person.profile.speaker_id || '-' }}</div>
<div>影片: {{ person.file_uuid }}</div>
<div>出現次數: {{ person.stats.appearance_count }}</div>
</div>
<div v-if="person.profile.original_name" class="text-sm text-gray-500">
原始名稱: {{ person.profile.original_name }}
</div>
</div>
<!-- Actions -->
<div class="flex flex-col space-y-2">
<button
v-if="!person.face_identity_id"
@click="registerPerson(person.person_id, person.file_uuid)"
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg text-sm transition"
>
註冊為全域身份
</button>
<button
@click="viewDetails(person)"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded-lg text-sm transition"
>
{{ person.face_identity_id ? '查看全域詳情' : '查看影片' }}
</button>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-else-if="loading" class="text-center py-12 text-gray-500">
載入中...
</div>
<!-- Empty State -->
<div v-else class="text-center py-12 text-gray-500">
尚無人物資料
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listIdentities } from '@/api/client'
import { useRouter } from 'vue-router'
import PersonThumbnail from '@/components/PersonThumbnail.vue'
import { invoke } from '@tauri-apps/api/core'
interface PersonProfile {
name: string | null
original_name: string | null
character_name: string | null
speaker_id: string | null
}
interface PersonStats {
appearance_count: number
total_duration: number
first_appearance: number | null
last_appearance: number | null
}
interface Person {
id: number
person_id: string
face_identity_id: number | null
file_uuid: string
profile: PersonProfile
stats: PersonStats
is_confirmed: boolean
}
const persons = ref<Person[]>([])
const loading = ref(false)
const filterQuery = ref('')
const router = useRouter()
const loadPersons = async () => {
loading.value = true
try {
const result = await listIdentities()
persons.value = result.identities
} catch (error) {
console.error('Failed to load persons:', error)
alert('載入失敗: ' + error)
} finally {
loading.value = false
}
}
const registerPerson = async (personId: string, videoUuid: string) => {
try {
await invoke('register_identity', {
personId,
videoUuid
})
alert('註冊成功!')
await loadPersons()
} catch (error) {
console.error('Registration failed:', error)
alert('註冊失敗: ' + error)
}
}
const viewDetails = (person: Person) => {
if (person.face_identity_id) {
router.push({
name: 'identity-detail',
params: { id: person.face_identity_id }
})
} else {
router.push({
name: 'video-detail',
params: { uuid: person.file_uuid }
})
}
}
onMounted(() => {
loadPersons()
})
</script>

View File

@@ -0,0 +1,181 @@
<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>
<!-- Mode Selector -->
<div class="flex items-center space-x-4">
<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>
<span class="text-gray-500 text-xs">
{{ modeDescription }}
</span>
</div>
</div>
<!-- Results -->
<div v-if="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>
<!-- Parent ID Badge -->
<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>
<!-- Visual Stats Badge -->
<span v-if="hit.has_visual_stats" class="bg-cyan-900 text-cyan-300 px-2 py-1 rounded text-sm">
📦 Visual
</span>
<span v-else class="bg-gray-700 text-gray-400 px-2 py-1 rounded text-sm">
No Visual
</span>
</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 -->
<div v-else-if="searched && !loading" class="text-center py-12 text-gray-500">
找不到符合的結果
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { searchVideos } from '@/api/client'
const router = useRouter()
const route = useRoute()
onMounted(() => {
localStorage.removeItem('searchState')
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 results = ref<SearchHit[]>([])
const loading = ref(false)
const searched = ref(false)
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
try {
const result = await searchVideos(searchQuery.value, 20, searchMode.value)
results.value = result.hits
} catch (error) {
console.error('Search failed:', error)
alert('搜尋失敗: ' + error)
} finally {
loading.value = false
}
}
const goToDetail = (uuid: string, chunkId: string) => {
localStorage.setItem('searchState', JSON.stringify({ query: searchQuery.value, results: results.value }))
router.push({
name: 'chunk-detail',
params: { uuid, chunkId }
})
}
</script>

View File

@@ -0,0 +1,337 @@
<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-2 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">{{ 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 Prefix</span>
<p class="text-white mt-1 font-mono">{{ 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-4">
<p>提示: 可透過環境變數切換環境</p>
<code class="bg-gray-900 px-2 py-1 rounded text-xs">export MOMENTRY_API_URL="http://127.0.0.1:3002"</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">
<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">
<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-else class="text-gray-400">載入中...</div>
<div v-if="health" class="mt-4 pt-4 border-t border-gray-700 grid grid-cols-2 gap-4 text-sm text-gray-400">
<div>版本: <span class="text-white">{{ health.version }}</span></div>
<div>運行時間: <span class="text-white">{{ formatUptime(health.uptime_ms) }}</span></div>
</div>
</div>
<!-- 搜尋 API 說明 -->
<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 class="space-y-4">
<!-- Unified Search -->
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<h4 class="font-semibold text-white">統一搜尋 API</h4>
<code class="text-sm text-blue-300">POST /api/v1/search</code>
<p class="text-gray-400 text-sm mt-2">
透過 mode 參數切換搜尋模式
</p>
<ul class="text-gray-400 text-sm mt-2 ml-4 space-y-1">
<li><strong class="text-white">vector</strong> - 語意向量搜尋 (預設, Qdrant)</li>
<li><strong class="text-white">bm25</strong> - 關鍵字搜尋 (PostgreSQL tsvector)</li>
<li><strong class="text-white">hybrid</strong> - 混合搜尋 (向量 + BM25, 可調權重)</li>
<li><strong class="text-white">smart</strong> - 智慧搜尋 (Gemma4 LLM 分析 5W1H)</li>
</ul>
</div>
<!-- N8N Search (不變) -->
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<h4 class="font-semibold text-white">N8N 搜尋</h4>
<code class="text-sm text-blue-300">POST /api/v1/n8n/search</code>
<p class="text-gray-400 text-sm mt-2">
保持不變專為 n8n 工作流設計
</p>
</div>
</div>
<!-- 搜尋目標 -->
<h4 class="font-semibold text-white mt-6 mb-3">搜尋目標說明</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-gray-900 p-3 rounded border border-green-600">
<h5 class="font-semibold text-green-400">Sentence Chunks</h5>
<p class="text-gray-400 text-xs mt-1">有文字內容來自 ASR 語音辨識</p>
</div>
<div class="bg-gray-900 p-3 rounded border border-yellow-600">
<h5 class="font-semibold text-yellow-400">Cut Chunks</h5>
<p class="text-gray-400 text-xs mt-1">場景剪輯點無文字</p>
</div>
<div class="bg-gray-900 p-3 rounded border border-blue-600">
<h5 class="font-semibold text-blue-400">Time Chunks</h5>
<p class="text-gray-400 text-xs mt-1">時間區間無文字</p>
</div>
</div>
<!-- 底層服務 -->
<h4 class="font-semibold text-white mt-6 mb-3">底層服務</h4>
<div class="bg-gray-900 p-4 rounded border border-gray-600">
<table class="w-full text-sm">
<tbody class="text-gray-300">
<tr class="border-b border-gray-800">
<td class="py-2">PostgreSQL</td>
<td class="py-2">資料庫 + BM25</td>
<td class="py-2 text-green-400"></td>
</tr>
<tr class="border-b border-gray-800">
<td class="py-2">Qdrant</td>
<td class="py-2">向量搜尋</td>
<td class="py-2 text-green-400"></td>
</tr>
<tr class="border-b border-gray-800">
<td class="py-2">Redis</td>
<td class="py-2">快取</td>
<td class="py-2 text-green-400"></td>
</tr>
<tr>
<td class="py-2">Ollama</td>
<td class="py-2">向量嵌入</td>
<td class="py-2 text-yellow-400"></td>
</tr>
</tbody>
</table>
</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-6">
<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 Prefix: <span class="text-white">momentry:</span></p>
<p class="text-xs mt-2">正式數據穩定運行</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 Prefix: <span class="text-white">momentry_dev:</span></p>
<p class="text-xs mt-2">測試數據開發用</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, defineComponent, h } from 'vue'
import { getHealth, getCurrentConfig } from '@/api/client'
const ServiceStatusCard = defineComponent({
props: {
name: String,
status: String,
latency: [Number, null],
error: [String, null]
},
setup(props: { name?: string; status?: string; latency?: number | null; error?: string | null }) {
const statusColor = () => {
if (props.status === 'ok') return 'text-green-400'
if (props.status === 'degraded') return 'text-yellow-400'
return 'text-red-400'
}
const bgColor = () => {
if (props.status === 'ok') return 'bg-green-900/20 border-green-700'
if (props.status === 'degraded') return 'bg-yellow-900/20 border-yellow-700'
return 'bg-red-900/20 border-red-700'
}
return () => h('div', {
class: `rounded-lg p-3 border ${bgColor()}`
}, [
h('div', { class: 'flex items-center justify-between' }, [
h('span', { class: 'font-semibold' }, props.name),
h('span', { class: statusColor() }, props.status === 'ok' ? '●' : '○')
]),
props.latency ? h('div', { class: 'text-xs text-gray-400 mt-1' }, `${props.latency}ms`) : null,
props.error ? h('div', { class: 'text-xs text-red-400 mt-1 truncate' }, props.error) : null
])
}
})
interface PortalConfig {
api_base_url: string
api_key: string
timeout_secs: number
}
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
}
const config = ref<PortalConfig | null>(null)
const health = ref<DetailedHealthResponse | null>(null)
const healthError = ref<string | null>(null)
const healthLoading = ref(false)
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) return ''
return config.value.api_key.substring(0, 12) + '...'
})
async function fetchConfig() {
try {
config.value = getCurrentConfig()
} catch (e) {
console.error('Failed to get config:', e)
}
}
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()
}
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(() => {
fetchConfig()
fetchHealth()
})
</script>

View File

@@ -0,0 +1,353 @@
<template>
<div class="space-y-6">
<!-- Header with Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<button @click="goBack" class="flex items-center space-x-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition">
<span class="text-xl"></span>
<span>返回納管檔案列表</span>
</button>
<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>
</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 / 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 } from '@/api/client'
const route = useRoute()
const router = useRouter()
const uuid = route.params.uuid as string
const video = ref<any>(null)
const probeInfo = ref<any>(null)
const clusters = ref<any[]>([])
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'
}
})
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
}
}
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.files?.[0]) {
const v = result.files[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)
}
}
}
} catch (e) {
console.error('Failed to load detail:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadVideoDetail()
})
</script>