diff --git a/src/locales/en.json b/src/locales/en.json
index 4df3c7d..981103d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -57,7 +57,8 @@
"keyword_ready": "Keyword Ready",
"semantic_ready": "Semantic Ready",
"face_mgmt_ready": "Face Mgmt Ready",
- "agent_ready": "Agent Ready"
+ "agent_ready": "Agent Ready",
+ "paused": "Paused"
},
"thumbnail": {
"register_to_preview": "Register to see preview"
@@ -75,7 +76,9 @@
"select_processor": "Please select at least one processor",
"file_must_be_completed": "File must be completed first",
"face_deduplication": "Face Deduplication",
- "qc_check": "QC Check"
+ "qc_check": "QC Check",
+ "pause": "⏸ Pause",
+ "resume": "▶ Resume"
},
"pipeline": {
"processors": "Processors",
diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json
index 71256ae..28b1b31 100644
--- a/src/locales/zh-TW.json
+++ b/src/locales/zh-TW.json
@@ -57,7 +57,8 @@
"keyword_ready": "關鍵字可用",
"semantic_ready": "語意可用",
"face_mgmt_ready": "臉部管理可用",
- "agent_ready": "Agent 可用"
+ "agent_ready": "Agent 可用",
+ "paused": "已暫停"
},
"thumbnail": {
"register_to_preview": "註冊以預覽"
@@ -75,7 +76,9 @@
"select_processor": "請選擇至少一個處理器",
"file_must_be_completed": "檔案必須先完成處理",
"face_deduplication": "人臉去重",
- "qc_check": "品質檢查"
+ "qc_check": "品質檢查",
+ "pause": "⏸ 暫停",
+ "resume": "▶ 繼續"
},
"pipeline": {
"processors": "處理器",
diff --git a/src/views/LibraryView.vue b/src/views/LibraryView.vue
index 7c50135..e538139 100644
--- a/src/views/LibraryView.vue
+++ b/src/views/LibraryView.vue
@@ -102,6 +102,12 @@
+
+
@@ -151,6 +157,12 @@
+
+
@@ -282,6 +294,14 @@
+
+
+
+
@@ -712,12 +732,45 @@ const filterText = ref('')
const displayFilter = ref('all')
const showFilter = ref(false)
const selectedFiles = ref([])
+
+const selectedScanningFiles = computed(() =>
+ filesCache.value.filter((f: any) =>
+ selectedFiles.value.includes(f.file_uuid) && f.status === 'scanning'
+ )
+)
+
+const selectedPausedFiles = computed(() =>
+ filesCache.value.filter((f: any) =>
+ selectedFiles.value.includes(f.file_uuid) && isFilePaused(f.file_uuid)
+ )
+)
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startFrame: 0, endFrame: 0, title: '' })
const processorJsonDialog = ref(false)
const processorJsonContent = ref('')
const processorJsonTitle = ref('')
+// Paused files tracking
+const pausedFileUuids = ref>(new Set())
+
+async function loadPausedFiles() {
+ try {
+ const base = getApiBase()
+ const res = await fetch(`${base}/api/v1/jobs/paused`)
+ if (res.ok) {
+ const data = await res.json()
+ pausedFileUuids.value = new Set((data.jobs || []).map((j: any) => j.uuid))
+ }
+ } catch (e) {
+ console.error('[loadPausedFiles]', e)
+ }
+}
+
+function isFilePaused(fileUuid?: string): boolean {
+ if (!fileUuid) return false
+ return pausedFileUuids.value.has(fileUuid)
+}
+
async function viewProcessorJson(processor: string) {
const f = ctxMenu.value.file
if (!f?.file_uuid) return
@@ -945,7 +998,7 @@ const selectedRegisteredFiles = computed(() => {
return [...registered, ...completed]
})
-onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh(); startProcessingFilesPolling(); startFilesWatch() })
+onMounted(() => { loadFiles(); loadPausedFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh(); startProcessingFilesPolling(); startFilesWatch() })
onUnmounted(() => { document.removeEventListener('click', docClickClose); stopStatsAutoRefresh(); stopProcessingFilesPolling(); stopFilesWatch() })
function docClickClose(e: MouseEvent) {
@@ -1683,6 +1736,7 @@ function statusBadgeClass(f: any): string {
if (f.status === 'preparing') return 'ms-badge-off'
if (f.status === 'registered') return 'ms-badge-warn'
if (f.status === 'scanning') return 'ms-badge-proc'
+ if (f.status === 'paused') return 'ms-badge-paused'
if (f.status === 'keyword_ready' || f.status === 'semantic_ready') return 'ms-badge-part'
if (f.status === 'face_mgmt_ready') return 'ms-badge-prog'
if (f.status === 'completed') return 'ms-badge-done'
@@ -1693,6 +1747,7 @@ function statusBadgeIcon(f: any): string {
if (f.status === 'preparing') return '⏳'
if (f.status === 'registered') return '📋'
if (f.status === 'scanning') return '🔍'
+ if (f.status === 'paused') return '⏸'
if (f.status === 'keyword_ready') return '🔎'
if (f.status === 'semantic_ready') return '🧠'
if (f.status === 'face_mgmt_ready') return '🎭'
@@ -2218,6 +2273,129 @@ async function ctxUnregister(f: any) {
setTimeout(() => { actionMsg.value = '' }, 8000)
}
+// Pause / Resume functions
+async function ctxPause(f: any) {
+ closeContextMenu()
+ if (!f?.file_uuid) return
+ try {
+ const base = getApiBase()
+ const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/pause`, { method: 'POST' })
+ if (res.ok) {
+ pausedFileUuids.value.add(f.file_uuid)
+ const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid)
+ if (file) file.status = 'paused'
+ actionMsg.value = `⏸ Paused: ${f.file_name}`
+ } else {
+ const err = await res.json().catch(() => ({}))
+ actionMsg.value = `Pause failed: ${err.error || res.statusText}`
+ }
+ } catch (e: any) {
+ actionMsg.value = `Pause error: ${e.message}`
+ console.error('[ctxPause]', e)
+ }
+ setTimeout(() => { actionMsg.value = '' }, 5000)
+}
+
+async function ctxResume(f: any) {
+ closeContextMenu()
+ if (!f?.file_uuid) return
+ try {
+ const base = getApiBase()
+ const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/resume`, { method: 'POST' })
+ if (res.ok) {
+ pausedFileUuids.value.delete(f.file_uuid)
+ actionMsg.value = `▶ Resumed: ${f.file_name}`
+ await loadFiles()
+ await loadPausedFiles()
+ } else {
+ const err = await res.json().catch(() => ({}))
+ actionMsg.value = `Resume failed: ${err.error || res.statusText}`
+ }
+ } catch (e: any) {
+ actionMsg.value = `Resume error: ${e.message}`
+ console.error('[ctxResume]', e)
+ }
+ setTimeout(() => { actionMsg.value = '' }, 5000)
+}
+
+async function batchPause() {
+ const filesToPause = selectedScanningFiles.value
+ if (filesToPause.length === 0) return
+
+ const base = getApiBase()
+ const limit = pLimit(3)
+ let successCount = 0
+ let failCount = 0
+
+ actionMsg.value = `⏸ Pausing ${filesToPause.length} files...`
+
+ async function pauseFile(f: any) {
+ try {
+ const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/pause`, { method: 'POST' })
+ if (res.ok) {
+ pausedFileUuids.value.add(f.file_uuid)
+ const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid)
+ if (file) file.status = 'paused'
+ successCount++
+ } else {
+ failCount++
+ }
+ } catch (e) {
+ failCount++
+ console.error('[batchPause] Failed:', f.file_name, e)
+ }
+ }
+
+ await Promise.all(filesToPause.map(f => limit(() => pauseFile(f))))
+
+ if (successCount > 0) {
+ actionMsg.value = `⏸ Paused ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
+ } else {
+ actionMsg.value = `Failed to pause all ${failCount} files`
+ }
+ selectedFiles.value = []
+ setTimeout(() => { actionMsg.value = '' }, 5000)
+}
+
+async function batchResume() {
+ const filesToResume = selectedPausedFiles.value
+ if (filesToResume.length === 0) return
+
+ const base = getApiBase()
+ const limit = pLimit(3)
+ let successCount = 0
+ let failCount = 0
+
+ actionMsg.value = `▶ Resuming ${filesToResume.length} files...`
+
+ async function resumeFile(f: any) {
+ try {
+ const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/resume`, { method: 'POST' })
+ if (res.ok) {
+ pausedFileUuids.value.delete(f.file_uuid)
+ successCount++
+ } else {
+ failCount++
+ }
+ } catch (e) {
+ failCount++
+ console.error('[batchResume] Failed:', f.file_name, e)
+ }
+ }
+
+ await Promise.all(filesToResume.map(f => limit(() => resumeFile(f))))
+
+ if (successCount > 0) {
+ actionMsg.value = `▶ Resumed ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
+ await loadFiles()
+ await loadPausedFiles()
+ } else {
+ actionMsg.value = `Failed to resume all ${failCount} files`
+ }
+ selectedFiles.value = []
+ setTimeout(() => { actionMsg.value = '' }, 5000)
+}
+
function resetFilters() {
sortBy.value = 'time_desc'
filterUnregistered.value = false
@@ -2290,6 +2468,10 @@ function resetFilters() {
.ms-ctx-item:hover { background: var(--hover-background); }
.ms-ctx-danger { color: var(--danger-color) !important; }
.ms-ctx-danger:hover { background: var(--danger-background) !important; }
+.ms-ctx-warning { color: var(--warning-color) !important; }
+.ms-ctx-warning:hover { background: var(--warning-background) !important; }
+.ms-ctx-success { color: var(--success-color) !important; }
+.ms-ctx-success:hover { background: var(--success-background) !important; }
.ms-ctx-procs { padding: 6px 10px 8px; display: flex; flex-wrap: wrap; gap: 6px 10px; }
.ms-ctx-procs-title { width: 100%; font-size: 11px; color: var(--text-secondary); font-weight: 600; margin-bottom: 2px; }
.ms-ctx-progress { padding: 8px 12px; background: var(--hover-background); border-radius: 8px; margin-top: 8px; }
@@ -2353,6 +2535,7 @@ function resetFilters() {
.ms-badge-done { background: var(--success-color); color: #fff; }
.ms-badge-warn { background: var(--warning-color); color: var(--text-primary); }
.ms-badge-fail { background: var(--danger-color); color: #fff; }
+.ms-badge-paused { background: var(--warning-color); color: var(--text-primary); }
@keyframes badge-pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
/* Three-axis status bar */
@@ -2368,6 +2551,10 @@ function resetFilters() {
.ms-batch-scan-btn:hover { background: var(--info-hover, #138496); }
.ms-batch-unregister-btn { margin-left: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--danger-color, #dc3545); color: white; border: none; border-radius: 6px; cursor: pointer; }
.ms-batch-unregister-btn:hover { background: var(--danger-hover, #c82333); }
+.ms-batch-pause-btn { margin-left: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--warning-color, #ffc107); color: var(--text-primary); border: none; border-radius: 6px; cursor: pointer; }
+.ms-batch-pause-btn:hover { background: var(--warning-hover, #e0a800); }
+.ms-batch-resume-btn { margin-left: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--success-color, #28a745); color: white; border: none; border-radius: 6px; cursor: pointer; }
+.ms-batch-resume-btn:hover { background: var(--success-hover, #218838); }
.ms-fm-card-checkbox { position: absolute; bottom: 8px; right: 8px; width: 24px; height: 24px; border: 2px solid var(--border-color); border-radius: 4px; background: var(--card-background); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--primary-color); z-index: 10; }
.ms-fm-card-checkbox:hover { border-color: var(--primary-color); }
.ms-fm-card-register-btn { position: absolute; bottom: 8px; right: 40px; width: 28px; height: 28px; border: none; border-radius: 4px; background: var(--primary-color); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; opacity: 0; transition: opacity .2s, transform .15s; z-index: 10; }