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:
344
portal/src/views/ChunkDetailView.vue
Normal file
344
portal/src/views/ChunkDetailView.vue
Normal 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">此片段尚無視覺分析數據 (YOLO、Pose、Face、OCR)。</p>
|
||||
</div>
|
||||
|
||||
<!-- Raw Content Card -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-400 mb-4">原始內容 (Raw Content)</h3>
|
||||
<pre class="bg-gray-900 p-4 rounded overflow-x-auto text-xs text-gray-300">{{ JSON.stringify(detail.content, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else 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>
|
||||
195
portal/src/views/FaceCandidatesView.vue
Normal file
195
portal/src/views/FaceCandidatesView.vue
Normal 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>
|
||||
255
portal/src/views/FilesView.vue
Normal file
255
portal/src/views/FilesView.vue
Normal 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>
|
||||
550
portal/src/views/HomeView.vue
Normal file
550
portal/src/views/HomeView.vue
Normal 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>
|
||||
165
portal/src/views/IdentitiesView.vue
Normal file
165
portal/src/views/IdentitiesView.vue
Normal 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>
|
||||
119
portal/src/views/IdentityDetailView.vue
Normal file
119
portal/src/views/IdentityDetailView.vue
Normal 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>
|
||||
134
portal/src/views/LoginView.vue
Normal file
134
portal/src/views/LoginView.vue
Normal 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>
|
||||
176
portal/src/views/PersonsView.vue
Normal file
176
portal/src/views/PersonsView.vue
Normal 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>
|
||||
181
portal/src/views/SearchView.vue
Normal file
181
portal/src/views/SearchView.vue
Normal 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>
|
||||
337
portal/src/views/SettingsView.vue
Normal file
337
portal/src/views/SettingsView.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
353
portal/src/views/VideoDetailView.vue
Normal file
353
portal/src/views/VideoDetailView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user