diff --git a/portal/src/views/FilesView.vue b/portal/src/views/FilesView.vue index 2eab355..519d74e 100644 --- a/portal/src/views/FilesView.vue +++ b/portal/src/views/FilesView.vue @@ -3,7 +3,31 @@

檔案管理 (Demo)

-
+
+ +
+ + + +
+ +
檔案名稱 + 類型 狀態 UUID 操作 @@ -81,7 +120,21 @@
- + + 🎬 影片 + + + 📷 照片 + + + + + ✅ 已入庫 + + + ⏳ 未入庫 + + ✅ 已完成 @@ -148,16 +201,32 @@ import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import { registerVideo, unregisterVideo, httpFetch, getCurrentConfig } from '@/api/client' +const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'avi', 'webm', 'wmv', 'flv', 'm4v'] +const PHOTO_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'tif'] + const router = useRouter() const files = ref([]) const loading = ref(false) const error = ref(null) const searchQuery = ref('') -const statusFilter = ref('all') // all, unregistered, pending, processing, completed +const statusFilter = ref('all') +const mediaType = ref('all') + +function getMediaType(fileName: string): 'video' | 'photo' { + const ext = fileName.split('.').pop()?.toLowerCase() || '' + if (VIDEO_EXTENSIONS.includes(ext)) return 'video' + if (PHOTO_EXTENSIONS.includes(ext)) return 'photo' + return 'video' +} const displayFiles = computed(() => { let result = files.value + // Filter by media type + if (mediaType.value !== 'all') { + result = result.filter(f => f.media_type === mediaType.value) + } + // Filter by search if (searchQuery.value) { const q = searchQuery.value.toLowerCase() @@ -169,7 +238,13 @@ const displayFiles = computed(() => { // Filter by status if (statusFilter.value !== 'all') { - result = result.filter(f => f.status === statusFilter.value) + if (statusFilter.value === 'indexed') { + result = result.filter(f => f.status === 'completed' && f.is_indexed) + } else if (statusFilter.value === 'unindexed') { + result = result.filter(f => f.status === 'completed' && !f.is_indexed) + } else { + result = result.filter(f => f.status === statusFilter.value) + } } return result @@ -179,24 +254,38 @@ function setStatusFilter(status: string) { statusFilter.value = status } +function setMediaType(type: string) { + mediaType.value = type +} + async function fetchFiles() { loading.value = true try { const config = getCurrentConfig() const scanResp = await httpFetch(`${config.api_base_url}/api/v1/files/scan`) - const scanFiles: any[] = (scanResp?.files || []).map((f: any) => ({ - ...f, - status: f.is_registered ? 'registered_scan' : 'unregistered' - })) + const scanFiles: any[] = (scanResp?.files || []).map((f: any) => { + const mediaType = getMediaType(f.file_name) + return { + ...f, + media_type: mediaType, + status: f.is_registered ? 'registered_scan' : 'unregistered', + is_indexed: false + } + }) // Get registered files with real processing status let regFiles: any[] = [] try { const regResp = await httpFetch(`${config.api_base_url}/api/v1/files?page=1&page_size=100`) - regFiles = (regResp?.files || regResp?.data || []).map((f: any) => ({ - ...f, - status: f.status || 'pending' - })) + regFiles = (regResp?.files || regResp?.data || []).map((f: any) => { + const mediaType = getMediaType(f.file_name) + return { + ...f, + media_type: mediaType, + status: f.status || 'pending', + is_indexed: f.total_chunks > 0 || false + } + }) } catch { // Registered files API may not be available; use scan data only } @@ -207,7 +296,16 @@ async function fetchFiles() { merged.set(f.file_path, f) } for (const f of regFiles) { - merged.set(f.file_path, f) + // Preserve media_type from scan if not set in regFiles + const existing = merged.get(f.file_path) + if (existing) { + merged.set(f.file_path, { + ...f, + media_type: f.media_type || existing.media_type + }) + } else { + merged.set(f.file_path, f) + } } files.value = Array.from(merged.values()) diff --git a/src/api/identity_api.rs b/src/api/identity_api.rs index 8c92df5..757a364 100644 --- a/src/api/identity_api.rs +++ b/src/api/identity_api.rs @@ -96,11 +96,21 @@ async fn list_files( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data = if let Some(v) = video { + let chunk_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1", + crate::core::db::schema::table_name("chunk") + )) + .bind(&v.file_uuid) + .fetch_one(state.db.pool()) + .await + .unwrap_or(0); + vec![FileItem { file_uuid: v.file_uuid, file_name: v.file_name, file_path: v.file_path, status: v.status.as_str().to_string(), + total_chunks: chunk_count, }] } else { vec![] @@ -124,18 +134,45 @@ async fn list_files( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let data = records + let total = records.1; + + let mut data: Vec = records .0 .into_iter() .map(|r| FileItem { - file_uuid: r.file_uuid, + file_uuid: r.file_uuid.clone(), file_name: r.file_name, file_path: r.file_path, status: r.status.as_str().to_string(), + total_chunks: 0, }) .collect(); - let total = records.1; + // Fetch chunk counts for all files in one query + let uuids: Vec = data.iter().map(|f| f.file_uuid.clone()).collect(); + if !uuids.is_empty() { + let chunk_table = crate::core::db::schema::table_name("chunk"); + let placeholders: Vec = (1..=uuids.len()).map(|i| format!("${}", i)).collect(); + let query_str = format!( + "SELECT file_uuid, COUNT(*) as cnt FROM {} WHERE file_uuid IN ({}) GROUP BY file_uuid", + chunk_table, + placeholders.join(", ") + ); + + let chunk_counts: Vec<(String, i64)> = sqlx::query_as(&query_str) + .fetch_all(state.db.pool()) + .await + .unwrap_or_default(); + + let count_map: std::collections::HashMap = + chunk_counts.into_iter().collect(); + + for item in &mut data { + if let Some(cnt) = count_map.get(&item.file_uuid) { + item.total_chunks = *cnt; + } + } + } Ok(Json(FilesResponse { success: true, @@ -161,6 +198,8 @@ pub struct FileItem { pub file_name: String, pub file_path: String, pub status: String, + #[serde(default)] + pub total_chunks: i64, } #[derive(Debug, Serialize)]