feat: 批量 unregister 功能 + 性能优化

功能:
- LibraryView 添加批量 unregister 按钮(勾选多个已注册文件后显示)
- 添加确认对话框,支持批量取消注册
- 添加中英日三语翻译

性能优化:
- search_agents API timeout 提升到 90s(避免前端超时)
- thumbnail 并发数从 32 降到 10(减少服务器压力)

修复:
- PeopleView 添加 group_uuid 字段映射
- LibraryView 排序优化(scanning/preparing 优先)
This commit is contained in:
2026-10-07 23:39:31 +08:00
parent 5928bc9404
commit e65d5648b8
7 changed files with 206 additions and 112 deletions

View File

@@ -301,7 +301,7 @@ case 'search_keyword': {
case 'search_agents': {
const body: any = { query: a.query }
if (a.conversationId) body.conversation_id = a.conversationId
return { url: '/api/v1/agents/search', method: 'POST', body }
return { url: '/api/v1/agents/search', method: 'POST', body, timeout: 90000 }
}
// --- Image APIs ---

View File

@@ -49,6 +49,7 @@
"registered": "Registered",
"unregistered": "Unregistered",
"pending": "Pending",
"preparing": "Preparing",
"processing": "Processing",
"completed": "Completed",
"scanning": "Scanning",
@@ -136,7 +137,8 @@
"rerun_processor": "Re-run {processor} for this file?",
"run_processor": "Run {processor} on {filename}?",
"unregister_title": "Unregister",
"unregister_message": "Unregister \"{filename}\"?\n\nThis will remove the file and its output data."
"unregister_message": "Unregister \"{filename}\"?\n\nThis will remove the file and its output data.",
"batch_unregister_message": "Unregister {count} files?\n\nThis will remove the files and their output data."
},
"processor": {
"started": "{processor} started",

View File

@@ -49,6 +49,7 @@
"registered": "登録済み",
"unregistered": "未登録",
"pending": "保留中",
"preparing": "準備中",
"processing": "処理中",
"completed": "完了",
"scanning": "スキャン中",
@@ -136,7 +137,8 @@
"rerun_processor": "{processor} を再実行しますか?",
"run_processor": "{filename} で {processor} を実行しますか?",
"unregister_title": "登録解除",
"unregister_message": "「{filename}」を登録解除しますか?\n\nファイルとその出力データが削除されます。"
"unregister_message": "「{filename}」を登録解除しますか?\n\nファイルとその出力データが削除されます。",
"batch_unregister_message": "{count} ファイルを登録解除しますか?\n\nファイルとその出力データが削除されます。"
},
"processor": {
"started": "{processor} が開始されました",

View File

@@ -49,6 +49,7 @@
"registered": "已註冊",
"unregistered": "未註冊",
"pending": "等待中",
"preparing": "準備中",
"processing": "處理中",
"completed": "已完成",
"scanning": "掃描中",
@@ -136,7 +137,8 @@
"rerun_processor": "重新執行 {processor}?",
"run_processor": "在 {filename} 上執行 {processor}?",
"unregister_title": "取消註冊",
"unregister_message": "取消註冊「{filename}」?\n\n這將移除檔案及其輸出資料。"
"unregister_message": "取消註冊「{filename}」?\n\n這將移除檔案及其輸出資料。",
"batch_unregister_message": "取消註冊 {count} 個檔案?\n\n這將移除檔案及其輸出資料。"
},
"processor": {
"started": "{processor} 已開始",

View File

@@ -425,7 +425,7 @@ loadProgressCacheFromStorage()
const thumbQueue: (() => Promise<void>)[] = []
let activeThumbLoads = 0
const MAX_CONCURRENT = 32
const MAX_CONCURRENT = 10
const profileQueue: (() => Promise<void>)[] = []
let activeProfileLoads = 0

View File

@@ -97,7 +97,12 @@
<!-- Completed Section -->
<div v-if="completedFiles.length && showCompletedSection" class="ms-fm-section">
<div class="ms-fm-section-title">✅ {{ t('library.status.completed') }} ({{ completedFiles.length }})</div>
<div class="ms-fm-section-title">
✅ {{ t('library.status.completed') }} ({{ completedFiles.length }})
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-unregister-btn" @click="batchUnregister">
🗑️ {{ t('library.context.unregister') }} ({{ selectedRegisteredFiles.length }})
</button>
</div>
<div class="ms-fm-grid">
<div v-for="f in completedFiles" :key="f.file_uuid" class="ms-fm-card ms-fm-card-completed" :class="{ 'is-selected': selectedFiles.includes(f.file_uuid) }" :data-drag-key="f.file_uuid" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)" @touchstart="onCardTouchStart($event, f)" @touchend="onCardTouchEnd" @touchmove="onCardTouchEnd">
<div class="ms-fm-thumb-wrap">
@@ -682,6 +687,7 @@ import { getAsrStatus } from '@/utils/asrStatus'
import { useAdvancedMode } from '@/stores/advancedMode'
import { useAutoScan } from '@/stores/autoScan'
import VideoPlayer from '../components/VideoPlayer.vue'
import pLimit from 'p-limit'
const { t } = useI18n()
const { isAdvanced } = useAdvancedMode()
@@ -743,7 +749,7 @@ const durationMax = ref<number | null>(null)
// Sort & Filter
const registeredFiles = computed(() => {
let result = files.value.filter((f: any) => f.isRegistered && f.status !== 'completed')
let result = filesCache.value.filter((f: any) => f.isRegistered && f.status !== 'completed')
if (displayFilter.value === 'video') result = result.filter(isVideo)
else if (displayFilter.value === 'photo') result = result.filter(isPhoto)
if (filterText.value) {
@@ -756,8 +762,20 @@ const registeredFiles = computed(() => {
if (onlyPhotos.value) result = result.filter(isPhoto)
if (sizeMin.value) result = result.filter((f: any) => (f.file_size || 0) >= sizeMin.value! * 1048576)
if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576)
// Sort
// Sort: scanning/preparing files first, then by user selection
result.sort((a: any, b: any) => {
const getPriority = (f: any) => {
if (f.status === 'scanning') return 0
if (f.status === 'preparing') return 1
return 2
}
const priorityA = getPriority(a)
const priorityB = getPriority(b)
if (priorityA !== priorityB) return priorityA - priorityB
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
@@ -838,7 +856,7 @@ const { onMousedown: dragMousedown, onClickCapture: dragClickCapture } = useDrag
})
const sortedFilteredFiles = computed(() => {
let result = [...files.value]
let result = [...filesCache.value]
// Display filter
if (displayFilter.value === 'video') result = result.filter(isVideo)
@@ -874,8 +892,21 @@ const sortedFilteredFiles = computed(() => {
})
}
// Sort
// Sort: scanning/preparing files first, then by user selection
result.sort((a: any, b: any) => {
// Priority: scanning > preparing > others
const getPriority = (f: any) => {
if (f.status === 'scanning') return 0
if (f.status === 'preparing') return 1
return 2
}
const priorityA = getPriority(a)
const priorityB = getPriority(b)
if (priorityA !== priorityB) return priorityA - priorityB
// Within same priority, sort by user selection
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
@@ -896,6 +927,12 @@ const completedCount = computed(() => files.value.filter((f: any) => f.status ==
const scanningCount = computed(() => files.value.filter((f: any) => f.status === 'scanning').length)
const readyCount = computed(() => files.value.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status)).length)
const selectedUnregisteredFiles = computed(() => unregisteredFiles.value.filter((f: any) => selectedFiles.value.includes(f.file_path)))
const selectedRegisteredFiles = computed(() => {
const selected = selectedFiles.value
const registered = registeredFiles.value.filter((f: any) => selected.includes(f.file_uuid))
const completed = completedFiles.value.filter((f: any) => selected.includes(f.file_uuid))
return [...registered, ...completed]
})
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh(); startProcessingFilesPolling(); startFilesWatch() })
onUnmounted(() => { document.removeEventListener('click', docClickClose); stopStatsAutoRefresh(); stopProcessingFilesPolling(); stopFilesWatch() })
@@ -908,31 +945,41 @@ function docClickClose(e: MouseEvent) {
}
}
// Concurrency limit for status API calls
const statusLimit = pLimit(5)
async function loadFiles() {
statusText.value = 'Loading...'
invalidateFiles()
await ensureFiles()
statusText.value = `${files.value.length} files`
// Load thumbnails asynchronously (non-blocking)
for (const f of files.value) {
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
}
for (const f of files.value) {
if ((isPhoto(f) || isVideo(f)) && !f.isRegistered && f.file_path) loadUnregisteredThumbnail(f.file_path)
}
// Start status loading in background (non-blocking)
loadAllStatus()
}
// Load status for all registered files progressively
async function loadAllStatus() {
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid)
await Promise.all(registeredFiles.map(async (f: any) => {
try {
await loadPipelineStats(f.file_uuid, true)
await loadFileStats(f.file_uuid, true)
} catch (e) {
console.error('check progress failed:', f.file_uuid, e)
}
}))
await Promise.all(registeredFiles.map((f: any) =>
statusLimit(() => Promise.all([
loadPipelineStats(f.file_uuid, true),
loadFileStats(f.file_uuid, true)
]).catch(() => {}))
))
}
async function refreshAllStatus() {
statusText.value = 'Refreshing status...'
await refreshAllFilesStatus()
// Clear caches first
pipelineStatsCache.value = {}
await loadAllStatus()
statusText.value = `${files.value.length} files`
}
@@ -1438,17 +1485,30 @@ async function runProcessorQC(f: any) {
async function redoProcessor(proc: string) {
if (!ctxMenu.value.file?.file_uuid) return
const fileUuid = ctxMenu.value.file.file_uuid
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (!f) return
if (!confirm(t('library.confirm.rerun_processor', { processor: proc.toUpperCase() }))) return
const oldStatus = f.status
try {
f.status = 'preparing'
fileStatusCache.value[f.file_uuid] = 'preparing'
const result = await apiCall('process_file', { fileUuid, processors: [proc] })
if (result.success) {
await syncFileStatus(f.file_uuid)
alert(t('library.processor.started', { processor: proc.toUpperCase() }))
} else {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.failed', { message: result.message }))
}
} catch (e: any) {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.error', { message: e.message }))
}
}
@@ -1460,21 +1520,33 @@ async function redoProcessorFromQC(proc: string) {
return
}
const fileUuid = qcFile.value.file_uuid
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (!f) return
if (!confirm(t('library.confirm.run_processor', { processor: proc.toUpperCase(), filename: qcFile.value.title }))) return
const oldStatus = f.status
try {
f.status = 'preparing'
fileStatusCache.value[f.file_uuid] = 'preparing'
const result = await apiCall('process_file', { fileUuid, processors: [proc] })
if (result.success) {
await syncFileStatus(f.file_uuid)
alert(t('library.processor.started', { processor: proc.toUpperCase() }))
// Re-run QC to refresh status
setTimeout(() => {
runProcessorQC(qcFile.value)
}, 2000)
} else {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.failed', { message: result.message || result.error || 'Unknown error' }))
}
} catch (e: any) {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.error', { message: e.message }))
console.error('[QC] Redo failed:', e)
}
@@ -1578,6 +1650,7 @@ function statusFromDB(status: string): string {
}
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 === 'keyword_ready' || f.status === 'semantic_ready') return 'ms-badge-part'
@@ -1587,6 +1660,7 @@ function statusBadgeClass(f: any): string {
}
function statusBadgeIcon(f: any): string {
if (f.status === 'preparing') return '⏳'
if (f.status === 'registered') return '📋'
if (f.status === 'scanning') return '🔍'
if (f.status === 'keyword_ready') return '🔎'
@@ -1792,6 +1866,53 @@ async function batchRegister() {
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function batchUnregister() {
if (unregistering.value) return
if (selectedRegisteredFiles.value.length === 0) return
const filesToUnregister = selectedRegisteredFiles.value
if (!confirm(t('library.confirm.batch_unregister_message', { count: filesToUnregister.length }))) return
unregistering.value = true
actionMsg.value = `Unregistering ${filesToUnregister.length} files...`
let successCount = 0
let failCount = 0
for (const f of filesToUnregister) {
const fileUuid = f.file_uuid
if (!fileUuid) {
failCount++
continue
}
try {
const result: any = await apiCall('unregister_file', { fileUuid, deleteOutputFiles: true })
if (result.success) {
successCount++
} else {
failCount++
}
} catch (e: any) {
failCount++
console.error('[batchUnregister] Failed:', f.file_name, e)
}
}
selectedFiles.value = []
unregistering.value = false
if (successCount > 0) {
actionMsg.value = `Unregistered ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
} else {
actionMsg.value = `Failed to unregister all ${failCount} files`
}
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function quickRegister(f: any) {
if (registering.value) return
@@ -1845,97 +1966,46 @@ async function doProcess(f: any) {
if (processing.value) return
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
processing.value = true
const oldUuid = f.file_uuid
actionMsg.value = '🔄 Unregistering...'
const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid)
if (!file) {
actionMsg.value = 'File not found in cache'
processing.value = false
return
}
const oldStatus = file.status
try {
// Step 1: Unregister with deleteOutputFiles=true
const unregResult: any = await apiCall('unregister_file', { fileUuid: oldUuid, deleteOutputFiles: true })
if (!unregResult.success) {
actionMsg.value = `Unregister failed: ${unregResult.message}`
processing.value = false
setTimeout(() => { actionMsg.value = '' }, 8000)
return
}
file.status = 'preparing'
fileStatusCache.value[file.file_uuid] = 'preparing'
actionMsg.value = '🔍 Starting deep scan...'
if (unregResult.command_id) {
actionMsg.value = '🔄 Waiting for unregister...'
await pollCommandStatus(unregResult.command_id)
}
// Clear all cached state for this file
delete fileProgressCache.value[oldUuid]
delete processorCountsCache.value[oldUuid]
actionMsg.value = '⏳ Waiting for unregister to stabilize...'
await waitForStable(async () => {
await invalidateFiles()
await ensureFiles()
return !filesCache.value.some((x: any) => x.file_uuid === oldUuid && x.isRegistered)
const result = await apiCall('re_scan_file', {
fileUuid: file.file_uuid,
autoPipeline: true
})
// Step 2: Register again
actionMsg.value = '📥 Registering...'
const filePath = f.file_path || f.relative_path || f.file_name
if (!filePath) { actionMsg.value = 'Missing file path'; processing.value = false; return }
const regResult: any = await apiCall('register_file', { filePath })
if (!regResult.success) {
actionMsg.value = `Register failed: ${regResult.message}`
processing.value = false
setTimeout(() => { actionMsg.value = '' }, 8000)
return
}
let newUuid = regResult.file_uuid
if (regResult.command_id && !newUuid) {
actionMsg.value = '📥 Waiting for registration...'
const finalStatus = await pollCommandStatus(regResult.command_id)
newUuid = finalStatus.file_uuid
}
if (!newUuid) {
actionMsg.value = `Registration completed but no file_uuid`
processing.value = false
return
}
f.file_uuid = newUuid
f.isRegistered = true
actionMsg.value = '⏳ Waiting for registration to stabilize...'
await waitForStable(async () => {
await invalidateFiles()
await ensureFiles()
const found = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid)
return found && found.isRegistered && found.status === 'registered'
})
// Step 3: Process (deep scan)
actionMsg.value = '🔍 Scanning...'
const procs = selectedProcessors()
if (!procs.length) { actionMsg.value = 'Please select at least one processor'; processing.value = false; return }
// Immediately update status to show user the action started
f.status = 'scanning'
fileStatusCache.value[f.file_uuid] = 'scanning'
const procResult: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
if (procResult.success) {
actionMsg.value = `Deep scan started: ${f.file_name}`
syncFileStatus(f.file_uuid)
if (result.success) {
file.status = 'scanning'
fileStatusCache.value[file.file_uuid] = 'scanning'
actionMsg.value = '✅ Deep scan started'
await syncFileStatus(file.file_uuid)
} else {
actionMsg.value = `Scan failed: ${procResult.message}`
// Revert status on failure
f.status = 'registered'
fileStatusCache.value[f.file_uuid] = 'registered'
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Failed: ${result.message || result.error}`
}
} catch (e: any) {
actionMsg.value = `Deep scan error: ${e}`
console.error('[deepScan]', e)
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Error: ${e.message}`
console.error('[deep-scan]', e)
} finally {
processing.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 5000)
}
processing.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function waitForStable(condition: () => boolean | Promise<boolean>, timeoutMs = 15000, intervalMs = 500): Promise<boolean> {
@@ -1959,26 +2029,38 @@ async function doReprocess(f: any) {
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
reprocessing.value = true
const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid)
if (!file) {
actionMsg.value = 'File not found in cache'
reprocessing.value = false
return
}
const oldStatus = file.status
try {
actionMsg.value = '🔄 Re-scanning...'
file.status = 'preparing'
fileStatusCache.value[file.file_uuid] = 'preparing'
actionMsg.value = '🔍 Starting deep scan...'
// Use single re-scan API with auto_pipeline: true
const result = await apiCall('re_scan_file', {
fileUuid: f.file_uuid,
fileUuid: file.file_uuid,
autoPipeline: true
})
if (result.success) {
actionMsg.value = '✅ Re-scan started'
// Update status to scanning
f.status = 'scanning'
fileStatusCache.value[f.file_uuid] = 'scanning'
// Sync status from Core API
await syncFileStatus(f.file_uuid)
file.status = 'scanning'
fileStatusCache.value[file.file_uuid] = 'scanning'
actionMsg.value = '✅ Deep scan started'
await syncFileStatus(file.file_uuid)
} else {
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Failed: ${result.message || result.error}`
}
} catch (e: any) {
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Error: ${e.message}`
console.error('[re-scan]', e)
} finally {
@@ -2161,6 +2243,8 @@ function resetFilters() {
.ms-fm-section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; padding-left: 4px; display: flex; align-items: center; gap: 12px; }
.ms-batch-register-btn { margin-left: auto; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer; }
.ms-batch-register-btn:hover { background: var(--primary-hover); }
.ms-batch-unregister-btn { margin-left: auto; 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-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; }

View File

@@ -2028,11 +2028,15 @@ async function loadClusterResults() {
}
const groups = results?.face_groups || results?.clusters || []
const frames = results?.frames || []
// 統一 group_id → cluster_id
// 統一 group_id → cluster_id, 並添加 group_uuid
for (const g of groups) {
if (g.group_id !== undefined && g.cluster_id === undefined) {
g.cluster_id = g.group_id
}
// 添加 group_uuid 字段供前端使用
if (g.group_uuid === undefined) {
g.group_uuid = `group_${g.cluster_id || g.group_id}`
}
}
// Build cluster_id → faces[] mapping from frames
const clusterFacesMap: Record<string, any[]> = {}