- 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
2814 lines
131 KiB
Vue
2814 lines
131 KiB
Vue
<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>
|