feat: add migrations, test scripts, and utility tools

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

View File

@@ -0,0 +1,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>