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