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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user