feat: add pause/resume to library context menu and batch operations

- Add pause/resume buttons to file context menu (right-click)
- Add batch pause/resume buttons to section titles
- Support concurrent pause/resume with pLimit(3)
- Add paused status badge (⏸) with warning color
- Integrate with Core API /api/v1/jobs/:uuid/pause|resume
- Track paused files via Redis jobs:paused set
- Add i18n translations (zh-TW, en)
This commit is contained in:
2026-10-18 21:09:05 +08:00
parent f43786f6af
commit 4e90f832d9
3 changed files with 198 additions and 5 deletions

View File

@@ -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",

View File

@@ -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": "處理器",

View File

@@ -102,6 +102,12 @@
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-scan-btn" @click="batchDeepScan">
🔍 Deep Scan ({{ selectedRegisteredFiles.length }})
</button>
<button v-if="selectedScanningFiles.length > 0" class="ms-batch-pause-btn" @click="batchPause">
⏸ Pause ({{ selectedScanningFiles.length }})
</button>
<button v-if="selectedPausedFiles.length > 0" class="ms-batch-resume-btn" @click="batchResume">
▶ Resume ({{ selectedPausedFiles.length }})
</button>
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-unregister-btn" @click="batchUnregister">
🗑️ {{ t('library.context.unregister') }} ({{ selectedRegisteredFiles.length }})
</button>
@@ -151,6 +157,12 @@
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-scan-btn" @click="batchDeepScan">
🔍 Deep Scan ({{ selectedRegisteredFiles.length }})
</button>
<button v-if="selectedScanningFiles.length > 0" class="ms-batch-pause-btn" @click="batchPause">
⏸ Pause ({{ selectedScanningFiles.length }})
</button>
<button v-if="selectedPausedFiles.length > 0" class="ms-batch-resume-btn" @click="batchResume">
▶ Resume ({{ selectedPausedFiles.length }})
</button>
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-unregister-btn" @click="batchUnregister">
🗑️ {{ t('library.context.unregister') }} ({{ selectedRegisteredFiles.length }})
</button>
@@ -282,6 +294,14 @@
<button v-if="ctxMenuFile?.isRegistered && ctxMenuFile?.status !== 'completed'" class="ms-ctx-item" @click="ctxProcess(ctxMenuFile)">🔍 {{ t('library.confirm.title_process') }}</button>
<button v-if="ctxMenuFile?.isRegistered && ctxMenuFile?.status === 'completed'" class="ms-ctx-item" @click="ctxReprocess(ctxMenuFile)">🔄 {{ t('library.confirm.title_rescan') }}</button>
<!-- Pause / Resume -->
<button v-if="ctxMenuFile?.status === 'scanning' && !isFilePaused(ctxMenuFile?.file_uuid)"
class="ms-ctx-item ms-ctx-warning"
@click="ctxPause(ctxMenuFile)">⏸ {{ t('library.context.pause') }}</button>
<button v-if="ctxMenuFile?.status === 'scanning' && isFilePaused(ctxMenuFile?.file_uuid)"
class="ms-ctx-item ms-ctx-success"
@click="ctxResume(ctxMenuFile)">▶ {{ t('library.context.resume') }}</button>
<button v-if="ctxMenuFile?.isRegistered" class="ms-ctx-item ms-ctx-danger" @click="ctxUnregister(ctxMenuFile)">🗑️ {{ t('library.context.unregister') }}</button>
<button v-if="isVideo(ctxMenuFile) && ctxMenuFile?.isRegistered" class="ms-ctx-item" @click="playVideo(ctxMenuFile); closeContextMenu()">▶ {{ t('library.context.play_video') }}</button>
<template v-if="ctxMenuFile?.isRegistered">
@@ -712,12 +732,45 @@ const filterText = ref('')
const displayFilter = ref('all')
const showFilter = ref(false)
const selectedFiles = ref<string[]>([])
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<Set<string>>(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; }