Files
momentry_studio/src/views/LibraryView.vue
Momentry Studio 5e0eac6700 fix: allow re-scan for paused files
- Added Rescan button for paused files in context menu
- Re-scan now removes file from paused set
- Paused files can be re-scanned directly without resuming first
2026-10-18 23:13:44 +08:00

2814 lines
131 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<label class="ms-fm-radio-label">
<input type="radio" name="ms-display-filter" value="all" v-model="displayFilter"> All
</label>
<label class="ms-fm-radio-label">
<input type="radio" name="ms-display-filter" value="video" v-model="displayFilter"> Videos
</label>
<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: showCompletedSection }" @click="showCompletedSection = !showCompletedSection">
{{ t('library.status.completed') }} {{ completedCount }} <span class="ms-ppl-toggle-dot" :class="{ on: showCompletedSection }"></span>
</button>
<button class="ms-ppl-section-toggle-btn" :class="{ active: showRegisteredSection }" @click="showRegisteredSection = !showRegisteredSection">
{{ t('library.status.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">
{{ t('library.status.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">
<button v-if="filterText" class="ms-fm-search-clear" @click="filterText = ''" type="button">✕</button>
</div>
<button class="ms-fm-icon-btn" type="button" @click.stop="showFilter = !showFilter">☰</button>
</div>
</div>
<!-- Filter Popup -->
<div class="ms-fm-sort-panel" :class="{ show: showFilter }">
<div class="ms-fm-sort-section">
<div class="ms-fm-sort-title">Sort By</div>
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="time_desc" v-model="sortBy"> Time (Newest First)</label>
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="time_asc" v-model="sortBy"> Time (Oldest First)</label>
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="size_desc" v-model="sortBy"> Size (Largest First)</label>
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="size_asc" v-model="sortBy"> Size (Smallest First)</label>
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="name_asc" v-model="sortBy"> Name (A to Z)</label>
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="name_desc" v-model="sortBy"> Name (Z to A)</label>
</div>
<div class="ms-fm-sort-section">
<div class="ms-fm-sort-title">Filter By</div>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterUnregistered"> {{ t('library.status.unregistered') }}</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterRegistered"> {{ t('library.status.registered') }}</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterScanning"> {{ t('library.status.scanning') }}</label>
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterReady"> {{ t('library.status.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>
<div class="ms-fm-sort-section">
<div class="ms-fm-sort-title">Size</div>
<div style="display:flex;align-items:center;gap:8px;">
<input type="number" class="ms-fm-edit-input" v-model.number="sizeMin" min="0" placeholder="Min MB" style="width:100%;">
<span>—</span>
<input type="number" class="ms-fm-edit-input" v-model.number="sizeMax" min="0" placeholder="Max MB" style="width:100%;">
</div>
</div>
<div class="ms-fm-sort-section">
<div class="ms-fm-sort-title">Video Duration</div>
<div style="display:flex;align-items:center;gap:8px;">
<input type="number" class="ms-fm-edit-input" v-model.number="durationMin" min="0" placeholder="Min Minutes" style="width:100%;">
<span>—</span>
<input type="number" class="ms-fm-edit-input" v-model.number="durationMax" min="0" placeholder="Max Minutes" style="width:100%;">
</div>
</div>
<div class="ms-fm-sort-section">
<div class="ms-fm-sort-title">Settings</div>
<label class="ms-fm-radio-label"><input type="checkbox" :checked="autoScanAfterRegister" @change="toggleAutoScan"> {{ t('library.settings.auto_scan') }}</label>
</div>
<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>
<span class="ms-fm-axis-sep">·</span>
<span class="ms-fm-axis-item">🔍 {{ t('library.status.scanning') }} <strong>{{ scanningCount }}</strong></span>
<span class="ms-fm-axis-item ms-fm-axis-paused">· ⏸ {{ t('library.status.paused') }} <strong>{{ pausedCount }}</strong></span>
<span class="ms-fm-axis-item">· ✅ {{ t('library.status.completed') }} <strong>{{ completedCount }}</strong></span>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="ms-fm-loading">
<div class="ms-fm-loading-spinner"></div>
<div class="ms-fm-loading-text">Loading...</div>
</div>
<div v-else class="ms-ppl-scroll-area" ref="libraryGridEl" @mousedown="dragMousedown" @click.capture="dragClickCapture">
<!-- Drag select rectangle -->
<div v-if="dragBox" class="ms-select-rect" :style="dragSelectRectStyle(dragBox)"></div>
<!-- Completed Section -->
<div v-if="completedFiles.length && showCompletedSection" class="ms-fm-section">
<div class="ms-fm-section-title">
✅ {{ t('library.status.completed') }} ({{ completedFiles.length }})
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-scan-btn" @click="batchDeepScan">
🔍 Deep Scan ({{ selectedRegisteredFiles.length }})
</button>
<button v-if="selectedScanningFiles.length > 0" class="ms-batch-pause-btn" @click="batchPause">
⏸ Pause ({{ selectedScanningFiles.length }})
</button>
<button v-if="selectedPausedFiles.length > 0" class="ms-batch-resume-btn" @click="batchResume">
▶ Resume ({{ selectedPausedFiles.length }})
</button>
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-unregister-btn" @click="batchUnregister">
🗑️ {{ t('library.context.unregister') }} ({{ selectedRegisteredFiles.length }})
</button>
</div>
<div class="ms-fm-grid">
<div v-for="f in completedFiles" :key="f.file_uuid" class="ms-fm-card ms-fm-card-completed" :class="{ 'is-selected': selectedFiles.includes(f.file_uuid) }" :data-drag-key="f.file_uuid" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)" @touchstart="onCardTouchStart($event, f)" @touchend="onCardTouchEnd" @touchmove="onCardTouchEnd">
<div class="ms-fm-thumb-wrap">
<div class="ms-fm-badge">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
<img v-if="thumbnails[thumbKey(f.file_uuid)]"
class="ms-fm-thumb"
:src="thumbnails[thumbKey(f.file_uuid)]"
alt=""
@error="handleThumbError">
<div v-else-if="isPhoto(f) || isVideo(f)" class="ms-fm-thumb-loading" v-observe="() => loadThumbnailLocal(f.file_uuid)">⟳</div>
<div v-else class="ms-fm-doc-thumb">
<span class="ms-fm-doc-icon">📄</span>
<span class="ms-fm-doc-ext">{{ getFileExt(f.file_name) }}</span>
</div>
<button v-if="isVideo(f)" class="ms-fm-play-btn" @click.stop="playVideo(f)">▶</button>
<button v-if="hasFaceData(f) && (faceCounts[f.file_uuid] === undefined || faceCounts[f.file_uuid] > 0)" class="ms-fm-face-btn" :title="'Faces: ' + f.file_name" @click.stop="openFacePage(f)" v-observe="() => loadFaceCount(f.file_uuid)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="10" r="4"/><path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/></svg>
<span v-if="faceCounts[f.file_uuid] > 0" class="ms-fm-face-count">{{ faceCounts[f.file_uuid] }}</span>
</button>
<div class="ms-fm-badges">
<div class="ms-fm-badge-row ms-badge-done">✅ {{ t('library.status.completed') }}</div>
</div>
<div class="ms-fm-card-checkbox" @click.stop="toggleSelect(f)">
<span v-if="selectedFiles.includes(f.file_uuid)">✓</span>
</div>
</div>
<div class="ms-fm-meta">
<div class="ms-fm-name">{{ f.file_name }}</div>
<div class="ms-fm-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</div>
</div>
</div>
</div>
</div>
<!-- Registered Section -->
<div v-if="showRegisteredSection" class="ms-fm-section">
<div class="ms-fm-section-title">
📋 {{ t('library.status.registered') }} ({{ registeredFiles.length }})
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-scan-btn" @click="batchDeepScan">
🔍 Deep Scan ({{ selectedRegisteredFiles.length }})
</button>
<button v-if="selectedScanningFiles.length > 0" class="ms-batch-pause-btn" @click="batchPause">
⏸ Pause ({{ selectedScanningFiles.length }})
</button>
<button v-if="selectedPausedFiles.length > 0" class="ms-batch-resume-btn" @click="batchResume">
▶ Resume ({{ selectedPausedFiles.length }})
</button>
<button v-if="selectedRegisteredFiles.length > 0" class="ms-batch-unregister-btn" @click="batchUnregister">
🗑️ {{ t('library.context.unregister') }} ({{ selectedRegisteredFiles.length }})
</button>
</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) }" :data-drag-key="f.file_uuid" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)" @touchstart="onCardTouchStart($event, f)" @touchend="onCardTouchEnd" @touchmove="onCardTouchEnd">
<div class="ms-fm-thumb-wrap">
<div class="ms-fm-badge">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
<img v-if="thumbnails[thumbKey(f.file_uuid)]"
class="ms-fm-thumb"
:src="thumbnails[thumbKey(f.file_uuid)]"
alt=""
@error="handleThumbError">
<div v-else-if="isPhoto(f) || isVideo(f)" class="ms-fm-thumb-loading" v-observe="() => loadThumbnailLocal(f.file_uuid)">⟳</div>
<div v-else class="ms-fm-doc-thumb">
<span class="ms-fm-doc-icon">📄</span>
<span class="ms-fm-doc-ext">{{ getFileExt(f.file_name) }}</span>
</div>
<button v-if="isVideo(f)" class="ms-fm-play-btn" @click.stop="playVideo(f)">▶</button>
<button v-if="hasFaceData(f) && (faceCounts[f.file_uuid] === undefined || faceCounts[f.file_uuid] > 0)" class="ms-fm-face-btn" :title="'Faces: ' + f.file_name" @click.stop="openFacePage(f)" v-observe="() => loadFaceCount(f.file_uuid)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="10" r="4"/><path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/></svg>
<span v-if="faceCounts[f.file_uuid] > 0" class="ms-fm-face-count">{{ faceCounts[f.file_uuid] }}</span>
</button>
<div class="ms-fm-badges">
<div class="ms-fm-badge-row ms-badge-ok">📋 {{ t('library.status.registered') }}</div>
<div class="ms-fm-badge-row" :class="statusBadgeClass(f)">{{ statusBadgeIcon(f) }} {{ statusBadgeLabel(f) }}</div>
</div>
<div class="ms-fm-card-checkbox" @click.stop="toggleSelect(f)">
<span v-if="selectedFiles.includes(f.file_uuid)">✓</span>
</div>
</div>
<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 === '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 === '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>
<div class="ms-fm-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</div>
</div>
</div>
</div>
</div>
<!-- Unregistered Section -->
<div v-if="unregisteredFiles.length && showUnregisteredSection" class="ms-fm-section">
<div class="ms-fm-section-title">
📦 {{ t('library.status.unregistered') }} ({{ unregisteredFiles.length }})
<button v-if="selectedUnregisteredFiles.length > 0" class="ms-batch-register-btn" @click="batchRegister">
📥 {{ t('library.context.register_file') }} ({{ selectedUnregisteredFiles.length }})
</button>
</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) }" :data-drag-key="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>
<img v-if="thumbnails[unregThumbKey(f.file_path)]"
class="ms-fm-thumb ms-fm-thumb-unreg"
:src="thumbnails[unregThumbKey(f.file_path)]"
alt=""
@error="handleThumbError">
<div v-else-if="isPhoto(f) || isVideo(f)" class="ms-fm-thumb-loading ms-fm-thumb-unreg-loading" v-observe="() => loadUnregisteredThumbnail(f.file_path)">⟳</div>
<div v-else class="ms-fm-doc-thumb">
<span class="ms-fm-doc-icon">📄</span>
<span class="ms-fm-doc-ext">{{ getFileExt(f.file_name) }}</span>
</div>
<div class="ms-fm-unreg-overlay">
<span class="ms-fm-unreg-label">{{ t('library.status.unregistered') }}</span>
</div>
<div class="ms-fm-card-checkbox" @click.stop="toggleSelect(f)">
<span v-if="selectedFiles.includes(f.file_path)">✓</span>
</div>
<button class="ms-fm-card-register-btn" @click.stop="quickRegister(f)" :title="t('library.context.register_file')">
📥
</button>
</div>
<div class="ms-fm-meta">
<div class="ms-fm-name">{{ f.file_name }}</div>
<div class="ms-fm-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!completedFiles.length && !registeredFiles.length && !unregisteredFiles.length" class="ms-fm-empty">
<div class="ms-fm-empty-icon">📁</div>
<div class="ms-fm-empty-text">No files found</div>
</div>
</div>
</section>
<!-- Context Menu -->
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
<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 === 'registered' ? 'ms-ctx-pending' : 'ms-ctx-none'">
{{ statusLabel(ctxMenuFile) }}
</span>
<span v-if="ctxMenuFile?.status_updated_at" class="ms-ctx-status-time">
· {{ formatRelativeTime(ctxMenuFile.status_updated_at) }}
</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)">📥 {{ t('library.context.register_file') }}</button>
<button v-if="ctxMenuFile?.isRegistered && ctxMenuFile?.status !== 'completed' && ctxMenuFile?.status !== 'paused'" class="ms-ctx-item" @click="ctxProcess(ctxMenuFile)">🔍 {{ t('library.confirm.title_process') }}</button>
<button v-if="ctxMenuFile?.isRegistered && ctxMenuFile?.status === 'completed'" class="ms-ctx-item" @click="ctxReprocess(ctxMenuFile)">🔄 {{ t('library.confirm.title_rescan') }}</button>
<button v-if="ctxMenuFile?.status === 'paused'" class="ms-ctx-item" @click="ctxReprocess(ctxMenuFile)">🔄 {{ t('library.confirm.title_rescan') }}</button>
<!-- Pause / Resume -->
<button v-if="ctxMenuFile?.status === 'scanning'"
class="ms-ctx-item ms-ctx-warning"
@click="ctxPause(ctxMenuFile)">⏸ {{ t('library.context.pause') }}</button>
<button v-if="ctxMenuFile?.status === 'paused'"
class="ms-ctx-item ms-ctx-success"
@click="ctxResume(ctxMenuFile)">▶ {{ t('library.context.resume') }}</button>
<button v-if="ctxMenuFile?.isRegistered" class="ms-ctx-item ms-ctx-danger" @click="ctxUnregister(ctxMenuFile)">🗑️ {{ t('library.context.unregister') }}</button>
<button v-if="isVideo(ctxMenuFile) && ctxMenuFile?.isRegistered" class="ms-ctx-item" @click="playVideo(ctxMenuFile); closeContextMenu()">▶ {{ t('library.context.play_video') }}</button>
<template v-if="ctxMenuFile?.isRegistered">
<hr class="ms-ctx-divider">
<div class="ms-ctx-procs">
<div class="ms-ctx-procs-title">{{ t('library.processor.output') }} <button v-if="isAdvanced" class="ms-proc-qc-btn" @click="runProcessorQC(ctxMenuFile)" :title="t('library.context.qc_check')">🔍</button></div>
<div class="ms-proc-line" :class="procStatusClass('asr')">
<label class="ms-fm-check-label"><input type="checkbox" v-model="procAsr"> ASR</label>
<span class="ms-proc-status">{{ procStatusIcon('asr') }}</span>
<span class="ms-proc-count" @click.stop="viewProcessorJson('asr')">{{ procCountLabel('asr', 'segment') }}</span>
<button class="ms-proc-redo" @click.stop="redoProcessor('asr')" :title="t('library.context.reprocess')">🔄</button>
</div>
<div class="ms-proc-line" :class="procStatusClass('asrx')">
<label class="ms-fm-check-label"><input type="checkbox" v-model="procAsrx"> ASRX</label>
<span class="ms-proc-status">{{ procStatusIcon('asrx') }}</span>
<span class="ms-proc-count" @click.stop="viewProcessorJson('asrx')">{{ procCountLabel('asrx', 'segment') }}</span>
<button class="ms-proc-redo" @click.stop="redoProcessor('asrx')" :title="t('library.context.reprocess')">🔄</button>
</div>
<div class="ms-proc-line" :class="procStatusClass('face_cluster')">
<label class="ms-fm-check-label"><input type="checkbox" v-model="procFaceCluster"> {{ t('library.context.face_deduplication') }}</label>
<span class="ms-proc-status">{{ procStatusIcon('face_cluster') }}</span>
<span class="ms-proc-count" @click.stop="viewProcessorJson('face_cluster')">{{ procCountLabel('face_cluster', 'frame') }}</span>
<button class="ms-proc-redo" @click.stop="redoProcessor('face_cluster')" :title="t('library.context.reprocess')">🔄</button>
</div>
<div class="ms-proc-line" :class="procStatusClass('face')">
<label class="ms-fm-check-label"><input type="checkbox" v-model="procFace"> Face</label>
<span class="ms-proc-status">{{ procStatusIcon('face') }}</span>
<span class="ms-proc-count" @click.stop="viewProcessorJson('face')">{{ procCountLabel('face', 'frame') }}</span>
<button class="ms-proc-redo" @click.stop="redoProcessor('face')" title="Re-run Face">🔄</button>
</div>
<div class="ms-proc-line" :class="procStatusClass('ocr')">
<label class="ms-fm-check-label"><input type="checkbox" v-model="procOcr"> OCR</label>
<span class="ms-proc-status">{{ procStatusIcon('ocr') }}</span>
<span class="ms-proc-count" @click.stop="viewProcessorJson('ocr')">{{ procCountLabel('ocr', 'frame') }}</span>
<button class="ms-proc-redo" @click.stop="redoProcessor('ocr')" title="Re-run OCR">🔄</button>
</div>
<div class="ms-proc-line" :class="procStatusClass('cut')">
<label class="ms-fm-check-label"><input type="checkbox" v-model="procCut"> CUT</label>
<span class="ms-proc-status">{{ procStatusIcon('cut') }}</span>
<span class="ms-proc-count" @click.stop="viewProcessorJson('cut')">{{ procCountLabel('cut', 'frame') }}</span>
<button class="ms-proc-redo" @click.stop="redoProcessor('cut')" title="Re-run CUT">🔄</button>
</div>
<div v-if="vlmSummaryLabel(ctxMenuFile?.file_uuid)" class="ms-proc-line" :class="vlmSummaryClass(ctxMenuFile?.file_uuid)">
<label class="ms-fm-check-label">FS VLM Summary</label>
<span class="ms-proc-status">{{ vlmSummaryIcon(ctxMenuFile?.file_uuid) }}</span>
<span class="ms-proc-count">{{ vlmSummaryLabel(ctxMenuFile?.file_uuid) }}</span>
<button class="ms-proc-redo" @click.stop="triggerVlmSummary(ctxMenuFile?.file_uuid)" title="Generate VLM Summaries">🔄</button>
</div>
<div class="ms-proc-line" :class="sceneVlmClass(ctxMenuFile?.file_uuid)">
<label class="ms-fm-check-label">Scene VLM</label>
<span class="ms-proc-status">{{ sceneVlmIcon(ctxMenuFile?.file_uuid) }}</span>
<span class="ms-proc-count" :class="{ 'ms-proc-clickable': hasSceneVlm(ctxMenuFile?.file_uuid) }" @click.stop="hasSceneVlm(ctxMenuFile?.file_uuid) && viewScenesJson()">{{ sceneVlmLabel(ctxMenuFile?.file_uuid) }}</span>
</div>
</div>
<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>
</div>
<template v-else>
<div class="ms-ctx-overall-progress" v-if="getPipelineStats(ctxMenuFile.file_uuid)">
<div class="ms-ctx-progress-title">{{ t('library.progress.overall') }}</div>
<div class="ms-ctx-progress-bar-wrap">
<div class="ms-ctx-progress-bar" :style="{ width: formatPipelineProgress(getPipelineStats(ctxMenuFile.file_uuid).overall_progress) }"></div>
<span class="ms-ctx-progress-text">{{ formatPipelineProgress(getPipelineStats(ctxMenuFile.file_uuid).overall_progress) }}</span>
</div>
</div>
<div v-if="getPipelineStats(ctxMenuFile.file_uuid)" class="ms-ctx-stages">
<div v-for="stage in getPipelineStats(ctxMenuFile.file_uuid).stages" :key="stage.name" class="ms-ctx-stage">
<div class="ms-ctx-stage-header">
<span class="ms-ctx-stage-icon" :style="{ color: getStageColor(stage.status) }">{{ getStageIcon(stage.status) }}</span>
<span class="ms-ctx-stage-name">{{ formatStageName(stage.name) }}</span>
<span class="ms-ctx-stage-progress">{{ formatPipelineProgress(stage.progress) }}</span>
</div>
<div class="ms-ctx-stage-bar-wrap">
<div class="ms-ctx-stage-bar" :style="{ width: formatPipelineProgress(stage.progress), backgroundColor: getStageColor(stage.status) }"></div>
</div>
</div>
</div>
<div v-if="getFileStats(ctxMenuFile.file_uuid)" class="ms-ctx-details">
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenuFile.file_uuid).json?.processors">
<div class="ms-ctx-phase-title">{{ t('library.phase.processor_output') }}</div>
<template v-for="(info, proc) in getFileStats(ctxMenuFile.file_uuid).json.processors" :key="proc">
<div v-if="proc !== 'pose'" class="ms-ctx-proc-item">
<span class="ms-ctx-proc-name">{{ proc.toUpperCase() }}</span>
<span class="ms-ctx-proc-count">{{ formatProcCount(proc as string, info) }}</span>
</div>
</template>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenuFile.file_uuid).postgresql">
<div class="ms-ctx-phase-title">Rule1: PostgreSQL Ingestion</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Sentence Chunks</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).postgresql.sentence_chunks }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Relationship Chunks</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).postgresql.relationship_chunks }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenuFile.file_uuid).qdrant">
<div class="ms-ctx-phase-title">Rule1: Qdrant Ingestion</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Text Chunks</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).qdrant.text_chunks }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Face Points</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).qdrant.faces }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Speakers</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenuFile.file_uuid).qdrant.speakers }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenuFile.file_uuid).tkg?.nodes">
<div class="ms-ctx-phase-title">Rule2: TKG Nodes</div>
<div v-for="(count, type) in getFileStats(ctxMenuFile.file_uuid).tkg.nodes" :key="type" class="ms-ctx-proc-item">
<span class="ms-ctx-proc-name">{{ formatNodeName(type) }}</span>
<span class="ms-ctx-proc-count">{{ count }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenuFile.file_uuid).tkg?.edges">
<div class="ms-ctx-phase-title">Rule2: TKG Edges</div>
<div v-for="(count, type) in getFileStats(ctxMenuFile.file_uuid).tkg.edges" :key="type" class="ms-ctx-proc-item">
<span class="ms-ctx-proc-name">{{ formatEdgeName(type) }}</span>
<span class="ms-ctx-proc-count">{{ count }}</span>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Processor JSON Viewer Modal -->
<div v-if="processorJsonDialog" class="ms-modal-overlay" @click.self="processorJsonDialog = false">
<div class="ms-modal-box ms-modal-wide">
<div class="ms-modal-header">
<span class="ms-modal-title">{{ processorJsonTitle }}</span>
<button class="ms-modal-close" @click="processorJsonDialog = false">✕</button>
</div>
<div class="ms-modal-body">
<pre class="ms-json-view">{{ processorJsonContent }}</pre>
</div>
</div>
</div>
<!-- Scan Confirm Modal -->
<div v-if="confirmDialog" class="ms-modal-overlay" @click.self="cancelConfirm">
<div class="ms-modal-box">
<div class="ms-modal-header">
<span class="ms-modal-title">{{ confirmTitle }}</span>
<button class="ms-modal-close" @click="cancelConfirm">✕</button>
</div>
<div class="ms-modal-body">
<div class="ms-ctx-filename" style="margin-bottom:12px;">{{ confirmTarget?.file?.file_name }}</div>
<div class="ms-proc-line">{{ confirmMessage }}</div>
<div class="ms-modal-actions" style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">
<button class="ms-ctx-item" @click="cancelConfirm">{{ t('library.confirm.cancel') }}</button>
<button class="ms-ctx-item" @click="confirmProceed">{{ t('library.confirm.proceed') }}</button>
</div>
</div>
</div>
</div>
<!-- QC Modal -->
<div v-if="showQCModal" class="ms-modal-overlay" @click.self="showQCModal = false">
<div class="ms-modal-box ms-modal-qc-wide">
<div class="ms-modal-header">
<span class="ms-modal-title">🔍 {{ t('library.processor.pipeline_qc') }} - {{ qcFile?.file_name?.slice(0, 30) }}</span>
<button class="ms-modal-close" @click="showQCModal = false">✕</button>
</div>
<div class="ms-modal-body">
<div class="ms-qc-uuid">{{ qcFile?.file_uuid }}</div>
<!-- System Status Bar -->
<div class="ms-qc-system-status">
<div class="ms-qc-service" :class="serviceStatus.core3002 ? 'ms-qc-svc-ok' : 'ms-qc-svc-off'">
<span class="ms-qc-svc-dot"></span>
<span>3002</span>
</div>
<div class="ms-qc-service" :class="serviceStatus.proxy8888 ? 'ms-qc-svc-ok' : 'ms-qc-svc-off'">
<span class="ms-qc-svc-dot"></span>
<span>8888</span>
</div>
<div class="ms-qc-service" :class="serviceStatus.vite5173 ? 'ms-qc-svc-ok' : 'ms-qc-svc-off'">
<span class="ms-qc-svc-dot"></span>
<span>5173</span>
</div>
<div class="ms-qc-service" :class="systemStatus.worker_running ? 'ms-qc-svc-ok' : 'ms-qc-svc-off'">
<span class="ms-qc-svc-dot"></span>
<span>Worker</span>
</div>
<div class="ms-qc-service" :class="serviceStatus.llm ? 'ms-qc-svc-ok' : 'ms-qc-svc-off'">
<span class="ms-qc-svc-dot"></span>
<span>LLM</span>
</div>
<div class="ms-qc-service" :class="serviceStatus.vlm ? 'ms-qc-svc-ok' : 'ms-qc-svc-off'">
<span class="ms-qc-svc-dot"></span>
<span>VLM</span>
</div>
<div class="ms-qc-service" :class="serviceStatus.embedding ? 'ms-qc-svc-ok' : 'ms-qc-svc-off'">
<span class="ms-qc-svc-dot"></span>
<span>Embedding</span>
</div>
<button class="ms-qc-svc-refresh" @click="refreshSystemStatus" title="Refresh">↻</button>
</div>
<!-- Jobs Section -->
<div class="ms-qc-jobs-section">
<div class="ms-qc-jobs-header">
<span class="ms-qc-jobs-icon">📋</span>
<span class="ms-qc-jobs-title">Jobs</span>
<span class="ms-qc-jobs-count">{{ jobsList.length }}</span>
</div>
<div class="ms-qc-jobs-list">
<div v-for="job in jobsList.slice(0, 5)" :key="job.id" class="ms-qc-job" :class="'ms-qc-job-' + job.status">
<div class="ms-qc-job-row">
<span class="ms-qc-job-id">#{{ job.id }}</span>
<span class="ms-qc-job-status">{{ job.status }}</span>
<span class="ms-qc-job-file">{{ job.file_name || job.uuid?.slice(0, 8) }}</span>
<span class="ms-qc-job-proc" v-if="job.current_processor">▶ {{ job.current_processor }}</span>
<span class="ms-qc-job-progress" v-if="job.progress_total > 0">{{ job.progress_current }}/{{ job.progress_total }}</span>
</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">{{ $t('library.qc.items', { count: out.count }) }}</span> <span class="ms-qc-job-size">({{ formatSize(out.size) }})</span>
</span>
</div>
</div>
<div v-if="jobsList.length === 0" class="ms-qc-job-empty">No jobs</div>
</div>
</div>
<!-- Audio Track Info -->
<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">{{ $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">{{ $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 ? $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">{{ $t('library.qc.speech') }}</span>
<span class="ms-qc-audio-value" :class="qcAudioInfo.hasSpeech ? 'ms-qc-audio-ok' : 'ms-qc-audio-warn'">
{{ 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">{{ $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">{{ $t('library.qc.sample_rate') }}</span>
<span class="ms-qc-audio-value">{{ qcAudioInfo.sampleRate }} Hz</span>
</div>
</div>
</div>
<div class="ms-qc-divider"></div>
<!-- Track 1: Processor Outputs -->
<div class="ms-qc-track">
<div class="ms-qc-track-header">
<span class="ms-qc-track-icon">⚙️</span>
<span class="ms-qc-track-title">{{ $t('library.qc.track_processor_output') }}</span>
</div>
<div class="ms-qc-flow">
<div class="ms-qc-node ms-qc-input">📁 Video File</div>
<div class="ms-qc-arrow">↓</div>
<div class="ms-qc-stage-row">
<div v-for="r in qcResults.filter(p => ['CUT','ASR','FACE','OCR'].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">{{ $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>
</div>
<div class="ms-qc-arrow">↓</div>
<div class="ms-qc-stage-row">
<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">{{ $t('library.qc.items', { count: r.size || 0 }) }}</span>
<button class="ms-qc-redo" @click="redoProcessorFromQC(r.processor.toLowerCase())">🔄</button>
</div>
</div>
<div class="ms-qc-arrow">↓</div>
<div class="ms-qc-node ms-qc-output">📄 JSON Outputs</div>
</div>
</div>
<div class="ms-qc-divider"></div>
<!-- Track 2: Ingestion -->
<div class="ms-qc-track">
<div class="ms-qc-track-header">
<span class="ms-qc-track-icon">📥</span>
<span class="ms-qc-track-title">{{ $t('library.qc.track_ingestion') }}</span>
</div>
<div class="ms-qc-flow">
<div class="ms-qc-node ms-qc-input">📄 JSON Outputs</div>
<div class="ms-qc-arrow">↓</div>
<div class="ms-qc-ingest-grid">
<div class="ms-qc-ingest-box">
<div class="ms-qc-ingest-header">🐘 PostgreSQL</div>
<div class="ms-qc-ingest-stat">
<span class="ms-qc-ingest-label">Sentence Chunks</span>
<span class="ms-qc-ingest-count">{{ getFileStats(qcFile?.file_uuid)?.postgresql?.sentence_chunks || 0 }}</span>
</div>
<div class="ms-qc-ingest-stat">
<span class="ms-qc-ingest-label">Relationship Chunks</span>
<span class="ms-qc-ingest-count">{{ getFileStats(qcFile?.file_uuid)?.postgresql?.relationship_chunks || 0 }}</span>
</div>
</div>
<div class="ms-qc-ingest-box">
<div class="ms-qc-ingest-header">🔍 Qdrant</div>
<div class="ms-qc-ingest-stat">
<span class="ms-qc-ingest-label">Faces</span>
<span class="ms-qc-ingest-count">{{ getFileStats(qcFile?.file_uuid)?.qdrant?.faces || 0 }}</span>
</div>
<div class="ms-qc-ingest-stat">
<span class="ms-qc-ingest-label">Text Chunks</span>
<span class="ms-qc-ingest-count">{{ getFileStats(qcFile?.file_uuid)?.qdrant?.text_chunks || 0 }}</span>
</div>
<div class="ms-qc-ingest-stat">
<span class="ms-qc-ingest-label">Speakers</span>
<span class="ms-qc-ingest-count">{{ getFileStats(qcFile?.file_uuid)?.qdrant?.speakers || 0 }}</span>
</div>
</div>
<div class="ms-qc-ingest-box">
<div class="ms-qc-ingest-header">🧠 TKG</div>
<div class="ms-qc-ingest-stat">
<span class="ms-qc-ingest-label">Nodes</span>
<span class="ms-qc-ingest-count">{{ getFileStats(qcFile?.file_uuid)?.tkg?.nodes?.character || 0 }}</span>
</div>
<div class="ms-qc-ingest-stat">
<span class="ms-qc-ingest-label">Edges</span>
<span class="ms-qc-ingest-count">{{ getFileStats(qcFile?.file_uuid)?.tkg?.edges?.appear_in || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="ms-qc-divider"></div>
<!-- Final: Status Change -->
<div class="ms-qc-track">
<div class="ms-qc-track-header">
<span class="ms-qc-track-icon">✅</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">{{ $t('library.qc.output_ingestion_done') }}</div>
<div class="ms-qc-arrow">↓</div>
<div class="ms-qc-status-change">
<div class="ms-qc-status-from">
<span class="ms-qc-status-label">From</span>
<span class="ms-qc-status-value ms-status-processing">processing</span>
</div>
<div class="ms-qc-arrow-h">→</div>
<div class="ms-qc-status-to">
<span class="ms-qc-status-label">To</span>
<span class="ms-qc-status-value ms-status-completed">completed</span>
</div>
</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>
<VideoPlayer v-if="playing" simple :file-uuid="currentVideo.fileUuid" :start-frame="currentVideo.startFrame" :end-frame="currentVideo.endFrame" :title="currentVideo.title" @close="playing = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { apiCall, pollCommandStatus } from '@/api'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useDragSelect, dragSelectRectStyle, type DragSelectBox } from '@/composables/useDragSelect'
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, faceCountsCache, loadFaceCount, fileStatusCache } from '@/store'
import { getApiBase } from '@/api/config'
import { getAsrStatus } from '@/utils/asrStatus'
import { useAdvancedMode } from '@/stores/advancedMode'
import { useAutoScan } from '@/stores/autoScan'
import VideoPlayer from '../components/VideoPlayer.vue'
import pLimit from 'p-limit'
const { t } = useI18n()
const { isAdvanced } = useAdvancedMode()
const { autoScanAfterRegister, toggleAutoScan } = useAutoScan()
const router = useRouter()
const files = computed(() => filesCache.value)
const loading = computed(() => !filesLoaded.value)
const statusText = ref('')
const filterText = ref('')
const displayFilter = ref('all')
const showFilter = ref(false)
const selectedFiles = ref<string[]>([])
const selectedScanningFiles = computed(() =>
filesCache.value.filter((f: any) =>
selectedFiles.value.includes(f.file_uuid) && f.status === 'scanning'
)
)
const selectedPausedFiles = computed(() =>
filesCache.value.filter((f: any) =>
selectedFiles.value.includes(f.file_uuid) && isFilePaused(f.file_uuid)
)
)
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startFrame: 0, endFrame: 0, title: '' })
const processorJsonDialog = ref(false)
const processorJsonContent = ref('')
const processorJsonTitle = ref('')
// Paused files tracking
const pausedFileUuids = ref<Set<string>>(new Set())
async function loadPausedFiles() {
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/jobs/paused`)
if (res.ok) {
const data = await res.json()
pausedFileUuids.value = new Set((data.jobs || []).map((j: any) => j.uuid))
}
} catch (e) {
console.error('[loadPausedFiles]', e)
}
}
function isFilePaused(fileUuid?: string): boolean {
if (!fileUuid) return false
return pausedFileUuids.value.has(fileUuid)
}
async function viewProcessorJson(processor: string) {
const f = ctxMenu.value.file
if (!f?.file_uuid) return
const fileHash = f.file_uuid
processorJsonTitle.value = `${processor.toUpperCase()} — ${f.file_name}`
processorJsonContent.value = 'Loading...'
processorJsonDialog.value = true
try {
const data: any = await apiCall('get_processor_json', { fileHash, processor })
processorJsonContent.value = JSON.stringify(data, null, 2)
} catch (e: any) {
processorJsonContent.value = `Error: ${e?.message || e}`
}
}
const thumbnails = computed(() => thumbnailsCache.value)
const thumbKey = (uuid: string) => `${uuid}:30`
const unregThumbKey = (path: string) => `unreg:${path}:30`
function hasFaceData(f: any) {
return ['completed', 'face_mgmt_ready', 'agent_ready'].includes(f?.status)
}
function openFacePage(f: any) {
router.push({ path: '/face', query: { file: String(f.file_uuid) } })
}
const sortBy = ref('time_desc')
const filterUnregistered = ref(false)
const faceCounts = computed(() => faceCountsCache.value)
const filterRegistered = ref(true)
const filterScanning = ref(false)
const filterReady = ref(false)
const onlyVideos = ref(false)
const onlyPhotos = ref(false)
const sizeMin = ref<number | null>(null)
const sizeMax = ref<number | null>(null)
const durationMin = ref<number | null>(null)
const durationMax = ref<number | null>(null)
// Sort & Filter
const registeredFiles = computed(() => {
let result = filesCache.value.filter((f: any) => f.isRegistered && f.status !== 'completed')
if (displayFilter.value === 'video') result = result.filter(isVideo)
else if (displayFilter.value === 'photo') result = result.filter(isPhoto)
if (filterText.value) {
const f = filterText.value.toLowerCase()
result = result.filter((file: any) => file.file_name.toLowerCase().includes(f))
}
if (filterScanning.value) result = result.filter((f: any) => f.status === 'scanning')
if (filterReady.value) result = result.filter((f: any) => ['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)
if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576)
// Sort: scanning/preparing files first, then by user selection
result.sort((a: any, b: any) => {
const getPriority = (f: any) => {
if (f.status === 'scanning') return 0
if (f.status === 'preparing') return 1
return 2
}
const priorityA = getPriority(a)
const priorityB = getPriority(b)
if (priorityA !== priorityB) return priorityA - priorityB
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
case 'size_desc': return (b.file_size || 0) - (a.file_size || 0)
case 'size_asc': return (a.file_size || 0) - (b.file_size || 0)
case 'name_asc': return (a.file_name || '').localeCompare(b.file_name || '')
case 'name_desc': return (b.file_name || '').localeCompare(a.file_name || '')
default: return 0
}
})
return result
})
const unregisteredFiles = computed(() => {
let result = files.value.filter((f: any) => !f.isRegistered)
if (displayFilter.value === 'video') result = result.filter(isVideo)
else if (displayFilter.value === 'photo') result = result.filter(isPhoto)
if (filterText.value) {
const f = filterText.value.toLowerCase()
result = result.filter((file: any) => file.file_name.toLowerCase().includes(f))
}
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)
if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576)
// Sort
result.sort((a: any, b: any) => {
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
case 'size_desc': return (b.file_size || 0) - (a.file_size || 0)
case 'size_asc': return (a.file_size || 0) - (b.file_size || 0)
case 'name_asc': return (a.file_name || '').localeCompare(b.file_name || '')
case 'name_desc': return (b.file_name || '').localeCompare(a.file_name || '')
default: return 0
}
})
return result
})
const completedFiles = computed(() => {
let result = files.value.filter((f: any) => f.status === 'completed')
if (displayFilter.value === 'video') result = result.filter(isVideo)
else if (displayFilter.value === 'photo') result = result.filter(isPhoto)
if (filterText.value) {
const f = filterText.value.toLowerCase()
result = result.filter((file: any) => file.file_name.toLowerCase().includes(f))
}
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)
if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576)
// Sort
result.sort((a: any, b: any) => {
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
case 'size_desc': return (b.file_size || 0) - (a.file_size || 0)
case 'size_asc': return (a.file_size || 0) - (b.file_size || 0)
case 'name_asc': return (a.file_name || '').localeCompare(b.file_name || '')
case 'name_desc': return (b.file_name || '').localeCompare(a.file_name || '')
default: return 0
}
})
return result
})
const showRegisteredSection = ref(true)
const showUnregisteredSection = ref(true)
const showCompletedSection = ref(true)
const libraryGridEl = ref<HTMLElement | null>(null)
const dragBox = ref<DragSelectBox | null>(null)
const { onMousedown: dragMousedown, onClickCapture: dragClickCapture } = useDragSelect(libraryGridEl, {
getSelected: () => selectedFiles.value,
setSelected: (keys) => { selectedFiles.value = keys },
onBox: (b) => { dragBox.value = b },
})
const sortedFilteredFiles = computed(() => {
let result = [...filesCache.value]
// Display filter
if (displayFilter.value === 'video') result = result.filter(isVideo)
else if (displayFilter.value === 'photo') result = result.filter(isPhoto)
// Text search
if (filterText.value) {
const f = filterText.value.toLowerCase()
result = result.filter((file: any) => file.file_name.toLowerCase().includes(f))
}
// 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 (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)
// Size filter
if (sizeMin.value) result = result.filter((f: any) => (f.file_size || 0) >= sizeMin.value! * 1048576)
if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576)
// Duration filter (estimated from file size for now)
if (durationMin.value || durationMax.value) {
result = result.filter((f: any) => {
if (!isVideo(f)) return !durationMin.value && !durationMax.value
const estDuration = (f.file_size || 0) / 50000000 // rough estimate
if (durationMin.value && estDuration < durationMin.value * 60) return false
if (durationMax.value && estDuration > durationMax.value * 60) return false
return true
})
}
// Sort: scanning/preparing files first, then by user selection
result.sort((a: any, b: any) => {
// Priority: scanning > preparing > others
const getPriority = (f: any) => {
if (f.status === 'scanning') return 0
if (f.status === 'preparing') return 1
return 2
}
const priorityA = getPriority(a)
const priorityB = getPriority(b)
if (priorityA !== priorityB) return priorityA - priorityB
// Within same priority, sort by user selection
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
case 'size_desc': return (b.file_size || 0) - (a.file_size || 0)
case 'size_asc': return (a.file_size || 0) - (b.file_size || 0)
case 'name_asc': return (a.file_name || '').localeCompare(b.file_name || '')
case 'name_desc': return (b.file_name || '').localeCompare(a.file_name || '')
default: return 0
}
})
return result
})
const registeredCount = computed(() => files.value.filter((f: any) => f.isRegistered && f.status !== 'completed').length)
const unregisteredCount = computed(() => files.value.filter((f: any) => !f.isRegistered).length)
const completedCount = computed(() => files.value.filter((f: any) => f.status === 'completed').length)
const scanningCount = computed(() => files.value.filter((f: any) => f.status === 'scanning').length)
const pausedCount = computed(() => files.value.filter((f: any) => f.status === 'paused').length)
const readyCount = computed(() => files.value.filter((f: any) => ['completed','keyword_ready','semantic_ready','face_mgmt_ready'].includes(f.status)).length)
const selectedUnregisteredFiles = computed(() => unregisteredFiles.value.filter((f: any) => selectedFiles.value.includes(f.file_path)))
const selectedRegisteredFiles = computed(() => {
const selected = selectedFiles.value
const registered = registeredFiles.value.filter((f: any) => selected.includes(f.file_uuid))
const completed = completedFiles.value.filter((f: any) => selected.includes(f.file_uuid))
return [...registered, ...completed]
})
onMounted(() => { loadFiles(); loadPausedFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh(); startProcessingFilesPolling(); startFilesWatch() })
onUnmounted(() => { document.removeEventListener('click', docClickClose); stopStatsAutoRefresh(); stopProcessingFilesPolling(); stopFilesWatch() })
function docClickClose(e: MouseEvent) {
if (e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
if (ctxMenu.value.show) ctxMenu.value.show = false
if (showFilter.value && !(e.target instanceof Element && (e.target.closest('.ms-fm-sort-panel') || e.target.closest('.ms-fm-icon-btn')))) {
showFilter.value = false
}
}
// Concurrency limit for status API calls
const statusLimit = pLimit(5)
async function loadFiles() {
statusText.value = 'Loading...'
invalidateFiles()
await ensureFiles()
statusText.value = `${files.value.length} files`
// Load thumbnails asynchronously (non-blocking)
for (const f of files.value) {
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
}
for (const f of files.value) {
if ((isPhoto(f) || isVideo(f)) && !f.isRegistered && f.file_path) loadUnregisteredThumbnail(f.file_path)
}
// Start status loading in background (non-blocking)
loadAllStatus()
}
// Load status for all registered files progressively
async function loadAllStatus() {
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid)
await Promise.all(registeredFiles.map((f: any) =>
statusLimit(() => Promise.all([
loadPipelineStats(f.file_uuid, true),
loadFileStats(f.file_uuid, true)
]).catch(() => {}))
))
}
async function refreshAllStatus() {
statusText.value = 'Refreshing status...'
// Clear caches first
pipelineStatsCache.value = {}
await loadAllStatus()
statusText.value = `${files.value.length} files`
}
function isVideo(f: any) {
const ext = (f.file_name || '').split('.').pop().toLowerCase()
return ['mp4', 'mov', 'mkv', 'avi', 'webm'].includes(ext)
}
function isPhoto(f: any) {
const ext = (f.file_name || '').split('.').pop().toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)
}
function getFileExt(name: string) {
return (name || '').split('.').pop().toUpperCase()
}
function formatSize(bytes: number) {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K'
return (bytes / 1048576).toFixed(1) + 'M'
}
function formatDate(date: string) {
if (!date) return ''
try {
const d = new Date(date)
if (isNaN(d.getTime())) return ''
return d.toISOString().slice(0, 10)
} catch {
return ''
}
}
function formatRelativeTime(isoString: string | undefined): string {
if (!isoString) return ''
try {
const date = new Date(isoString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
} catch {
return ''
}
}
async function loadThumbnailLocal(uuid: string) {
loadThumbnail(uuid, 30)
}
function handleThumbError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}
function toggleSelect(f: any) {
const key = f.file_uuid || f.file_path
const idx = selectedFiles.value.indexOf(key)
if (idx >= 0) selectedFiles.value.splice(idx, 1)
else selectedFiles.value.push(key)
}
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
}
const registering = ref(false)
const processing = ref(false)
const unregistering = ref(false)
const batchProcessing = ref(false)
const reprocessing = ref(false)
const confirmDialog = ref(false)
const confirmTarget = ref<{ mode: 'register' | 'process' | 'reprocess'; file: any } | null>(null)
const confirmTitle = computed(() => {
const m = confirmTarget.value?.mode
if (m === 'register') return t('library.confirm.title_register')
if (m === 'process') return t('library.confirm.title_process')
return t('library.confirm.title_rescan')
})
const confirmMessage = computed(() => {
const m = confirmTarget.value?.mode
if (m === 'register') return autoScanAfterRegister.value ? t('library.confirm.message_register') : t('library.confirm.message_register_no_scan')
if (m === 'process') return t('library.confirm.message_process')
return t('library.confirm.message_rescan')
})
function cancelConfirm() {
confirmDialog.value = false
confirmTarget.value = null
}
function confirmProceed() {
confirmDialog.value = false
const t = confirmTarget.value
confirmTarget.value = null
if (!t) return
if (t.mode === 'register') doRegister(t.file)
else if (t.mode === 'process') doProcess(t.file)
else doReprocess(t.file)
}
const actionMsg = ref('')
const procAsr = ref(true)
const procAsrx = ref(true)
const procFaceCluster = ref(true)
const procFace = ref(true)
const procOcr = ref(true)
const procAppearance = ref(true)
const procCut = ref(true)
const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show: false, x: 0, y: 0, file: null })
const statsLoading = ref(false)
const ctxMenuFile = computed(() => {
const f = ctxMenu.value.file
if (!f) return null
const fileUuid = f.file_uuid
if (fileUuid) {
const live = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (live) return live
}
return f
})
function formatProcCount(proc: string, info: JsonProcessorInfo): string {
if (info.segment_count != null) return `${info.segment_count} segments`
if (info.frame_count != null) return `${info.frame_count} frames`
if (info.chunk_count != null) return `${info.chunk_count} chunks`
return info.status
}
function formatNodeName(type: string): string {
const names: Record<string, string> = {
character: 'Character', object: 'Object', scene: 'Scene',
action: 'Action', location: 'Location', event: 'Event',
dialogue: 'Dialogue', emotion: 'Emotion', relation: 'Relation'
}
return names[type] || type
}
function formatEdgeName(type: string): string {
const names: Record<string, string> = {
appear_in: 'Appear_In', interact_with: 'Interact_With',
located_at: 'Located_At', perform_action: 'Perform_Action',
cause_effect: 'Cause_Effect', temporal_link: 'Temporal_Link',
speak_to: 'Speak_To', feel: 'Feel'
}
return names[type] || type
}
function formatPipelineProgress(progress: number): string {
return `${Math.round(progress * 100)}%`
}
function getStageIcon(status: string): string {
switch (status) {
case 'completed': return '✓'
case 'running': return '⟳'
case 'failed': return '✗'
default: return '○'
}
}
function formatStageName(name: string): string {
const names: Record<string, string> = {
processors: 'Processors',
knowledge_graph: 'Knowledge Graph',
moment_extraction: 'Moment Extraction',
integration: 'Integration',
// Keep old names for backward compatibility
rule1_ingestion: 'Rule1 Ingestion',
face_tracing: 'Face Tracing',
face_dedup: 'Face Dedup',
tkg_nodes: 'TKG Nodes',
tkg_edges: 'TKG Edges',
rule2_ingestion: 'Rule2 Ingestion',
vlm_summary: 'VLM Summary',
scene_vlm: 'Scene VLM',
moment_chunks: 'Moment Chunks'
}
return names[name] || name
}
function getStageColor(status: string): string {
switch (status) {
case 'completed': return '#34a853'
case 'running': return '#4285f4'
case 'failed': return '#ea4335'
default: return '#dadce0'
}
}
function selectedProcessors(): string[] {
const procs: string[] = []
if (procAsr.value) procs.push('asr')
if (procAsrx.value) procs.push('asrx')
if (procFaceCluster.value) procs.push('face_cluster')
if (procFace.value) procs.push('face')
if (procOcr.value) procs.push('ocr')
if (procAppearance.value) procs.push('appearance')
if (procCut.value) procs.push('cut')
return procs
}
function procStatusClass(proc: string): string {
const fileUuid = ctxMenu.value.file?.file_uuid
if (!fileUuid) return ''
const info = getProcessorCounts(fileUuid)[proc]
if (info?.has_json) return 'ms-proc-done'
return ''
}
function procCountLabel(proc: string, type: 'frame' | 'segment' | 'chunk'): string {
const fileUuid = ctxMenu.value.file?.file_uuid
if (!fileUuid) return ''
const info = getProcessorCounts(fileUuid)[proc]
if (!info?.has_json) return ''
if (type === 'frame' && info.frame_count != null) return `(${info.frame_count})`
if (type === 'segment' && info.segment_count != null) return `(${info.segment_count})`
if (type === 'chunk' && info.chunk_count != null) return `(${info.chunk_count})`
return '(✓)'
}
function procStatusIcon(proc: string): string {
const fileUuid = ctxMenu.value.file?.file_uuid
if (!fileUuid) return ''
const info = getProcessorCounts(fileUuid)[proc]
if (info?.has_json) return '✓'
return '✗'
}
// ── VLM Summary helpers ──
function vlmSummaryStats(fileUuid: string | undefined) {
if (!fileUuid) return null
return getFileStats(fileUuid)?.vlm_summary || null
}
function vlmSummaryClass(fileUuid: string | undefined): string {
const s = vlmSummaryStats(fileUuid)
if (!s || s.total === 0) return ''
if (s.done >= s.total) return 'ms-proc-done'
// File completed but VLM not done → warning
const file = ctxMenu.value.file
if (file?.status === 'completed') return 'ms-proc-warning'
return 'ms-proc-running'
}
function vlmSummaryIcon(fileUuid: string | undefined): string {
const s = vlmSummaryStats(fileUuid)
if (!s || s.total === 0) return '—'
if (s.done >= s.total) return '✓'
// File completed but VLM not done → warning icon only
const file = ctxMenu.value.file
if (file?.status === 'completed') return '⚠️'
return ''
}
function vlmSummaryLabel(fileUuid: string | undefined): string {
const s = vlmSummaryStats(fileUuid)
if (!s || s.total === 0) return ''
if (s.done >= s.total) return `(${s.total})`
return `${s.done}/${s.total}`
}
let vlmTriggering = false
async function triggerVlmSummary(fileUuid: string | undefined) {
if (!fileUuid || vlmTriggering) return
vlmTriggering = true
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/file/${fileUuid}/vlm-summary`, { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: any = await res.json()
console.log(`[VLM] started: ${data.pending || data.total_traces || '?'} traces`)
// Poll until done
const poll = async () => {
await loadFileStats(fileUuid, true)
const s = getFileStats(fileUuid)?.vlm_summary
if (s && s.done >= s.total) {
console.log(`[VLM] complete: ${s.total}/${s.total}`)
return
}
setTimeout(poll, 3000)
}
poll()
} catch (e: any) {
console.error('VLM trigger failed:', e)
alert(t('library.processor.vlm_failed', { message: e?.message || e }))
} finally {
vlmTriggering = false
}
}
function sceneVlmStats(fileUuid: string | undefined) {
if (!fileUuid) return null
return getFileStats(fileUuid)?.scene_vlm || null
}
function hasSceneVlm(fileUuid: string | undefined): boolean {
const s = sceneVlmStats(fileUuid)
return s !== null && s.total_scenes > 0
}
function sceneVlmClass(fileUuid: string | undefined): string {
const s = sceneVlmStats(fileUuid)
if (!s || s.total_scenes === 0) return ''
if (s.status === 'ready') return 'ms-proc-done'
return 'ms-proc-running'
}
function sceneVlmIcon(fileUuid: string | undefined): string {
const s = sceneVlmStats(fileUuid)
if (!s || s.total_scenes === 0) return '—'
if (s.status === 'ready') return '✓'
return ''
}
function sceneVlmLabel(fileUuid: string | undefined): string {
const s = sceneVlmStats(fileUuid)
if (!s) return '—'
if (s.total_scenes === 0) return '—'
return `(${s.total_scenes})`
}
async function viewScenesJson() {
const f = ctxMenu.value.file
if (!f?.file_uuid) return
processorJsonTitle.value = `SCENES — ${f.file_name}`
processorJsonContent.value = 'Loading...'
processorJsonDialog.value = true
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/processor-json?file_hash=${f.file_uuid}&processor=scenes`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: any = await res.json()
processorJsonContent.value = JSON.stringify(data, null, 2)
} catch (e: any) {
processorJsonContent.value = `Error: ${e?.message || e}`
}
}
function getProcNodeClass(r: any): string {
if (r.processor === 'ASR') {
if (!r.exists) return 'ms-qc-missing'
const status = r.asrStatus
if (status === 'no_audio_track') return 'ms-qc-warn'
return 'ms-qc-ok'
}
return r.exists ? 'ms-qc-ok' : 'ms-qc-missing'
}
function getProcIcon(r: any): string {
if (r.processor === 'ASR') {
if (!r.exists) return '✗'
return '✓'
}
return r.exists ? '✓' : '✗'
}
function getAsrStatusLabel(r: any): string {
if (r.processor !== 'ASR' || !r.exists) return ''
const status = r.asrStatus
if (status === 'no_audio_track') return t('library.qc.no_audio')
if (status === 'silent_audio') return t('library.qc.no_speech')
return ''
}
const showQCModal = ref(false)
const qcFile = ref<any>(null)
const qcResults = ref<{processor: string, exists: boolean, size?: number, path?: string, asrStatus?: string}[]>([])
const qcAudioInfo = ref<{hasAudio: boolean, hasSpeech: boolean, language?: string, languageProb?: number, sampleRate?: number}>({hasAudio: false, hasSpeech: false})
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
if (data.segments) return data.segments.length
if (data.faces) return data.faces.length
if (data.frames) return data.frame_count || data.frames.length
if (data.chunks) return data.chunks.length
if (data.traces) return data.traces.length
if (data.cuts) return data.cuts.length
if (data.clusters) return data.clusters.length
return 0
}
async function refreshSystemStatus() {
try {
const health: any = await apiCall('get_health', {})
systemStatus.value.worker_running = health?.worker_running || false
systemStatus.value.watcher_running = health?.watcher_running || false
serviceStatus.value.core3002 = true // If health works, 3002 is up
} catch (e) {
serviceStatus.value.core3002 = false
console.error('Failed to get system status:', e)
}
// Load jobs
try {
const jobsResult: any = await apiCall('list_jobs', {})
jobsList.value = jobsResult?.jobs || []
// Try to get file names from files
for (const job of jobsList.value) {
const file = files.value?.find((f: any) => f.file_uuid === job.uuid)
if (file) job.file_name = file.file_name
// Load output files for this job
job.outputs = []
const processors = ['cut', 'asr', 'asrx', 'face', 'ocr', 'appearance', 'face_cluster']
for (const proc of processors) {
try {
const data: any = await apiCall('get_processor_json', { fileHash: job.uuid, processor: proc })
if (data) {
const size = JSON.stringify(data).length
const count = getJsonCount(data, proc)
const mtime = Date.now() // Use current time as proxy since API doesn't return mtime
job.outputs.push({ name: proc + '.json', size, count, mtime })
}
} catch {}
}
// Sort outputs by mtime (newest first)
job.outputs.sort((a, b) => b.mtime - a.mtime)
}
} catch (e) {
console.error('Failed to load jobs:', e)
}
// Check 8888 proxy (current portal)
serviceStatus.value.proxy8888 = true // If we're running, proxy is up
// Check 5173 vite dev server
serviceStatus.value.vite5173 = await checkPort(5173)
// Check LLM services (simplified - just mark as ok if core is up)
serviceStatus.value.llm = serviceStatus.value.core3002
serviceStatus.value.vlm = serviceStatus.value.core3002
serviceStatus.value.embedding = serviceStatus.value.core3002
}
async function checkPort(port: number): Promise<boolean> {
try {
const res = await fetch(`http://localhost:${port}/`, { mode: 'no-cors', signal: AbortSignal.timeout(2000) })
return true
} catch {
return false
}
}
async function runProcessorQC(f: any) {
if (!f?.file_uuid) return
qcFile.value = f
qcResults.value = []
qcAudioInfo.value = { hasAudio: false, hasSpeech: false }
showQCModal.value = true
// Refresh system status when opening QC
refreshSystemStatus()
// Get probe info for audio track
try {
const probe: any = await apiCall('get_processor_json', { fileHash: f.file_uuid, processor: 'probe' })
const audioStream = probe?.streams?.find((s: any) => s.codec_type === 'audio')
qcAudioInfo.value.hasAudio = !!audioStream
qcAudioInfo.value.sampleRate = audioStream?.sample_rate || null
} catch (e) {
qcAudioInfo.value.hasAudio = false
}
const processors = ['cut', 'asr', 'asrx', 'face', 'ocr', 'appearance', 'face_cluster']
for (const proc of processors) {
const counts = getProcessorCounts(f.file_uuid)
const info = counts[proc]
let asrStatus = ''
if (proc === 'asr' && info?.has_json) {
try {
const json: any = await apiCall('get_processor_json', { fileHash: f.file_uuid, processor: 'asr' })
asrStatus = getAsrStatus(json)
qcAudioInfo.value.hasSpeech = json?.segments?.length > 0
qcAudioInfo.value.language = json?.language || ''
qcAudioInfo.value.languageProb = json?.language_probability || 0
} catch (e) {
asrStatus = 'processing'
}
}
qcResults.value.push({
processor: proc.toUpperCase(),
exists: info?.has_json || false,
size: info?.frame_count || info?.segment_count || 0,
asrStatus
})
}
}
async function redoProcessor(proc: string) {
if (!ctxMenu.value.file?.file_uuid) return
const fileUuid = ctxMenu.value.file.file_uuid
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (!f) return
if (!confirm(t('library.confirm.rerun_processor', { processor: proc.toUpperCase() }))) return
const oldStatus = f.status
try {
f.status = 'preparing'
fileStatusCache.value[f.file_uuid] = 'preparing'
const result = await apiCall('process_file', { fileUuid, processors: [proc] })
if (result.success) {
await syncFileStatus(f.file_uuid)
alert(t('library.processor.started', { processor: proc.toUpperCase() }))
} else {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.failed', { message: result.message }))
}
} catch (e: any) {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.error', { message: e.message }))
}
}
async function redoProcessorFromQC(proc: string) {
if (!qcFile.value?.file_uuid) {
alert(t('library.processor.no_file'))
return
}
const fileUuid = qcFile.value.file_uuid
const f = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (!f) return
if (!confirm(t('library.confirm.run_processor', { processor: proc.toUpperCase(), filename: qcFile.value.title }))) return
const oldStatus = f.status
try {
f.status = 'preparing'
fileStatusCache.value[f.file_uuid] = 'preparing'
const result = await apiCall('process_file', { fileUuid, processors: [proc] })
if (result.success) {
await syncFileStatus(f.file_uuid)
alert(t('library.processor.started', { processor: proc.toUpperCase() }))
setTimeout(() => {
runProcessorQC(qcFile.value)
}, 2000)
} else {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.failed', { message: result.message || result.error || 'Unknown error' }))
}
} catch (e: any) {
f.status = oldStatus
fileStatusCache.value[f.file_uuid] = oldStatus
alert(t('library.processor.error', { message: e.message }))
console.error('[QC] Redo failed:', e)
}
}
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 {
const key = `library.status.${status}`
const translated = t(key)
return translated !== key ? translated : (status || '—')
}
function statusBadgeClass(f: any): string {
if (f.status === 'preparing') return 'ms-badge-off'
if (f.status === 'registered') return 'ms-badge-warn'
if (f.status === 'scanning') return 'ms-badge-proc'
if (f.status === 'paused') return 'ms-badge-paused'
if (f.status === 'keyword_ready' || f.status === 'semantic_ready') return 'ms-badge-part'
if (f.status === 'face_mgmt_ready') return 'ms-badge-prog'
if (f.status === 'completed') return 'ms-badge-done'
return 'ms-badge-off'
}
function statusBadgeIcon(f: any): string {
if (f.status === 'preparing') return '⏳'
if (f.status === 'registered') return '📋'
if (f.status === 'scanning') return '🔍'
if (f.status === 'paused') return '⏸'
if (f.status === 'keyword_ready') return '🔎'
if (f.status === 'semantic_ready') return '🧠'
if (f.status === 'face_mgmt_ready') return '🎭'
if (f.status === 'completed') return '✅'
return '⏺️'
}
function statusBadgeLabel(f: any): string {
if (!f) return ''
return statusFromDB(f.status)
}
function ingestedBadgeClass(f: any): string {
if (f.ingested) return 'ms-badge-done'
if (f.isRegistered) return 'ms-badge-warn'
return 'ms-badge-off'
}
function ingestedBadgeIcon(f: any): string {
if (f.ingested) return '🗄️'
if (f.isRegistered) return '📦'
return '📦'
}
function ingestedBadgeLabel(f: any): string {
if (f.ingested) return 'Ingested'
if (f.isRegistered) return 'Not Ingested'
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()
ctxMenu.value = {
show: true,
x: Math.min(e.clientX, window.innerWidth - 360),
y: e.clientY,
file: f
}
await nextTick()
const menu = document.querySelector('.ms-ctx-menu') as HTMLElement
if (menu) {
const rect = menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
ctxMenu.value.y = Math.max(10, e.clientY - overflow - 20)
}
}
if (f.isRegistered && f.file_uuid) {
statsLoading.value = true
try {
await Promise.all([
loadPipelineStats(f.file_uuid, true),
loadFileStats(f.file_uuid, true),
loadProcessorCounts(f.file_uuid, true)
])
ctxMenu.value.file = { ...f }
} catch (e) {
console.error('Failed to load stats:', e)
} finally {
statsLoading.value = false
}
}
}
function closeContextMenu() {
ctxMenu.value.show = false
}
async function ctxRegister(f: any) {
closeContextMenu()
if (registering.value) return
confirmTarget.value = { mode: 'register', file: f }
confirmDialog.value = true
}
async function doRegister(f: any) {
if (registering.value) return
registering.value = true
actionMsg.value = 'Registering...'
const filePath = f.file_path || f.relative_path || f.file_name
if (!filePath) { registering.value = false; actionMsg.value = 'Missing file path'; return }
try {
const result: any = await apiCall('register_file', { filePath })
if (result.success && result.command_id) {
actionMsg.value = `Registration queued...`
const finalStatus = await pollCommandStatus(result.command_id)
const fileUuid = finalStatus.file_uuid
if (fileUuid) {
actionMsg.value = `Registered: ${f.file_name}`
f.isRegistered = true
f.file_uuid = fileUuid
clearFileStatusCache(fileUuid)
await loadFiles()
const procs = selectedProcessors()
if (procs.length && autoScanAfterRegister.value) {
actionMsg.value = 'Scanning...'
// Immediately update status to show user the action started
f.status = 'scanning'
fileStatusCache.value[fileUuid] = 'scanning'
const procResult: any = await apiCall('process_file', { fileUuid: fileUuid, processors: procs })
if (procResult.success) {
actionMsg.value = `Scanning started: ${f.file_name}`
syncFileStatus(fileUuid)
} else {
actionMsg.value = `Scan failed: ${procResult.message}`
// Revert status on failure
f.status = 'registered'
fileStatusCache.value[fileUuid] = 'registered'
}
} else {
actionMsg.value = `Registered: ${f.file_name}`
}
} else {
actionMsg.value = `Registration completed but no file_uuid`
}
} else {
actionMsg.value = `Registration failed: ${result.message}`
}
} catch (e: any) {
actionMsg.value = `Registration error: ${e}`
console.error('[register]', e)
}
registering.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function batchRegister() {
if (registering.value) return
if (selectedUnregisteredFiles.value.length === 0) return
const filesToRegister = selectedUnregisteredFiles.value
const procs = selectedProcessors()
registering.value = true
actionMsg.value = `Registering ${filesToRegister.length} files...`
let successCount = 0
let failCount = 0
for (const f of filesToRegister) {
const filePath = f.file_path || f.relative_path || f.file_name
if (!filePath) {
failCount++
continue
}
try {
const result: any = await apiCall('register_file', { filePath })
if (result.success) {
if (result.command_id) {
await pollCommandStatus(result.command_id)
}
const fileUuid = result.file_uuid
if (fileUuid && procs.length && autoScanAfterRegister.value) {
await apiCall('process_file', { fileUuid, processors: procs })
}
successCount++
} else {
failCount++
}
} catch (e: any) {
failCount++
console.error('[batchRegister] Failed:', f.file_name, e)
}
}
selectedFiles.value = []
registering.value = false
if (successCount > 0) {
actionMsg.value = `Registered ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
} else {
actionMsg.value = `Failed to register all ${failCount} files`
}
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function batchDeepScan() {
if (batchProcessing.value) return
if (selectedRegisteredFiles.value.length === 0) return
const filesToProcess = selectedRegisteredFiles.value
if (!confirm(`Deep scan ${filesToProcess.length} files?\n\nThis will process all selected files with default settings.`)) return
batchProcessing.value = true
const maxConcurrent = 2
const limit = pLimit(maxConcurrent)
const immediateCount = Math.min(filesToProcess.length, maxConcurrent)
const queueTotal = Math.max(0, filesToProcess.length - maxConcurrent)
let processing = 0
let successCount = 0
let failCount = 0
actionMsg.value = `Starting: ${immediateCount} immediate, ${queueTotal} queuing...`
async function processFile(f: any) {
const fileUuid = f.file_uuid
if (!fileUuid) {
failCount++
return
}
processing++
const queueCount = limit.pendingCount
const activeCount = limit.activeCount
if (queueCount > 0) {
actionMsg.value = `Processing ${successCount + 1}/${filesToProcess.length} (${queueCount} queuing, ${activeCount} active)...`
} else {
actionMsg.value = `Processing ${successCount + 1}/${filesToProcess.length} (${activeCount} active)...`
}
try {
const file = filesCache.value.find((x: any) => x.file_uuid === fileUuid)
if (!file) {
failCount++
processing--
return
}
const result: any = await apiCall('re_scan_file', {
fileUuid: fileUuid,
autoPipeline: true
})
if (result.success) {
successCount++
if (file) {
file.status = 'scanning'
fileStatusCache.value[fileUuid] = 'scanning'
await syncFileStatus(fileUuid)
}
// 等待 2 秒,确保后端开始处理并释放资源
await new Promise(resolve => setTimeout(resolve, 2000))
} else {
failCount++
}
} catch (e: any) {
failCount++
console.error('[batchDeepScan] Failed:', f.file_name, e)
} finally {
processing--
}
}
const tasks = filesToProcess.map((f) => limit(() => processFile(f)))
await Promise.all(tasks)
selectedFiles.value = []
batchProcessing.value = false
if (successCount > 0) {
actionMsg.value = `Deep scan started for ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
} else {
actionMsg.value = `Failed to start deep scan for all ${failCount} files`
}
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function batchUnregister() {
if (unregistering.value) return
if (selectedRegisteredFiles.value.length === 0) return
const filesToUnregister = selectedRegisteredFiles.value
if (!confirm(t('library.confirm.batch_unregister_message', { count: filesToUnregister.length }))) return
unregistering.value = true
actionMsg.value = `Unregistering ${filesToUnregister.length} files...`
let successCount = 0
let failCount = 0
for (const f of filesToUnregister) {
const fileUuid = f.file_uuid
if (!fileUuid) {
failCount++
continue
}
try {
const result: any = await apiCall('unregister_file', { fileUuid, deleteOutputFiles: true })
if (result.success) {
successCount++
} else {
failCount++
}
} catch (e: any) {
failCount++
console.error('[batchUnregister] Failed:', f.file_name, e)
}
}
selectedFiles.value = []
unregistering.value = false
if (successCount > 0) {
actionMsg.value = `Unregistered ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
} else {
actionMsg.value = `Failed to unregister all ${failCount} files`
}
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function quickRegister(f: any) {
if (registering.value) return
const filePath = f.file_path || f.relative_path || f.file_name
if (!filePath) {
actionMsg.value = 'Missing file path'
setTimeout(() => { actionMsg.value = '' }, 8000)
return
}
registering.value = true
actionMsg.value = `Registering ${f.file_name}...`
try {
const result: any = await apiCall('register_file', { filePath })
if (result.success) {
if (result.command_id) {
await pollCommandStatus(result.command_id)
}
const fileUuid = result.file_uuid
const procs = selectedProcessors()
if (fileUuid && procs.length && autoScanAfterRegister.value) {
await apiCall('process_file', { fileUuid, processors: procs })
}
actionMsg.value = `Registered: ${f.file_name}`
} else {
actionMsg.value = `Failed to register: ${result.message}`
}
} catch (e: any) {
actionMsg.value = `Error: ${e.message || e}`
}
registering.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
async function ctxProcess(f: any) {
closeContextMenu()
if (processing.value) return
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
const procs = selectedProcessors()
if (!procs.length) { actionMsg.value = 'Please select at least one processor'; return }
confirmTarget.value = { mode: 'process', file: f }
confirmDialog.value = true
}
async function doProcess(f: any) {
if (processing.value) return
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
processing.value = true
const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid)
if (!file) {
actionMsg.value = 'File not found in cache'
processing.value = false
return
}
const oldStatus = file.status
try {
file.status = 'preparing'
fileStatusCache.value[file.file_uuid] = 'preparing'
actionMsg.value = '🔍 Starting deep scan...'
const result = await apiCall('re_scan_file', {
fileUuid: file.file_uuid,
autoPipeline: true
})
if (result.success) {
file.status = 'scanning'
fileStatusCache.value[file.file_uuid] = 'scanning'
actionMsg.value = '✅ Deep scan started'
await syncFileStatus(file.file_uuid)
} else {
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Failed: ${result.message || result.error}`
}
} catch (e: any) {
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Error: ${e.message}`
console.error('[deep-scan]', e)
} finally {
processing.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 5000)
}
}
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 ctxReprocess(f: any) {
closeContextMenu()
if (reprocessing.value) return
confirmTarget.value = { mode: 'reprocess', file: f }
confirmDialog.value = true
}
async function doReprocess(f: any) {
if (reprocessing.value) return
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
reprocessing.value = true
const file = filesCache.value.find((x: any) => x.file_uuid === f.file_uuid)
if (!file) {
actionMsg.value = 'File not found in cache'
reprocessing.value = false
return
}
const oldStatus = file.status
try {
file.status = 'preparing'
fileStatusCache.value[file.file_uuid] = 'preparing'
actionMsg.value = '🔍 Starting deep scan...'
const result = await apiCall('re_scan_file', {
fileUuid: file.file_uuid,
autoPipeline: true
})
if (result.success) {
file.status = 'scanning'
fileStatusCache.value[file.file_uuid] = 'scanning'
// Remove from paused set
pausedFileUuids.value = new Set([...pausedFileUuids.value].filter(uuid => uuid !== file.file_uuid))
actionMsg.value = '✅ Deep scan started'
await syncFileStatus(file.file_uuid)
} else {
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Failed: ${result.message || result.error}`
}
} catch (e: any) {
file.status = oldStatus
fileStatusCache.value[file.file_uuid] = oldStatus
actionMsg.value = `Error: ${e.message}`
console.error('[re-scan]', e)
} finally {
reprocessing.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 5000)
}
}
async function ctxUnregister(f: any) {
closeContextMenu()
if (!confirm(t('library.confirm.unregister_message', { filename: f.file_name }))) return
if (unregistering.value) return
if (!f.isRegistered) { actionMsg.value = 'File not registered'; return }
unregistering.value = true
actionMsg.value = 'Removing...'
try {
const result: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true })
if (result.success) {
if (result.command_id) {
actionMsg.value = 'Removing in background...'
await pollCommandStatus(result.command_id)
actionMsg.value = `Removed: ${f.file_name}`
} else {
actionMsg.value = `Removed: ${f.file_name}`
}
} else {
actionMsg.value = `Removal failed: ${result.message}`
}
} catch (e: any) {
actionMsg.value = `Removal error: ${e}`
console.error('[unregister]', e)
}
unregistering.value = false
await loadFiles()
setTimeout(() => { actionMsg.value = '' }, 8000)
}
// Pause / Resume functions
async function ctxPause(f: any) {
closeContextMenu()
if (!f?.file_uuid) { console.error('[ctxPause] no file_uuid'); return }
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/pause`, { method: 'POST' })
if (res.ok) {
pausedFileUuids.value = new Set(pausedFileUuids.value).add(f.file_uuid)
// Reload files to get synced status from backend (PostgreSQL trigger syncs videos.status)
await loadFiles()
await loadPausedFiles()
actionMsg.value = `⏸ Paused: ${f.file_name}`
} else {
const err = await res.json().catch(() => ({}))
actionMsg.value = `Pause failed: ${err.error || res.statusText}`
}
} catch (e: any) {
actionMsg.value = `Pause error: ${e.message}`
console.error('[ctxPause]', e)
}
setTimeout(() => { actionMsg.value = '' }, 5000)
}
async function ctxResume(f: any) {
closeContextMenu()
if (!f?.file_uuid) return
try {
const base = getApiBase()
const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/resume`, { method: 'POST' })
if (res.ok) {
pausedFileUuids.value = new Set([...pausedFileUuids.value].filter(uuid => uuid !== f.file_uuid))
actionMsg.value = `▶ Resumed: ${f.file_name}`
await loadFiles()
await loadPausedFiles()
} else {
const err = await res.json().catch(() => ({}))
actionMsg.value = `Resume failed: ${err.error || res.statusText}`
}
} catch (e: any) {
actionMsg.value = `Resume error: ${e.message}`
console.error('[ctxResume]', e)
}
setTimeout(() => { actionMsg.value = '' }, 5000)
}
async function batchPause() {
const filesToPause = selectedScanningFiles.value
if (filesToPause.length === 0) return
const base = getApiBase()
const limit = pLimit(3)
let successCount = 0
let failCount = 0
actionMsg.value = `⏸ Pausing ${filesToPause.length} files...`
async function pauseFile(f: any) {
try {
const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/pause`, { method: 'POST' })
if (res.ok) {
successCount++
} else {
failCount++
}
} catch (e) {
failCount++
console.error('[batchPause] Failed:', f.file_name, e)
}
}
await Promise.all(filesToPause.map(f => limit(() => pauseFile(f))))
if (successCount > 0) {
// Reload files to get synced status from backend (PostgreSQL trigger syncs videos.status)
await loadFiles()
await loadPausedFiles()
actionMsg.value = `⏸ Paused ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
} else {
actionMsg.value = `Failed to pause all ${failCount} files`
}
selectedFiles.value = []
setTimeout(() => { actionMsg.value = '' }, 5000)
}
async function batchResume() {
const filesToResume = selectedPausedFiles.value
if (filesToResume.length === 0) return
const base = getApiBase()
const limit = pLimit(3)
let successCount = 0
let failCount = 0
actionMsg.value = `▶ Resuming ${filesToResume.length} files...`
async function resumeFile(f: any) {
try {
const res = await fetch(`${base}/api/v1/jobs/${f.file_uuid}/resume`, { method: 'POST' })
if (res.ok) {
successCount++
} else {
failCount++
}
} catch (e) {
failCount++
console.error('[batchResume] Failed:', f.file_name, e)
}
}
await Promise.all(filesToResume.map(f => limit(() => resumeFile(f))))
if (successCount > 0) {
await loadFiles()
await loadPausedFiles()
actionMsg.value = `▶ Resumed ${successCount} files${failCount > 0 ? `, ${failCount} failed` : ''}`
} else {
actionMsg.value = `Failed to resume all ${failCount} files`
}
selectedFiles.value = []
setTimeout(() => { actionMsg.value = '' }, 5000)
}
function resetFilters() {
sortBy.value = 'time_desc'
filterUnregistered.value = false
filterRegistered.value = false
filterScanning.value = false
filterReady.value = false
onlyVideos.value = false
onlyPhotos.value = false
sizeMin.value = null
sizeMax.value = null
durationMin.value = null
durationMax.value = null
displayFilter.value = 'all'
filterText.value = ''
}
</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; 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); }
.ms-fm-icon-btn.ms-advanced-active { border-color: #4285f4; color: #4285f4; background: rgba(66, 133, 244, 0.1); }
.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: 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; }
.ms-fm-toolbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.ms-fm-radio-label { font-size: 13px; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; cursor: pointer; min-height: 24px; margin-bottom: 4px; }
.ms-fm-radio-label input { margin: 0; accent-color: var(--primary-color); }
.ms-fm-sort-panel { position: absolute; top: 44px; right: 0; z-index: 998; background: var(--card-background); border-radius: 12px; box-shadow: var(--shadow); padding: 12px 16px; min-width: 200px; border: 1px solid var(--border-color); display: none; }
.ms-fm-sort-panel.show { display: block; }
.ms-fm-toolbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; position: relative; }
.ms-fm-sort-section { margin-bottom: 12px; }
.ms-fm-sort-section:last-child { margin-bottom: 0; }
.ms-fm-sort-title { font-size: 11.5px; font-weight: 600; color: var(--muted-text); text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
.ms-fm-processor-group { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding-left: 8px; border-left: 1.5px solid var(--border-light); }
.ms-fm-check-label { font-size: 12px; color: var(--text-secondary); display: flex; align-items: center; gap: 4px; cursor: pointer; }
.ms-fm-check-label input { margin: 0; accent-color: var(--text-primary); }
.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; position: relative; }
.ms-fm-search { width: 100%; height: 42px; border: 1px solid var(--border-color); border-radius: 14px; padding: 0 32px 0 14px; font-size: 14px; background: var(--card-background); color: var(--text-primary); outline: none; font-family: inherit; }
.ms-fm-search-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; border: none; background: var(--border-light); border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--text-secondary); padding: 0; line-height: 1; }
.ms-fm-search-clear:hover { background: var(--border-color); color: var(--text-primary); }
.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); }
.ms-ctx-menu { position: fixed !important; z-index: 99999 !important; background: var(--card-background); border-radius: 12px; box-shadow: var(--shadow); padding: 6px; min-width: 280px; max-width: 360px; max-height: 80vh; overflow-y: auto; font-size: 13px; color: var(--text-primary); border: 1px solid var(--border-color); }
.ms-ctx-menu::-webkit-scrollbar { width: 4px; }
.ms-ctx-menu::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
.ms-ctx-header { padding: 8px 10px 4px; }
.ms-ctx-filename { font-weight: 600; font-size: 13px; color: var(--text-primary); word-break: break-all; }
.ms-ctx-info { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
.ms-ctx-done { color: var(--success-color); font-weight: 600; }
.ms-ctx-pending { color: var(--warning-color); font-weight: 600; }
.ms-ctx-none { color: var(--muted-text); }
.ms-ctx-divider { height: 1px; background: var(--border-light); margin: 4px 8px; border: none; }
.ms-ctx-item { display: block; width: 100%; text-align: left; padding: 7px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary); border: none; background: none; border-radius: 6px; font-weight: 500; }
.ms-ctx-item:hover { background: var(--hover-background); }
.ms-ctx-danger { color: var(--danger-color) !important; }
.ms-ctx-danger:hover { background: var(--danger-background) !important; }
.ms-ctx-warning { color: var(--warning-color) !important; }
.ms-ctx-warning:hover { background: var(--warning-background) !important; }
.ms-ctx-success { color: var(--success-color) !important; }
.ms-ctx-success:hover { background: var(--success-background) !important; }
.ms-ctx-procs { padding: 6px 10px 8px; display: flex; flex-wrap: wrap; gap: 6px 10px; }
.ms-ctx-procs-title { width: 100%; font-size: 11px; color: var(--text-secondary); font-weight: 600; margin-bottom: 2px; }
.ms-ctx-progress { padding: 8px 12px; background: var(--hover-background); border-radius: 8px; margin-top: 8px; }
.ms-ctx-progress-title { font-size: 11px; color: var(--text-secondary); font-weight: 600; margin-bottom: 6px; }
.ms-ctx-progress-bar-wrap { position: relative; height: 20px; background: var(--border-light); border-radius: 4px; overflow: hidden; }
.ms-ctx-progress-bar { height: 100%; background: linear-gradient(90deg, var(--primary-color), #4285f4); border-radius: 4px; transition: width 0.3s; }
.ms-ctx-progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: var(--text-primary); }
.ms-ctx-proc-status { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 11px; }
.ms-ctx-proc-name { font-weight: 600; color: var(--primary-color); }
.ms-ctx-proc-detail { color: var(--text-secondary); }
.ms-proc-done { color: var(--success-color); }
.ms-proc-done .ms-proc-count { color: var(--success-color); font-weight: 600; }
.ms-proc-warning { background: rgba(255, 180, 0, 0.08); border-radius: 4px; padding: 2px 4px; }
.ms-proc-warning .ms-proc-status { color: #f59e0b; }
.ms-proc-warning .ms-proc-count { color: #f59e0b; font-weight: 600; }
.ms-proc-line { display: flex; align-items: center; }
.ms-face-th-input { margin-left: auto; width: 90px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; color: var(--text-primary); background: var(--bg-card); }
.ms-proc-count { margin-left: 4px; font-size: 11px; color: var(--text-secondary); }
.ms-proc-clickable { cursor: pointer; text-decoration: underline; }
.ms-proc-clickable:hover { color: var(--primary-color); }
.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; }
.ms-fm-card:hover .ms-fm-thumb-wrap { border-radius: 0; }
.ms-fm-card:hover .ms-fm-thumb { transform: scale(1.02); }
.ms-fm-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: var(--border-light); aspect-ratio: 4 / 3; border: 1px solid var(--border-color); box-shadow: 0 0 0 2px var(--border-color) inset; transition: border-radius 0.2s; }
.ms-fm-thumb { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.2s; }
.ms-fm-thumb-loading { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: var(--muted-text); animation: spin 1s linear infinite; }
.ms-fm-thumb-unregistered { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: linear-gradient(180deg, var(--hover-background), var(--border-light)); padding: 20px; text-align: center; }
.ms-fm-unregistered-icon { font-size: 3rem; opacity: 0.6; }
.ms-fm-unregistered-text { font-size: 11px; color: var(--text-secondary); opacity: 0.8; line-height: 1.4; }
@keyframes spin { to { transform: rotate(360deg); } }
.ms-fm-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, var(--hover-background), var(--border-light)); color: var(--text-secondary); text-align: center; padding: 14px; }
.ms-fm-doc-icon { font-size: 38px; line-height: 1; }
.ms-fm-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: var(--text-secondary); text-transform: uppercase; }
.ms-fm-badge { position: absolute; top: 8px; left: 8px; font-size: 10px; line-height: 1; padding: 4px 6px; border-radius: 999px; background: rgba(17,24,39,.75); color: #fff; letter-spacing: .3px; }
.ms-fm-check { position: absolute; right: 10px; bottom: 10px; width: 28px; height: 28px; border-radius: 50%; background: rgba(17,24,39,.78); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; z-index: 9; }
.ms-fm-card.is-selected .ms-fm-thumb-wrap { border-color: var(--primary-color) !important; background: rgba(var(--primary-color-rgb), 0.1) !important; box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.3) inset !important; }
.ms-fm-card.is-selected .ms-fm-doc-thumb { background: linear-gradient(180deg, rgba(var(--primary-color-rgb), 0.1), rgba(var(--primary-color-rgb), 0.2)) !important; }
.ms-fm-meta { padding-top: 6px; }
.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-face-btn { position: absolute; top: 50%; left: 8px; transform: translateY(-50%); height: 28px; padding: 0 8px; border-radius: 14px; border: none; background: rgba(17,24,39,.78); color: #fff; cursor: pointer; z-index: 9; display: flex; align-items: center; justify-content: center; gap: 4px; transition: background .15s, transform .15s; }
.ms-fm-face-btn:hover { background: var(--primary-color); transform: translateY(-50%) scale(1.1); }
.ms-fm-face-count { font-size: 11px; font-weight: 500; }
.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 */
.ms-fm-badges { position: absolute; bottom: 8px; left: 8px; display: flex; flex-direction: column; gap: 2px; z-index: 9; }
.ms-fm-badge-row { font-size: 9px; line-height: 1.2; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: 0.2px; white-space: nowrap; }
.ms-badge-ok { background: var(--primary-color); color: #fff; }
.ms-badge-off { background: rgba(0,0,0,.48); color: #ccc; }
.ms-badge-proc { background: var(--primary-color); color: #fff; animation: badge-pulse 1.2s ease-in-out infinite; }
.ms-badge-done { background: var(--success-color); color: #fff; }
.ms-badge-warn { background: var(--warning-color); color: var(--text-primary); }
.ms-badge-fail { background: var(--danger-color); color: #fff; }
.ms-badge-paused { background: var(--warning-color); color: var(--text-primary); }
@keyframes badge-pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
/* Three-axis status bar */
.ms-fm-axis-item strong { color: var(--text-primary); }
.ms-fm-axis-sep { color: var(--border-color); }
.ms-fm-axis-paused strong { color: var(--warning-color, #ffc107) !important; }
/* Section titles */
.ms-fm-section { margin-bottom: 24px; }
.ms-fm-section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; padding-left: 4px; display: flex; align-items: center; gap: 12px; }
.ms-batch-register-btn { margin-left: auto; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer; }
.ms-batch-register-btn:hover { background: var(--primary-hover); }
.ms-batch-scan-btn { margin-left: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--info-color, #17a2b8); color: white; border: none; border-radius: 6px; cursor: pointer; }
.ms-batch-scan-btn:hover { background: var(--info-hover, #138496); }
.ms-batch-unregister-btn { margin-left: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--danger-color, #dc3545); color: white; border: none; border-radius: 6px; cursor: pointer; }
.ms-batch-unregister-btn:hover { background: var(--danger-hover, #c82333); }
.ms-batch-pause-btn { margin-left: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--warning-color, #ffc107); color: var(--text-primary); border: none; border-radius: 6px; cursor: pointer; }
.ms-batch-pause-btn:hover { background: var(--warning-hover, #e0a800); }
.ms-batch-resume-btn { margin-left: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; background: var(--success-color, #28a745); color: white; border: none; border-radius: 6px; cursor: pointer; }
.ms-batch-resume-btn:hover { background: var(--success-hover, #218838); }
.ms-fm-card-checkbox { position: absolute; bottom: 8px; right: 8px; width: 24px; height: 24px; border: 2px solid var(--border-color); border-radius: 4px; background: var(--card-background); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--primary-color); z-index: 10; }
.ms-fm-card-checkbox:hover { border-color: var(--primary-color); }
.ms-fm-card-register-btn { position: absolute; bottom: 8px; right: 40px; width: 28px; height: 28px; border: none; border-radius: 4px; background: var(--primary-color); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; opacity: 0; transition: opacity .2s, transform .15s; z-index: 10; }
.ms-fm-card-unregistered:hover .ms-fm-card-register-btn { opacity: 1; }
.ms-fm-card-register-btn:hover { background: var(--primary-hover); transform: scale(1.1); }
.ms-select-rect { position: fixed; z-index: 9999; border: 1.5px solid var(--primary-color); background: rgba(var(--primary-color-rgb), 0.15); pointer-events: none; border-radius: 4px; }
/* Unregistered card styles */
.ms-fm-card-unregistered { opacity: 0.85; }
.ms-fm-card-unregistered:hover { opacity: 1; }
.ms-fm-thumb-unreg { filter: grayscale(30%) brightness(0.85); }
.ms-fm-thumb-unreg-loading { filter: grayscale(50%); opacity: 0.6; }
.ms-fm-unreg-overlay { position: absolute; bottom: 36px; left: 8px; pointer-events: none; }
.ms-fm-unreg-label { font-size: 9px; padding: 2px 6px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; font-weight: 600; letter-spacing: 0.3px; }
/* 加载状态 */
.ms-ctx-loading { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 32px 0; }
.ms-ctx-spinner { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--primary-color); border-radius: 50%; animation: spin 0.8s linear infinite; }
.ms-ctx-loading-text { font-size: 13px; color: var(--text-secondary); }
/* 进度条和阶段样式 */
.ms-ctx-overall-progress { margin-bottom: 16px; }
.ms-ctx-progress-bar-wrap { position: relative; height: 20px; background: var(--border-light); border-radius: 10px; overflow: hidden; }
.ms-ctx-progress-bar { height: 100%; background: linear-gradient(90deg, var(--primary-color), var(--success-color)); transition: width 0.3s ease; }
.ms-ctx-progress-text { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 12px; font-weight: 600; color: var(--text-primary); }
.ms-ctx-stages { margin-bottom: 16px; }
.ms-ctx-stage { margin-bottom: 12px; }
.ms-ctx-stage-header { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-bottom: 4px; }
.ms-ctx-stage-icon { width: 16px; text-align: center; font-size: 12px; font-weight: 600; }
.ms-ctx-stage-name { flex: 1; font-weight: 500; color: var(--text-primary); }
.ms-ctx-stage-progress { color: var(--text-secondary); font-size: 12px; font-weight: 600; }
.ms-ctx-stage-bar-wrap { height: 6px; background: var(--border-light); border-radius: 3px; overflow: hidden; }
.ms-ctx-stage-bar { height: 100%; transition: width 0.3s ease; }
/* 详细数据样式 */
.ms-ctx-details { border-top: 1px solid var(--border-light); padding-top: 12px; }
.ms-ctx-phase { margin-bottom: 16px; }
.ms-ctx-phase:last-child { margin-bottom: 0; }
.ms-ctx-phase-title { font-size: 11px; color: var(--text-secondary); font-weight: 600; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.ms-ctx-proc-item, .ms-ctx-stat-item { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.ms-ctx-proc-name, .ms-ctx-stat-name { color: var(--text-primary); }
.ms-ctx-proc-count, .ms-ctx-stat-count { color: var(--text-secondary); font-weight: 500; }
.ms-ctx-proc-count { cursor: pointer; }
.ms-ctx-proc-count:hover { color: var(--primary-color); text-decoration: underline; }
/* Processor JSON viewer modal */
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 10000; display: flex; align-items: center; justify-content: center; }
.ms-modal-box { background: var(--card-background); border-radius: 12px; box-shadow: var(--shadow); max-height: 80vh; display: flex; flex-direction: column; }
.ms-modal-wide { width: 90vw; max-width: 900px; }
.ms-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-light); }
.ms-modal-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.ms-modal-close { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--text-secondary); padding: 4px 8px; border-radius: 4px; }
.ms-modal-close:hover { background: var(--hover-background); }
.ms-modal-body { padding: 20px; overflow: auto; flex: 1; }
.ms-json-view { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; color: var(--text-primary); margin: 0; }
/* Processor QC styles */
.ms-proc-status { font-weight: 600; margin-left: 4px; }
.ms-proc-done .ms-proc-status { color: #34a853; }
.ms-proc-redo { background: none; border: none; cursor: pointer; opacity: 0.5; font-size: 14px; margin-left: 8px; }
.ms-proc-redo:hover { opacity: 1; }
.ms-proc-qc-btn { background: none; border: none; cursor: pointer; font-size: 14px; margin-left: 8px; }
.ms-proc-qc-btn:hover { opacity: 0.7; }
.ms-modal-qc-wide { width: 90vw; max-width: 900px; }
.ms-qc-uuid { font-size: 11px; color: var(--text-secondary); margin-bottom: 16px; font-family: monospace; }
.ms-qc-track { margin-bottom: 20px; }
.ms-qc-track-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.ms-qc-track-icon { font-size: 18px; }
.ms-qc-track-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.ms-qc-flow { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.ms-qc-node { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 8px; background: var(--hover-background); font-size: 13px; }
.ms-qc-input { background: rgba(66, 133, 244, 0.1); color: #4285f4; font-weight: 500; }
.ms-qc-output { background: rgba(52, 168, 83, 0.1); color: #34a853; font-weight: 500; }
.ms-qc-ok { background: rgba(52, 168, 83, 0.1); }
.ms-qc-missing { background: rgba(234, 67, 53, 0.1); }
.ms-qc-warn { background: rgba(251, 188, 4, 0.15); }
.ms-qc-ok .ms-qc-icon { color: #34a853; }
.ms-qc-missing .ms-qc-icon { color: #ea4335; }
.ms-qc-warn .ms-qc-icon { color: #f9a825; }
.ms-qc-arrow { font-size: 18px; color: var(--text-secondary); }
/* System Status Bar */
.ms-qc-system-status {
display: flex;
gap: 12px;
padding: 10px 14px;
background: rgba(128, 128, 128, 0.08);
border-radius: 8px;
margin-bottom: 12px;
align-items: center;
flex-wrap: wrap;
}
.ms-qc-service {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.ms-qc-svc-ok { background: rgba(52, 168, 83, 0.15); color: #34a853; }
.ms-qc-svc-off { background: rgba(234, 67, 53, 0.15); color: #ea4335; }
.ms-qc-svc-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.ms-qc-svc-refresh {
margin-left: auto;
background: none;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary, #888);
}
.ms-qc-svc-refresh:hover { background: rgba(128, 128, 128, 0.1); }
/* Jobs Section */
.ms-qc-jobs-section {
background: var(--hover-background);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 12px;
}
.ms-qc-jobs-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ms-qc-jobs-icon { font-size: 14px; }
.ms-qc-jobs-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.ms-qc-jobs-count {
font-size: 11px;
background: rgba(128, 128, 128, 0.2);
padding: 2px 8px;
border-radius: 10px;
color: var(--text-secondary);
}
.ms-qc-jobs-list { display: flex; flex-direction: column; gap: 4px; }
.ms-qc-job {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(128, 128, 128, 0.05);
}
.ms-qc-job-id { font-weight: 600; color: var(--text-secondary); min-width: 40px; }
.ms-qc-job-status {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.ms-qc-job-running .ms-qc-job-status { background: rgba(66, 133, 244, 0.15); color: #4285f4; }
.ms-qc-job-completed .ms-qc-job-status { background: rgba(52, 168, 83, 0.15); color: #34a853; }
.ms-qc-job-failed .ms-qc-job-status { background: rgba(234, 67, 53, 0.15); color: #ea4335; }
.ms-qc-job-pending .ms-qc-job-status { background: rgba(251, 188, 4, 0.15); color: #f9a825; }
.ms-qc-job-file { flex: 1; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-qc-job-proc { font-size: 10px; color: var(--text-secondary); }
.ms-qc-job-empty { font-size: 12px; color: var(--text-secondary); text-align: center; padding: 8px; }
.ms-qc-job-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.ms-qc-job-progress {
font-size: 10px;
color: var(--text-secondary);
background: rgba(128, 128, 128, 0.1);
padding: 2px 6px;
border-radius: 8px;
}
.ms-qc-job-outputs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
padding-left: 50px;
}
.ms-qc-job-output {
font-size: 10px;
background: rgba(52, 168, 83, 0.1);
color: #34a853;
padding: 2px 8px;
border-radius: 4px;
}
.ms-qc-job-size { color: var(--text-secondary); font-weight: normal; }
.ms-qc-job { flex-direction: column; align-items: flex-start; }
.ms-qc-status-label { font-size: 11px; color: #f9a825; font-weight: 500; margin-left: 4px; }
.ms-qc-audio-info { background: var(--hover-background); border-radius: 8px; padding: 12px; margin-bottom: 16px; }
.ms-qc-audio-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.ms-qc-audio-icon { font-size: 18px; }
.ms-qc-audio-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.ms-qc-audio-grid { display: flex; flex-wrap: wrap; gap: 16px; }
.ms-qc-audio-item { display: flex; flex-direction: column; gap: 4px; }
.ms-qc-audio-label { font-size: 11px; color: var(--text-secondary); }
.ms-qc-audio-value { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.ms-qc-audio-ok { color: #34a853; }
.ms-qc-audio-none { color: #999; }
.ms-qc-audio-warn { color: #f9a825; }
.ms-qc-stage-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.ms-qc-divider { height: 1px; background: var(--border-light); margin: 16px 0; }
.ms-qc-ingest-grid { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
.ms-qc-ingest-box { background: var(--hover-background); border-radius: 8px; padding: 12px; min-width: 140px; }
.ms-qc-ingest-header { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.ms-qc-ingest-stat { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; }
.ms-qc-ingest-label { color: var(--text-secondary); }
.ms-qc-ingest-count { color: var(--text-primary); font-weight: 500; }
.ms-qc-status-change { display: flex; align-items: center; gap: 16px; padding: 12px 20px; background: var(--hover-background); border-radius: 8px; }
.ms-qc-status-from, .ms-qc-status-to { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.ms-qc-status-label { font-size: 11px; color: var(--text-secondary); }
.ms-qc-status-value { font-size: 14px; font-weight: 600; padding: 4px 12px; border-radius: 4px; }
.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; }
}
.ms-fm-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px 0; }
.ms-fm-loading-spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--primary-color); border-radius: 50%; animation: spin 0.8s linear infinite; }
.ms-fm-loading-text { margin-top: 16px; font-size: 14px; color: var(--text-secondary); }
@keyframes spin { to { transform: rotate(360deg); } }
</style>