fix: unregister timeout 200s + async mode

- Add UNREGISTER_TIMEOUT=200000 (200s) for large file unregister
- Add timeout field to buildHttpRequest return type
- httpCall now uses per-request timeout override
- unregister_file now uses asyncMode: true for better UX
- Show "Removing in background..." toast for async mode

Fixes timeout error when unregistering large files (83K faces, 20K nodes).
This commit is contained in:
2026-07-31 18:32:50 +08:00
parent 2b944cf9d1
commit 9d58fdb9d9
2 changed files with 358 additions and 174 deletions

View File

@@ -41,16 +41,6 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
}
return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) })
}
// run_identity_agent 沒有 Tauri command,直接走 HTTP 避免 invoke error
if (cmd === 'run_identity_agent') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// run_identity_for_seed 也沒有 Tauri command
if (cmd === 'run_identity_for_seed') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// run_cluster_agent 也沒有 Tauri command
if (cmd === 'run_cluster_agent') {
const data = await httpCall(cmd, args)
@@ -61,6 +51,11 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// get_similar_face_groups 也沒有 Tauri command
if (cmd === 'get_similar_face_groups') {
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
// create_pending_identity 也沒有 Tauri command
if (cmd === 'create_pending_identity') {
const data = await httpCall(cmd, args)
@@ -93,6 +88,7 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
}
const DEFAULT_TIMEOUT = 30000
const UNREGISTER_TIMEOUT = 200000 // 200s for large file unregister (was timing out at 30s)
export enum ApiErrorType {
NETWORK = 'network',
@@ -114,11 +110,11 @@ export class ApiError extends Error {
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
const base = getApiBase()
const { url, method, body } = buildHttpRequest(cmd, args)
const { url, method, body, timeout: requestTimeout } = buildHttpRequest(cmd, args)
const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${base}${url}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
const timeout = setTimeout(() => controller.abort(), requestTimeout ?? DEFAULT_TIMEOUT)
let response: Response | null = null
try {
@@ -202,7 +198,7 @@ async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Pr
}
}
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any } {
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any; timeout?: number } {
const a = args
switch (cmd) {
@@ -346,12 +342,19 @@ case 'search_keyword': {
}
case 'get_trace_profile': {
const params = new URLSearchParams()
if (a.file_uuid) params.set('file_uuid', a.file_uuid)
if (a.trace_id) params.set('trace_id', String(a.trace_id))
const fu = a.file_uuid ?? a.fileUuid
const tid = a.trace_id ?? a.traceId
if (fu) params.set('file_uuid', fu)
if (tid != null) params.set('trace_id', String(tid))
return { url: `/api/v1/trace-profile?${params.toString()}`, method: 'GET' }
}
case 'update_trace_profile': {
return { url: '/api/v1/trace-profile', method: 'PUT', body: a.body }
const body = a.body ?? {
file_uuid: a.file_uuid ?? a.fileUuid,
trace_id: a.trace_id ?? a.traceId,
...Object.fromEntries(Object.entries(a).filter(([k]) => !['fileUuid','traceId','file_uuid','trace_id','body'].includes(k)))
}
return { url: '/api/v1/trace-profile', method: 'PUT', body }
}
case 'delete_trace': {
const body: any = { hard_delete: a.hard_delete ?? true }
@@ -460,21 +463,22 @@ case 'search_keyword': {
case 'process_file': {
return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } }
}
case 'run_identity_agent': {
return { url: `/api/v1/file/${a.fileUuid}/identity-agent`, method: 'POST' }
}
case 'run_identity_for_seed': {
return { url: '/api/v1/agents/identity/run-for-seed', method: 'POST', body: { file_uuid: a.fileUuid, identity_uuid: a.identityUuid } }
}
case 'get_identity_matches': {
return { url: `/api/v1/identity-matches?file_hash=${a.fileHash}`, method: 'GET' }
}
case 'run_cluster_agent': {
return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST' }
return { url: `/api/v1/file/${a.fileUuid}/cluster-agent`, method: 'POST', body: { confirm: true } }
}
case 'get_cluster_results': {
return { url: `/api/v1/cluster-results?file_hash=${a.fileHash}`, method: 'GET' }
}
case 'get_similar_face_groups': {
const params = new URLSearchParams()
if (a.traceIds) params.set('trace_ids', String(a.traceIds))
if (a.topK) params.set('top_k', String(a.topK))
if (a.minScore) params.set('min_score', String(a.minScore))
return { url: `/api/v1/file/${a.fileUuid}/face-groups/similar?${params.toString()}`, method: 'GET' }
}
case 'update_trace_profile_group': {
return { url: '/api/v1/trace-profile/group', method: 'PUT', body: { file_uuid: a.fileUuid, trace_ids: a.traceIds, name: a.name, key_frame: a.keyFrame, key_face: a.keyFace } }
}
@@ -496,7 +500,7 @@ case 'search_keyword': {
case 'unregister_file': {
const body: any = { file_uuid: a.fileUuid }
if (a.deleteOutputFiles != null) body.delete_output_files = a.deleteOutputFiles
return { url: '/api/v1/unregister', method: 'POST', body }
return { url: '/api/v1/unregister', method: 'POST', body, timeout: UNREGISTER_TIMEOUT }
}
case 'ingest_file': {
return { url: `/api/v1/file/${a.fileUuid}/checkin`, method: 'POST' }
@@ -611,7 +615,7 @@ export function transformResponse(cmd: string, data: any): any {
isRegistered: f.isRegistered ?? f.is_registered ?? false,
status: f.status || '',
registrationTime: f.registration_time || null,
ingested: f.ingested ?? (f.status === 'completed'),
ingested: f.ingested ?? ['completed','keyword_ready','semantic_ready','face_mgmt_ready','agent_ready'].includes(f.status),
}))
}
case 'get_people': {
@@ -705,19 +709,19 @@ case 'get_unassigned_traces': {
const asrLangProb = asr?.language_probability || 0
let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing'
let asrMessage = '處理中'
let asrMessage = 'Processing'
if (asrSegments.length === 0) {
if (asrLang === '' && asrLangProb === 0) {
asrStatus = 'no_audio_track'
asrMessage = '無音軌'
asrMessage = 'No Audio Track'
} else {
asrStatus = 'silent_audio'
asrMessage = asrLang ? `無語音 (${asrLang})` : '無語音'
asrMessage = asrLang ? `No Speech (${asrLang})` : 'No Speech'
}
} else {
asrStatus = 'has_transcript'
asrMessage = `${asrSegments.length} 段語音 (${asrLang || 'unknown'})`
asrMessage = `${asrSegments.length} segments (${asrLang || 'unknown'})`
}
return {
@@ -797,13 +801,6 @@ case 'get_unassigned_traces': {
message: data.message ?? '',
}
}
case 'run_identity_agent': {
return {
success: data.success ?? false,
file_uuid: data.file_uuid ?? '',
message: data.message ?? '',
}
}
case 'run_cluster_agent': {
return {
success: data.success ?? false,
@@ -816,6 +813,9 @@ case 'get_unassigned_traces': {
case 'get_cluster_results': {
return data || null
}
case 'get_similar_face_groups': {
return data || null
}
case 'get_trace_profile': { return data || null }
case 'update_trace_profile': { return data || { success: true } }
case 'update_trace_profile_group': { return data || { success: true } }

View File

@@ -1,10 +1,10 @@
<template>
<div id="ms-files-page">
<section class="ms-fm-panel">
<div class="ms-ppl-control-area">
<div class="ms-fm-toolbar">
<div class="ms-fm-toolbar-left">
<button class="ms-fm-icon-btn" type="button" title="Refresh" @click="loadFiles">⟳</button>
<button class="ms-fm-icon-btn" type="button" title="Refresh Status" @click="refreshAllStatus">⟡</button>
<label class="ms-fm-radio-label">
<input type="radio" name="ms-display-filter" value="all" v-model="displayFilter"> All
</label>
@@ -14,13 +14,18 @@
<label class="ms-fm-radio-label">
<input type="radio" name="ms-display-filter" value="photo" v-model="displayFilter"> Photos
</label>
<span class="ms-fm-toggle-sep">|</span>
<button class="ms-ppl-section-toggle-btn" :class="{ active: showRegisteredSection }" @click="showRegisteredSection = !showRegisteredSection">
Registered {{ registeredCount }} <span class="ms-ppl-toggle-dot" :class="{ on: showRegisteredSection }"></span>
</button>
<button class="ms-ppl-section-toggle-btn" :class="{ active: showUnregisteredSection }" @click="showUnregisteredSection = !showUnregisteredSection">
Unregistered {{ unregisteredCount }} <span class="ms-ppl-toggle-dot" :class="{ on: showUnregisteredSection }"></span>
</button>
</div>
<div class="ms-fm-toolbar-right">
<div class="ms-fm-search-wrap">
<input type="text" class="ms-fm-search" v-model="filterText" placeholder="Search files / docs / videos">
</div>
<button class="ms-fm-icon-btn" type="button" title="Toggle Dark Mode" @click="toggleDark()">{{ isDark ? '☀' : '☾' }}</button>
<button class="ms-fm-icon-btn" type="button" :title="isAdvanced ? 'Advanced Mode: ON' : 'Advanced Mode: OFF'" @click="toggleAdvanced()" :class="{ 'ms-advanced-active': isAdvanced }">{{ isAdvanced ? '🔓' : '🔒' }}</button>
<button class="ms-fm-icon-btn" type="button" @click.stop="showFilter = !showFilter">☰</button>
</div>
</div>
@@ -40,8 +45,8 @@
<div class="ms-fm-sort-title">Filter By</div>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterUnregistered"> Unregistered</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterRegistered"> Registered</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterPending"> Pending</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterCompleted"> Completed</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterScanning"> Scanning</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterReady"> Ready</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyVideos"> Videos Only</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyPhotos"> Photos Only</label>
</div>
@@ -64,18 +69,20 @@
<button type="button" class="ms-fm-btn" style="width:100%;margin-top:8px;" @click="resetFilters">Clear Filters</button>
</div>
<div class="ms-fm-status">{{ statusText }}<span v-if="actionMsg" class="ms-fm-action-msg">{{ actionMsg }}</span></div>
<div class="ms-fm-axis-bar">
<span class="ms-fm-axis-item">📋 Registered <strong>{{ registeredCount }}</strong> · Unregistered <strong>{{ unregisteredCount }}</strong></span>
<span class="ms-fm-axis-sep">|</span>
<span class="ms-fm-axis-item">⚙️ Pending <strong>{{ pendingCount }}</strong> · Scanning <strong>{{ processingCount }}</strong> · Completed <strong>{{ completedCount }}</strong></span>
<div class="ms-fm-status">{{ statusText }}<span v-if="actionMsg" class="ms-fm-action-msg">{{ actionMsg }}</span>
<span class="ms-fm-axis-sep">·</span>
<span class="ms-fm-axis-item">🔍 Scanning <strong>{{ scanningCount }}</strong></span>
<span class="ms-fm-axis-item">· ✅ Completed <strong>{{ completedCount }}</strong></span>
</div>
</div>
<div class="ms-ppl-scroll-area">
<!-- Registered Section -->
<div v-if="showRegisteredSection" class="ms-fm-section">
<div class="ms-fm-section-title">📋 Registered ({{ registeredFiles.length }})</div>
<div class="ms-fm-grid">
<div v-for="f in registeredFiles" :key="f.file_uuid" class="ms-fm-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)">
<div v-for="f in registeredFiles" :key="f.file_uuid" class="ms-fm-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(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">
<div class="ms-fm-badge">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
@@ -98,17 +105,17 @@
<div class="ms-fm-badge-row" :class="statusBadgeClass(f)">{{ statusBadgeIcon(f) }} {{ statusBadgeLabel(f) }}</div>
</div>
</div>
<div v-if="f.status === 'processing' && getPipelineStats(f.file_uuid) && getPipelineStats(f.file_uuid).overall_progress < 1.0" class="ms-fm-progress">
<div v-if="f.status === 'scanning' && getPipelineStats(f.file_uuid) && getPipelineStats(f.file_uuid).overall_progress < 1.0" class="ms-fm-progress">
<div class="ms-fm-progress-bar" :style="{ width: (getPipelineStats(f.file_uuid).overall_progress * 100) + '%' }"></div>
<div class="ms-fm-progress-text">{{ Math.round(getPipelineStats(f.file_uuid).overall_progress * 100) }}%</div>
</div>
<div v-else-if="f.status === 'processing' && getProgress(f.file_uuid) && getProgress(f.file_uuid).overall_progress < 100" class="ms-fm-progress">
<div v-else-if="f.status === 'scanning' && getProgress(f.file_uuid) && getProgress(f.file_uuid).overall_progress < 100" class="ms-fm-progress">
<div class="ms-fm-progress-bar" :style="{ width: getProgress(f.file_uuid)?.overall_progress + '%' }"></div>
<div class="ms-fm-progress-text">{{ getProgress(f.file_uuid)?.overall_progress || 0 }}%</div>
</div>
<div v-else-if="f.status === 'processing'" class="ms-fm-progress">
<div class="ms-fm-progress-bar" style="width: 100%"></div>
<div class="ms-fm-progress-text">Scanning...</div>
<div v-else-if="f.status === 'scanning'" class="ms-fm-progress">
<div class="ms-fm-progress-bar indeterminate"></div>
<div class="ms-fm-progress-text">⟳</div>
</div>
<div class="ms-fm-meta">
<div class="ms-fm-name">{{ f.file_name }}</div>
@@ -122,7 +129,7 @@
<div v-if="unregisteredFiles.length && showUnregisteredSection" class="ms-fm-section">
<div class="ms-fm-section-title">📦 Unregistered ({{ unregisteredFiles.length }})</div>
<div class="ms-fm-grid">
<div v-for="f in unregisteredFiles" :key="f.file_path" class="ms-fm-card ms-fm-card-unregistered" :class="{ 'is-selected': selectedFiles.includes(f.file_path) }" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)">
<div v-for="f in unregisteredFiles" :key="f.file_path" class="ms-fm-card ms-fm-card-unregistered" :class="{ 'is-selected': selectedFiles.includes(f.file_path) }" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)" @touchstart="onCardTouchStart($event, f)" @touchend="onCardTouchEnd" @touchmove="onCardTouchEnd">
<div class="ms-fm-thumb-wrap">
<div class="ms-fm-badge">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
@@ -157,6 +164,7 @@
<div class="ms-fm-empty-icon">📁</div>
<div class="ms-fm-empty-text">No files found</div>
</div>
</div>
</section>
<!-- Context Menu -->
@@ -164,8 +172,8 @@
<div class="ms-ctx-header">
<div class="ms-ctx-filename">{{ ctxMenuFile?.file_name }}</div>
<div class="ms-ctx-info">{{ formatSize(ctxMenuFile?.file_size || 0) }} · {{ formatDate(ctxMenuFile?.modified_time || '') }}</div>
<div class="ms-ctx-info">Status: <span :class="ctxMenuFile?.status === 'completed' ? 'ms-ctx-done' : ctxMenuFile?.status === 'pending' ? 'ms-ctx-pending' : 'ms-ctx-none'">{{ statusLabel(ctxMenuFile) }}</span></div>
<div v-if="ctxMenuFile?.isRegistered && ctxMenuFile?.file_uuid" class="ms-ctx-info">UUID:{{ ctxMenuFile.file_uuid }}</div>
<div class="ms-ctx-info">Status: <span :class="ctxMenuFile?.status === 'completed' ? 'ms-ctx-done' : ctxMenuFile?.status === 'registered' ? 'ms-ctx-pending' : 'ms-ctx-none'">{{ statusLabel(ctxMenuFile) }}</span></div>
<div v-if="ctxMenuFile?.isRegistered && ctxMenuFile?.file_uuid" class="ms-ctx-info">{{ $t('library.qc.uuid_label') }} {{ ctxMenuFile.file_uuid }}</div>
</div>
<hr class="ms-ctx-divider">
<button v-if="!ctxMenuFile?.isRegistered" class="ms-ctx-item" @click="ctxRegister(ctxMenuFile)">📥 Register File</button>
@@ -221,7 +229,7 @@
<button class="ms-proc-redo" @click.stop="redoProcessor('cut')" title="Re-run CUT">🔄</button>
</div>
</div>
<div v-if="ctxMenuFile?.file_uuid && ctxMenuFile?.status === 'processing'" class="ms-ctx-progress">
<div v-if="ctxMenuFile?.file_uuid && ctxMenuFile?.status === 'scanning'" class="ms-ctx-progress">
<div v-if="statsLoading" class="ms-ctx-loading">
<div class="ms-ctx-spinner"></div>
<span class="ms-ctx-loading-text">Loading...</span>
@@ -301,25 +309,6 @@
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenuFile.file_uuid).identity_agent && getPipelineStats(ctxMenuFile.file_uuid)?.overall_progress >= 1.0">
<div class="ms-ctx-phase-title">Identity Agent</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Identities Created</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).identity_agent.identities_created }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">TMDB Matches</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).identity_agent.tmdb_matches }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Speaker Bindings</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).identity_agent.speaker_bindings }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Confirmations</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).identity_agent.confirmations }}</span>
</div>
</div>
</div>
</template>
</div>
@@ -400,7 +389,7 @@
</div>
<div class="ms-qc-job-outputs" v-if="job.outputs?.length">
<span v-for="out in job.outputs" :key="out.name" class="ms-qc-job-output">
{{ out.name }} <span class="ms-qc-job-count">{{ out.count }}筆</span> <span class="ms-qc-job-size">({{ formatSize(out.size) }})</span>
{{ out.name }} <span class="ms-qc-job-count">{{ $t('library.qc.items', { count: out.count }) }}</span> <span class="ms-qc-job-size">({{ formatSize(out.size) }})</span>
</span>
</div>
</div>
@@ -412,27 +401,27 @@
<div class="ms-qc-audio-info">
<div class="ms-qc-audio-header">
<span class="ms-qc-audio-icon">🎵</span>
<span class="ms-qc-audio-title">音軌資訊</span>
<span class="ms-qc-audio-title">{{ $t('library.qc.audio_info') }}</span>
</div>
<div class="ms-qc-audio-grid">
<div class="ms-qc-audio-item">
<span class="ms-qc-audio-label">音軌</span>
<span class="ms-qc-audio-label">{{ $t('library.qc.audio_track') }}</span>
<span class="ms-qc-audio-value" :class="qcAudioInfo.hasAudio ? 'ms-qc-audio-ok' : 'ms-qc-audio-none'">
{{ qcAudioInfo.hasAudio ? '✓ 有音軌' : '✗ 無音軌' }}
{{ qcAudioInfo.hasAudio ? $t('library.qc.has_audio') : $t('library.qc.no_audio') }}
</span>
</div>
<div class="ms-qc-audio-item" v-if="qcAudioInfo.hasAudio">
<span class="ms-qc-audio-label">語音</span>
<span class="ms-qc-audio-label">{{ $t('library.qc.speech') }}</span>
<span class="ms-qc-audio-value" :class="qcAudioInfo.hasSpeech ? 'ms-qc-audio-ok' : 'ms-qc-audio-warn'">
{{ qcAudioInfo.hasSpeech ? '✓ 有語音' : '🔇 無語音' }}
{{ qcAudioInfo.hasSpeech ? $t('library.qc.has_speech') : $t('library.qc.no_speech') }}
</span>
</div>
<div class="ms-qc-audio-item" v-if="qcAudioInfo.language">
<span class="ms-qc-audio-label">語言</span>
<span class="ms-qc-audio-label">{{ $t('library.qc.language') }}</span>
<span class="ms-qc-audio-value">{{ qcAudioInfo.language }} ({{ Math.round((qcAudioInfo.languageProb || 0) * 100) }}%)</span>
</div>
<div class="ms-qc-audio-item" v-if="qcAudioInfo.sampleRate">
<span class="ms-qc-audio-label">取樣率</span>
<span class="ms-qc-audio-label">{{ $t('library.qc.sample_rate') }}</span>
<span class="ms-qc-audio-value">{{ qcAudioInfo.sampleRate }} Hz</span>
</div>
</div>
@@ -444,7 +433,7 @@
<div class="ms-qc-track">
<div class="ms-qc-track-header">
<span class="ms-qc-track-icon">⚙️</span>
<span class="ms-qc-track-title">路徑一:處理器輸出</span>
<span class="ms-qc-track-title">{{ $t('library.qc.track_processor_output') }}</span>
</div>
<div class="ms-qc-flow">
@@ -455,7 +444,7 @@
<div v-for="r in qcResults.filter(p => ['CUT','ASR','FACE','OCR','POSE'].includes(p.processor))" :key="r.processor" class="ms-qc-node" :class="getProcNodeClass(r)">
<span class="ms-qc-icon">{{ getProcIcon(r) }}</span>
<span class="ms-qc-proc">{{ r.processor }}</span>
<span class="ms-qc-count">{{ r.size }}筆</span>
<span class="ms-qc-count">{{ $t('library.qc.items', { count: r.size }) }}</span>
<span class="ms-qc-status-label" v-if="r.processor === 'ASR' && getAsrStatusLabel(r)">{{ getAsrStatusLabel(r) }}</span>
<button class="ms-qc-redo" @click="redoProcessorFromQC(r.processor.toLowerCase())">🔄</button>
</div>
@@ -466,7 +455,7 @@
<div v-for="r in qcResults.filter(p => ['ASRX','FACE_CLUSTER','APPEARANCE'].includes(p.processor))" :key="r.processor" class="ms-qc-node" :class="r.exists ? 'ms-qc-ok' : 'ms-qc-missing'">
<span class="ms-qc-icon">{{ r.exists ? '✓' : '✗' }}</span>
<span class="ms-qc-proc">{{ r.processor }}</span>
<span class="ms-qc-count">{{ r.size || 0 }}筆</span>
<span class="ms-qc-count">{{ $t('library.qc.items', { count: r.size || 0 }) }}</span>
<button class="ms-qc-redo" @click="redoProcessorFromQC(r.processor.toLowerCase())">🔄</button>
</div>
</div>
@@ -482,7 +471,7 @@
<div class="ms-qc-track">
<div class="ms-qc-track-header">
<span class="ms-qc-track-icon">📥</span>
<span class="ms-qc-track-title">路徑二:資料入庫</span>
<span class="ms-qc-track-title">{{ $t('library.qc.track_ingestion') }}</span>
</div>
<div class="ms-qc-flow">
@@ -539,11 +528,11 @@
<div class="ms-qc-track">
<div class="ms-qc-track-header">
<span class="ms-qc-track-icon">✅</span>
<span class="ms-qc-track-title">終點:狀態改變</span>
<span class="ms-qc-track-title">{{ $t('library.qc.track_status_change') }}</span>
</div>
<div class="ms-qc-flow">
<div class="ms-qc-node ms-qc-input">📥 輸出 + 入庫 完成</div>
<div class="ms-qc-node ms-qc-input">{{ $t('library.qc.output_ingestion_done') }}</div>
<div class="ms-qc-arrow">↓</div>
<div class="ms-qc-status-change">
@@ -559,6 +548,9 @@
</div>
</div>
</div>
<div class="ms-qc-footer">
<button class="ms-fm-btn ms-fm-btn-sm" @click="copyLibraryQCReport">{{ libQcCopied ? '✅ Copied!' : '📋 Copy Report' }}</button>
</div>
</div>
</div>
</div>
@@ -571,14 +563,13 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { apiCall } from '@/api'
import { useI18n } from 'vue-i18n'
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, loadUnregisteredThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, pollingProgressSet, getFileProcessingStatus, ProcessingStatus, ProcessingLevel, loadPipelineStats, getPipelineStats, PipelineStats, loadFileStats, getFileStats, FileStats, pollPipelineProgress, startStatsAutoRefresh, stopStatsAutoRefresh, startProcessingFilesPolling, stopProcessingFilesPolling, startFilesWatch, stopFilesWatch, JsonProcessorInfo, pipelineStatsCache, clearFileStatusCache } from '@/store'
import { isDark, toggleDark } from '@/composables/useTheme'
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, loadUnregisteredThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, pollingProgressSet, getFileProcessingStatus, ProcessingStatus, ProcessingLevel, loadPipelineStats, getPipelineStats, PipelineStats, loadFileStats, getFileStats, FileStats, startStatsAutoRefresh, stopStatsAutoRefresh, startProcessingFilesPolling, stopProcessingFilesPolling, startFilesWatch, stopFilesWatch, JsonProcessorInfo, pipelineStatsCache, clearFileStatusCache, syncFileStatus } from '@/store'
import { getAsrStatus } from '@/utils/asrStatus'
import { useAdvancedMode } from '@/stores/advancedMode'
import VideoPlayer from '../components/VideoPlayer.vue'
const { t } = useI18n()
const { isAdvanced, toggleAdvanced } = useAdvancedMode()
const { isAdvanced } = useAdvancedMode()
const files = computed(() => filesCache.value)
const loading = computed(() => !filesLoaded.value)
@@ -615,8 +606,8 @@ const unregThumbKey = (path: string) => `unreg:${path}:30`
const sortBy = ref('time_desc')
const filterUnregistered = ref(false)
const filterRegistered = ref(true)
const filterPending = ref(false)
const filterCompleted = ref(false)
const filterScanning = ref(false)
const filterReady = ref(false)
const onlyVideos = ref(false)
const onlyPhotos = ref(false)
const sizeMin = ref<number | null>(null)
@@ -633,8 +624,8 @@ const registeredFiles = computed(() => {
const f = filterText.value.toLowerCase()
result = result.filter((file: any) => file.file_name.toLowerCase().includes(f))
}
if (filterPending.value) result = result.filter((f: any) => !f.status)
if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed')
if (filterScanning.value) result = result.filter((f: any) => f.status === 'scanning')
if (filterReady.value) result = result.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status))
if (onlyVideos.value) result = result.filter(isVideo)
if (onlyPhotos.value) result = result.filter(isPhoto)
if (sizeMin.value) result = result.filter((f: any) => (f.file_size || 0) >= sizeMin.value! * 1048576)
@@ -681,18 +672,8 @@ const unregisteredFiles = computed(() => {
return result
})
const showRegisteredSection = computed(() => {
if (filterRegistered.value && !filterUnregistered.value) return true
if (filterRegistered.value && filterUnregistered.value) return true
if (!filterRegistered.value && !filterUnregistered.value) return true // default: show registered
return false
})
const showUnregisteredSection = computed(() => {
if (filterUnregistered.value && !filterRegistered.value) return true
if (filterRegistered.value && filterUnregistered.value) return true
return false
})
const showRegisteredSection = ref(true)
const showUnregisteredSection = ref(true)
const sortedFilteredFiles = computed(() => {
let result = [...files.value]
@@ -710,8 +691,8 @@ const sortedFilteredFiles = computed(() => {
// Checkbox filters
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered)
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.isRegistered)
if (filterPending.value) result = result.filter((f: any) => f.isRegistered && !f.status)
if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed')
if (filterScanning.value) result = result.filter((f: any) => f.status === 'scanning')
if (filterReady.value) result = result.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status))
if (onlyVideos.value) result = result.filter(isVideo)
if (onlyPhotos.value) result = result.filter(isPhoto)
@@ -749,8 +730,8 @@ const sortedFilteredFiles = computed(() => {
const registeredCount = computed(() => files.value.filter((f: any) => f.isRegistered).length)
const unregisteredCount = computed(() => files.value.filter((f: any) => !f.isRegistered).length)
const pendingCount = computed(() => files.value.filter((f: any) => f.isRegistered && !f.status).length)
const processingCount = computed(() => files.value.filter((f: any) => f.status === 'processing').length)
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 completedCount = computed(() => files.value.filter((f: any) => f.status === 'completed').length)
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh(); startProcessingFilesPolling(); startFilesWatch() })
@@ -776,24 +757,14 @@ async function loadFiles() {
if ((isPhoto(f) || isVideo(f)) && !f.isRegistered && f.file_path) loadUnregisteredThumbnail(f.file_path)
}
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid)
for (const f of registeredFiles) {
await Promise.all(registeredFiles.map(async (f: any) => {
try {
const pipeline = await loadPipelineStats(f.file_uuid, true)
if (pipeline && pipeline.overall_progress < 1.0 && pipeline.stages?.some((s: any) => s.status === 'running')) {
f.status = 'processing'
pollPipelineProgress(f.file_uuid)
} else if (pipeline && pipeline.overall_progress >= 1.0) {
f.status = 'completed'
} else if (!pipeline && f.status === 'processing') {
pollPipelineProgress(f.file_uuid)
}
await loadPipelineStats(f.file_uuid, true)
await loadFileStats(f.file_uuid, true)
} catch (e) {
console.error('check progress failed:', f.file_uuid, e)
}
}
}))
}
async function refreshAllStatus() {
@@ -841,8 +812,19 @@ function toggleSelect(f: any) {
else selectedFiles.value.push(f.file_uuid)
}
function playVideo(f: any) {
currentVideo.value = { fileUuid: f.file_uuid, startFrame: 0, endFrame: 99999, title: f.file_name }
async function playVideo(f: any) {
let startFrame = 0
let endFrame = 99999
try {
const info: any = await apiCall('get_file_info', { uuid: f.file_uuid })
if (info?.fps && info?.duration) {
endFrame = Math.round(info.fps * info.duration)
} else if (info?.metadata?.streams) {
const videoStream = info.metadata.streams.find((s: any) => s.codec_type === 'video')
if (videoStream?.nb_frames) endFrame = parseInt(videoStream.nb_frames)
}
} catch { /* fallback to defaults */ }
currentVideo.value = { fileUuid: f.file_uuid, startFrame, endFrame, title: f.file_name }
playing.value = true
}
@@ -994,8 +976,8 @@ function getProcIcon(r: any): string {
function getAsrStatusLabel(r: any): string {
if (r.processor !== 'ASR' || !r.exists) return ''
const status = r.asrStatus
if (status === 'no_audio_track') return '無音軌'
if (status === 'silent_audio') return '無語音'
if (status === 'no_audio_track') return t('library.qc.no_audio')
if (status === 'silent_audio') return t('library.qc.no_speech')
return ''
}
@@ -1006,6 +988,7 @@ const qcAudioInfo = ref<{hasAudio: boolean, hasSpeech: boolean, language?: strin
const systemStatus = ref<{worker_running: boolean, watcher_running: boolean}>({worker_running: false, watcher_running: false})
const serviceStatus = ref<{llm: boolean, vlm: boolean, embedding: boolean, core3002: boolean, proxy8888: boolean, vite5173: boolean}>({llm: false, vlm: false, embedding: false, core3002: false, proxy8888: false, vite5173: false})
const jobsList = ref<{id: number, uuid: string, status: string, current_processor?: string, file_name?: string, progress_current?: number, progress_total?: number, outputs?: {name: string, size: number, count: number, mtime: number}[]}[]>([])
const libQcCopied = ref(false)
function getJsonCount(data: any, proc: string): number {
if (!data) return 0
@@ -1179,43 +1162,125 @@ async function redoProcessorFromQC(proc: string) {
}
}
function copyToClipboard(text: string) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '0'
ta.style.top = '0'
ta.style.width = '0'
ta.style.height = '0'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.focus()
ta.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(ta)
if (!ok) window.prompt('Copy (Ctrl+C, Enter):', text)
}
function copyLibraryQCReport() {
const f = qcFile.value
if (!f) return
const lines: string[] = []
lines.push('TYPE: PIPELINE_QC')
lines.push(`FILE: ${f.file_name || ''}`)
lines.push(`UUID: ${f.file_uuid || ''}`)
lines.push(`STATUS: ${f.status || 'unknown'}`)
const svc = serviceStatus.value
lines.push(`SRV_3002: ${svc.core3002 ? 'up' : 'down'}`)
lines.push(`SRV_8888: ${svc.proxy8888 ? 'up' : 'down'}`)
lines.push(`SRV_5173: ${svc.vite5173 ? 'up' : 'down'}`)
lines.push(`SRV_WORKER: ${systemStatus.value.worker_running ? 'running' : 'stopped'}`)
lines.push(`SRV_LLM: ${svc.llm ? 'up' : 'down'}`)
lines.push(`SRV_VLM: ${svc.vlm ? 'up' : 'down'}`)
lines.push(`SRV_EMBEDDING: ${svc.embedding ? 'up' : 'down'}`)
const jobs = jobsList.value
lines.push(`JOB_COUNT: ${jobs.length}`)
for (let i = 0; i < Math.min(jobs.length, 10); i++) {
const j = jobs[i]
const parts = [`id=${j.id}`, `status=${j.status}`]
if (j.current_processor) parts.push(`processor=${j.current_processor}`)
if (j.progress_total && j.progress_total > 0) parts.push(`progress=${j.progress_current}/${j.progress_total}`)
lines.push(`JOB_${i}: ${parts.join('|')}`)
}
for (const r of qcResults.value) {
const p = r.processor
const parts = [`count=${r.size || 0}`, `ok=${r.exists ? 'yes' : 'no'}`]
if (p === 'ASR' && r.asrStatus) {
parts.push(`speech=${qcAudioInfo.value.hasSpeech ? 'yes' : 'no'}`)
if (qcAudioInfo.value.language) parts.push(`lang=${qcAudioInfo.value.language}`)
if (qcAudioInfo.value.languageProb) parts.push(`confidence=${qcAudioInfo.value.languageProb.toFixed(2)}`)
}
lines.push(`OUT_${p}: ${parts.join('|')}`)
}
lines.push(`AUDIO: ${qcAudioInfo.value.hasAudio ? 'yes' : 'no'}`)
if (qcAudioInfo.value.hasAudio && qcAudioInfo.value.hasSpeech) {
const parts = [`lang=${qcAudioInfo.value.language || ''}`]
if (qcAudioInfo.value.languageProb) parts.push(`confidence=${qcAudioInfo.value.languageProb.toFixed(2)}`)
lines.push(`SPEECH: ${parts.join('|')}`)
}
const stats = getFileStats(f.file_uuid)
if (stats) {
if (stats.postgresql) {
lines.push(`INGEST_PG_SENTENCE: ${stats.postgresql.sentence_chunks || 0}`)
lines.push(`INGEST_PG_RELATIONSHIP: ${stats.postgresql.relationship_chunks || 0}`)
}
if (stats.qdrant) {
lines.push(`INGEST_QDRANT_FACES: ${stats.qdrant.faces || 0}`)
lines.push(`INGEST_QDRANT_TEXT: ${stats.qdrant.text_chunks || 0}`)
lines.push(`INGEST_QDRANT_SPEAKERS: ${stats.qdrant.speakers || 0}`)
}
if (stats.tkg) {
lines.push(`INGEST_TKG_NODES: ${stats.tkg.nodes?.character || 0}`)
lines.push(`INGEST_TKG_EDGES: ${stats.tkg.edges?.appear_in || 0}`)
}
}
copyToClipboard(lines.join('\n'))
libQcCopied.value = true
setTimeout(() => libQcCopied.value = false, 2000)
}
function statusLabel(f: any): string {
if (!f) return ''
return statusFromDB(f.status)
}
function statusFromDB(status: string): string {
if (status === 'completed') return 'Completed'
if (status === 'processing') return 'Scanning'
if (status === 'failed') return 'Failed'
if (status === 'pending') return 'Pending'
if (status === 'registered') return 'Registered'
return status || 'Unknown'
const key = `library.status.${status}`
const translated = t(key)
return translated !== key ? translated : (status || '—')
}
function statusBadgeClass(f: any): string {
if (f.status === 'processing') return 'ms-badge-proc'
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'
if (f.status === 'face_mgmt_ready') return 'ms-badge-prog'
if (f.status === 'completed') return 'ms-badge-done'
if (f.status === 'failed') return 'ms-badge-fail'
if (f.status === 'pending') return 'ms-badge-warn'
return 'ms-badge-off'
}
function statusBadgeIcon(f: any): string {
if (f.status === 'processing') return '⏳'
if (f.status === 'registered') return '📋'
if (f.status === 'scanning') return '🔍'
if (f.status === 'keyword_ready') return '🔎'
if (f.status === 'semantic_ready') return '🧠'
if (f.status === 'face_mgmt_ready') return '🎭'
if (f.status === 'completed') return '✅'
if (f.status === 'failed') return '❌'
if (f.status === 'pending') return '⏸️'
return '⏺️'
}
function statusBadgeLabel(f: any): string {
if (!f) return ''
if (f.status === 'completed') return 'Completed'
if (f.status === 'processing') return 'Scanning'
if (f.status === 'failed') return 'Failed'
if (f.status === 'pending') return 'Pending'
return '—'
return statusFromDB(f.status)
}
function ingestedBadgeClass(f: any): string {
@@ -1236,6 +1301,24 @@ function ingestedBadgeLabel(f: any): string {
return '—'
}
let longPressTimer: ReturnType<typeof setTimeout> | null = null
function onCardTouchStart(e: TouchEvent, f: any) {
if (longPressTimer) clearTimeout(longPressTimer)
longPressTimer = setTimeout(() => {
longPressTimer = null
const touch = e.touches[0]
openContextMenu({ clientX: touch.clientX, clientY: touch.clientY, preventDefault: () => {} } as any, f)
}, 500)
}
function onCardTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
async function openContextMenu(e: MouseEvent, f: any) {
e.preventDefault()
e.stopPropagation()
@@ -1262,14 +1345,6 @@ async function openContextMenu(e: MouseEvent, f: any) {
loadFileStats(f.file_uuid, true),
loadProcessorCounts(f.file_uuid, true)
])
const pipeline = getPipelineStats(f.file_uuid)
if (pipeline && pipeline.overall_progress < 1.0 && pipeline.stages?.some((s: any) => s.status === 'running')) {
f.status = 'processing'
} else if (pipeline && pipeline.overall_progress >= 1.0) {
f.status = 'completed'
} else if (!pipeline && f.status === 'processing') {
f.status = 'registered'
}
ctxMenu.value.file = { ...f }
} catch (e) {
console.error('Failed to load stats:', e)
@@ -1321,8 +1396,7 @@ async function ctxProcess(f: any) {
const result: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
if (result.success) {
actionMsg.value = `Scanning started: ${f.file_name}`
f.status = 'processing'
pollPipelineProgress(f.file_uuid)
syncFileStatus(f.file_uuid)
} else {
actionMsg.value = `Scan failed: ${result.message}`
}
@@ -1333,15 +1407,24 @@ async function ctxProcess(f: any) {
processing.value = false
}
async function ctxReprocess(f: any) {
closeContextMenu()
async function waitForStable(condition: () => boolean | Promise<boolean>, timeoutMs = 15000, intervalMs = 500): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await condition()) return true
await new Promise(r => setTimeout(r, intervalMs))
}
return false
}
async function cardRescan(f: any) {
if (!confirm(`Re-scan "${f.file_name}"?\n\nThis will unregister and re-process the file.`)) return
if (reprocessing.value) return
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
reprocessing.value = true
const oldUuid = f.file_uuid
actionMsg.value = '🔄 Unregistering...'
try {
const unregResult: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true })
const unregResult: any = await apiCall('unregister_file', { fileUuid: oldUuid, deleteOutputFiles: true })
if (!unregResult.success) {
actionMsg.value = `Unregister failed: ${unregResult.message}`
reprocessing.value = false
@@ -1349,9 +1432,15 @@ async function ctxReprocess(f: any) {
return
}
// Clear all cached state for this file
delete fileProgressCache.value[f.file_uuid]
delete processorCountsCache.value[f.file_uuid]
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)
})
actionMsg.value = '📥 Registering...'
const filePath = f.file_path || f.relative_path || f.file_name
@@ -1365,7 +1454,14 @@ async function ctxReprocess(f: any) {
}
f.file_uuid = regResult.file_uuid
f.isRegistered = true
f.status = 'registered'
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'
})
actionMsg.value = '🔍 Scanning...'
const procs = selectedProcessors()
@@ -1373,8 +1469,75 @@ async function ctxReprocess(f: any) {
const procResult: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
if (procResult.success) {
actionMsg.value = `Re-scan started: ${f.file_name}`
f.status = 'processing'
pollPipelineProgress(f.file_uuid)
syncFileStatus(f.file_uuid)
} else {
actionMsg.value = `Scan failed: ${procResult.message}`
}
} catch (e: any) {
actionMsg.value = `Reprocess error: ${e}`
console.error('[rescan]', e)
}
reprocessing.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function ctxReprocess(f: any) {
closeContextMenu()
if (!confirm(`Re-scan "${f.file_name}"?\n\nThis will unregister and re-process the file.`)) return
if (reprocessing.value) return
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
reprocessing.value = true
const oldUuid = f.file_uuid
actionMsg.value = '🔄 Unregistering...'
try {
const unregResult: any = await apiCall('unregister_file', { fileUuid: oldUuid, deleteOutputFiles: true })
if (!unregResult.success) {
actionMsg.value = `Unregister failed: ${unregResult.message}`
reprocessing.value = false
setTimeout(() => { actionMsg.value = '' }, 8000)
return
}
// 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)
})
actionMsg.value = '📥 Registering...'
const filePath = f.file_path || f.relative_path || f.file_name
if (!filePath) { actionMsg.value = 'Missing file path'; reprocessing.value = false; return }
const regResult: any = await apiCall('register_file', { filePath })
if (!regResult.success) {
actionMsg.value = `Register failed: ${regResult.message}`
reprocessing.value = false
setTimeout(() => { actionMsg.value = '' }, 8000)
return
}
f.file_uuid = regResult.file_uuid
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'
})
actionMsg.value = '🔍 Scanning...'
const procs = selectedProcessors()
if (!procs.length) { actionMsg.value = 'Please select at least one processor'; reprocessing.value = false; return }
const procResult: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
if (procResult.success) {
actionMsg.value = `Re-scan started: ${f.file_name}`
syncFileStatus(f.file_uuid)
} else {
actionMsg.value = `Scan failed: ${procResult.message}`
}
@@ -1395,9 +1558,11 @@ async function ctxUnregister(f: any) {
unregistering.value = true
actionMsg.value = 'Removing...'
try {
const result: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true })
const result: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true, asyncMode: true })
if (result.success) {
actionMsg.value = `Removed: ${f.file_name}`
actionMsg.value = result.job_id
? `Removing in background...`
: `Removed: ${f.file_name}`
} else {
actionMsg.value = `Removal failed: ${result.message}`
}
@@ -1414,8 +1579,8 @@ function resetFilters() {
sortBy.value = 'time_desc'
filterUnregistered.value = false
filterRegistered.value = false
filterPending.value = false
filterCompleted.value = false
filterScanning.value = false
filterReady.value = false
onlyVideos.value = false
onlyPhotos.value = false
sizeMin.value = null
@@ -1428,7 +1593,7 @@ function resetFilters() {
</script>
<style scoped>
#ms-files-page { width: 100%; max-width: 1200px; margin: 0 auto; color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans TC', sans-serif; position: relative; }
#ms-files-page { width: 100%; max-width: 1200px; margin: 0 auto; color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans TC', sans-serif; position: relative; height: 100%; display: flex; flex-direction: column; overflow: hidden; }
#ms-files-page * { box-sizing: border-box; }
.ms-fm-icon-btn { width: 34px; height: 34px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); cursor: pointer; display: grid; place-items: center; font-size: 16px; color: var(--text-secondary); transition: border-color .15s, color .15s, background .15s; }
.ms-fm-icon-btn:hover { border-color: var(--text-primary); color: var(--text-primary); background: var(--hover-background); }
@@ -1436,8 +1601,14 @@ function resetFilters() {
.ms-fm-btn { padding: 6px 14px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--card-background); cursor: pointer; font-size: 13px; font-family: inherit; color: var(--text-primary); transition: border-color .15s, background .15s; }
.ms-fm-btn:hover:not(:disabled) { border-color: var(--text-primary); background: var(--hover-background); }
.ms-fm-btn:disabled { opacity: 0.5; cursor: default; }
.ms-fm-panel { display: block; position: relative; overflow: visible; }
.ms-fm-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; position: relative; z-index: 2; }
.ms-fm-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
.ms-ppl-section-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1.5px solid var(--border-color); border-radius: 10px; background: var(--card-background); cursor: pointer; font-size: 12.5px; font-family: inherit; color: var(--text-secondary); transition: border-color .15s, color .15s, background .15s; }
.ms-ppl-section-toggle-btn.active { border-color: var(--text-primary); color: var(--text-primary); background: var(--hover-background); }
.ms-ppl-toggle-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border-color); transition: background .15s; }
.ms-ppl-toggle-dot.on { background: var(--primary-color); }
.ms-ppl-control-area { flex-shrink: 0; padding-bottom: 8px; background: var(--card-background); position: sticky; top: 0; z-index: 10; }
.ms-ppl-scroll-area { overflow-y: auto; flex: 1; min-height: 0; }
.ms-fm-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; position: relative; z-index: 2; }
.ms-fm-toolbar-left { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.ms-fm-processor-bar { display: flex; align-items: center; gap: 10px; padding: 6px 12px; margin-bottom: 12px; background: var(--hover-background); border-radius: 8px; border: 1px solid var(--border-light); flex-wrap: wrap; }
.ms-fm-processor-label { font-size: 12px; color: var(--text-secondary); font-weight: 600; margin-right: 2px; }
@@ -1456,7 +1627,7 @@ function resetFilters() {
.ms-fm-status-badge { position: absolute; bottom: 4px; right: 4px; font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: 0.3px; }
.ms-fm-search-wrap { width: 240px; }
.ms-fm-search { width: 100%; height: 42px; border: 1px solid var(--border-color); border-radius: 14px; padding: 0 14px; font-size: 14px; background: var(--card-background); color: var(--text-primary); outline: none; font-family: inherit; }
.ms-fm-status { font-size: 13px; color: var(--text-secondary); margin: 4px 0 14px; }
.ms-fm-status { font-size: 13px; color: var(--text-secondary); margin: 0 0 8px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.ms-fm-action-msg { margin-left: 12px; color: var(--success-color); font-weight: 600; }
.ms-fm-btn-danger { background: var(--danger-color); color: #fff; border-color: var(--danger-color); }
.ms-fm-btn-danger:hover:not(:disabled) { background: var(--danger-hover); }
@@ -1490,6 +1661,8 @@ function resetFilters() {
.ms-proc-count { margin-left: 4px; font-size: 11px; color: var(--text-secondary); }
.ms-fm-progress { position: relative; height: 20px; background: var(--border-light); border-radius: 4px; margin-top: 4px; overflow: hidden; }
.ms-fm-progress-bar { height: 100%; background: linear-gradient(90deg, var(--primary-color), #4285f4); border-radius: 4px; transition: width 0.3s; }
.ms-fm-progress-bar.indeterminate { width: 100%; background: linear-gradient(90deg, transparent, var(--primary-color), #4285f4, transparent); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
.ms-fm-progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: var(--text-primary); }
.ms-fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
.ms-fm-card { border-radius: 14px; cursor: pointer; position: relative; }
@@ -1513,6 +1686,8 @@ function resetFilters() {
.ms-fm-source { font-size: 11px; color: var(--text-secondary); margin-bottom: 2px; }
.ms-fm-name { font-size: 12px; color: var(--text-primary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.ms-fm-play-btn { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 42px; height: 42px; border-radius: 50%; border: none; background: rgba(0,0,0,.58); color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 8; }
.ms-fm-rescan-btn { display: block; width: 100%; margin-top: 6px; padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--card-background); cursor: pointer; font-size: 11px; font-family: inherit; color: var(--text-primary); text-align: center; transition: border-color .15s, background .15s; }
.ms-fm-rescan-btn:hover { border-color: var(--primary-color); background: rgba(var(--primary-color-rgb), 0.1); color: var(--primary-color); }
.ms-fm-edit-input { width: 100%; height: 32px; border: 1px solid var(--border-color); border-radius: 10px; padding: 0 9px; font-size: 12px; color: var(--text-primary); background: var(--card-background); outline: none; font-family: inherit; }
/* Three-axis badges */
@@ -1527,7 +1702,6 @@ function resetFilters() {
@keyframes badge-pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
/* Three-axis status bar */
.ms-fm-axis-bar { font-size: 12px; color: var(--text-secondary); margin: 2px 0 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.ms-fm-axis-item strong { color: var(--text-primary); }
.ms-fm-axis-sep { color: var(--border-color); }
@@ -1762,4 +1936,14 @@ function resetFilters() {
.ms-status-processing { background: rgba(251, 188, 4, 0.2); color: #f9a825; }
.ms-status-completed { background: rgba(52, 168, 83, 0.2); color: #34a853; }
.ms-qc-arrow-h { font-size: 20px; color: var(--text-secondary); }
.ms-qc-footer { display: flex; justify-content: flex-end; margin-top: 16px; }
@media (max-width: 767px) {
.ms-fm-toolbar { flex-wrap: wrap; gap: 6px; }
.ms-fm-toolbar-left, .ms-fm-toolbar-right { flex-wrap: wrap; gap: 4px; }
.ms-fm-search { width: 120px; font-size: 13px; }
.ms-fm-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.ms-fm-sort-panel { position: fixed; left: 8px; right: 8px; width: auto; top: 60px; max-height: 60vh; }
.ms-fm-status { font-size: 12px; }
}
</style>