| 檔案名稱 |
+ 類型 |
狀態 |
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)]
|