feat: bind/unbind by id, video streaming proxy, unmute default, library refresh, hamburger click-outside
- Core API: bind/unbind accept id (integer PK) when face_id is null - Tauri proxy: stream video responses instead of buffering for seek bar - VideoPlayer: remove muted attribute, unmute by default - LibraryView: call invalidateFiles() before ensureFiles() on register/unregister - PeopleView: close sort panel on click-outside, not just hamburger toggle - bind_face: prefer face_id, fallback to id when face_id is null
This commit is contained in:
@@ -1,97 +1,131 @@
|
||||
<template>
|
||||
<div id="ms-files-page">
|
||||
<section class="mp-panel is-active">
|
||||
<div class="mp-toolbar">
|
||||
<div class="mp-toolbar-left">
|
||||
<button class="mp-btn mp-btn-primary" type="button" @click="addToPeople">Register</button>
|
||||
<button class="mp-btn mp-btn-icon" type="button" title="Refresh" @click="loadFiles">
|
||||
<span class="mp-refresh-icon">⟳</span>
|
||||
</button>
|
||||
<label class="mp-radio-row">
|
||||
<input type="radio" name="mp-display-filter" value="all" v-model="displayFilter"> All
|
||||
<section class="ms-fm-panel">
|
||||
<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="mp-radio-row">
|
||||
<input type="radio" name="mp-display-filter" value="video" v-model="displayFilter"> All Videos
|
||||
<label class="ms-fm-radio-label">
|
||||
<input type="radio" name="ms-display-filter" value="video" v-model="displayFilter"> Videos
|
||||
</label>
|
||||
<label class="mp-radio-row">
|
||||
<input type="radio" name="mp-display-filter" value="photo" v-model="displayFilter"> All Photos
|
||||
<label class="ms-fm-radio-label">
|
||||
<input type="radio" name="ms-display-filter" value="photo" v-model="displayFilter"> Photos
|
||||
</label>
|
||||
</div>
|
||||
<div class="mp-toolbar-right">
|
||||
<div class="mp-search-wrap">
|
||||
<input type="text" class="mp-search" v-model="filterText" placeholder="Search files / docs / videos">
|
||||
<div class="ms-fm-toolbar-right">
|
||||
<div class="ms-fm-search-wrap">
|
||||
<input type="text" class="ms-fm-search" v-model="filterText" placeholder="Search files / docs / videos">
|
||||
</div>
|
||||
<button class="mp-filter-btn" type="button" @click="showFilter = !showFilter">☰</button>
|
||||
<button class="ms-fm-icon-btn" type="button" @click="showFilter = !showFilter">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Popup -->
|
||||
<div class="mp-filter-pop" :class="{ show: showFilter }">
|
||||
<div class="mp-filter-title">排序方式</div>
|
||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_desc" v-model="sortBy" checked> 依時間・由近到遠</label>
|
||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_asc" v-model="sortBy"> 依時間・由遠到近</label>
|
||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_desc" v-model="sortBy"> 依大小・由大到小</label>
|
||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_asc" v-model="sortBy"> 依大小・由小到大</label>
|
||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_asc" v-model="sortBy"> 依檔名・由 A 到 Z</label>
|
||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_desc" v-model="sortBy"> 依檔名・由 Z 到 A</label>
|
||||
|
||||
<div class="mp-filter-title">過濾方式</div>
|
||||
<label class="mp-check-row"><input type="checkbox" v-model="filterUnregistered"> 未註冊</label>
|
||||
<label class="mp-check-row"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
|
||||
<label class="mp-check-row"><input type="checkbox" v-model="onlyVideos"> 僅顯示影片</label>
|
||||
<label class="mp-check-row"><input type="checkbox" v-model="onlyPhotos"> 僅顯示照片</label>
|
||||
|
||||
<div class="mp-filter-title">大小</div>
|
||||
<div class="mp-range-row">
|
||||
<input type="number" class="mp-filter-number" v-model.number="sizeMin" min="0" placeholder="最小 MB">
|
||||
<span>—</span>
|
||||
<input type="number" class="mp-filter-number" v-model.number="sizeMax" min="0" placeholder="最大 MB">
|
||||
<div class="ms-fm-sort-panel" :class="{ show: showFilter }">
|
||||
<div class="ms-fm-sort-section">
|
||||
<div class="ms-fm-sort-title">排序方式</div>
|
||||
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="time_desc" v-model="sortBy"> 依時間・由近到遠</label>
|
||||
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="time_asc" v-model="sortBy"> 依時間・由遠到近</label>
|
||||
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="size_desc" v-model="sortBy"> 依大小・由大到小</label>
|
||||
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="size_asc" v-model="sortBy"> 依大小・由小到大</label>
|
||||
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="name_asc" v-model="sortBy"> 依檔名・由 A 到 Z</label>
|
||||
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="name_desc" v-model="sortBy"> 依檔名・由 Z 到 A</label>
|
||||
</div>
|
||||
|
||||
<div class="mp-filter-title">影片時長</div>
|
||||
<div class="mp-range-row">
|
||||
<input type="number" class="mp-filter-number" v-model.number="durationMin" min="0" placeholder="最小分鐘">
|
||||
<span>—</span>
|
||||
<input type="number" class="mp-filter-number" v-model.number="durationMax" min="0" placeholder="最大分鐘">
|
||||
<div class="ms-fm-sort-section">
|
||||
<div class="ms-fm-sort-title">過濾方式</div>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterUnregistered"> 未註冊</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterPending"> 待處理</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterCompleted"> 已完成</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyVideos"> 僅顯示影片</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyPhotos"> 僅顯示照片</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="mp-filter-reset" @click="resetFilters">清除篩選</button>
|
||||
<div class="ms-fm-sort-section">
|
||||
<div class="ms-fm-sort-title">大小</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="最小 MB" style="width:100%;">
|
||||
<span>—</span>
|
||||
<input type="number" class="ms-fm-edit-input" v-model.number="sizeMax" min="0" placeholder="最大 MB" style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-fm-sort-section">
|
||||
<div class="ms-fm-sort-title">影片時長</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="最小分鐘" style="width:100%;">
|
||||
<span>—</span>
|
||||
<input type="number" class="ms-fm-edit-input" v-model.number="durationMax" min="0" placeholder="最大分鐘" style="width:100%;">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ms-fm-btn" style="width:100%;margin-top:8px;" @click="resetFilters">清除篩選</button>
|
||||
</div>
|
||||
|
||||
<div class="mp-status" :class="{ show: loading || statusText }">{{ statusText }}</div>
|
||||
<div class="ms-fm-status">{{ statusText }}<span v-if="actionMsg" class="ms-fm-action-msg">{{ actionMsg }}</span></div>
|
||||
|
||||
<div class="mp-grid">
|
||||
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="mp-file-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)">
|
||||
<div class="mp-thumb-wrap">
|
||||
<div class="mp-badge-type">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
||||
<img v-if="isPhoto(f) || isVideo(f)" v-show="thumbnails[f.file_uuid]" class="lt-thumb" :src="thumbnails[f.file_uuid]" alt="" @error="handleThumbError" @vue:mounted="loadThumbnail(f.file_uuid)">
|
||||
<div v-if="(isPhoto(f) || isVideo(f)) && !thumbnails[f.file_uuid]" class="mp-thumb-loading">⟳</div>
|
||||
<div v-else class="mp-doc-thumb">
|
||||
<span class="mp-doc-icon">📄</span>
|
||||
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
||||
<div class="ms-fm-grid">
|
||||
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="ms-fm-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)">
|
||||
<div class="ms-fm-thumb-wrap" v-observe="() => { if (isPhoto(f) || isVideo(f)) loadThumbnailLocal(f.file_uuid) }">
|
||||
<div class="ms-fm-badge">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
||||
<img v-if="isPhoto(f) || isVideo(f)" v-show="thumbnails[thumbKey(f.file_uuid)]" class="ms-fm-thumb" :src="thumbnails[thumbKey(f.file_uuid)]" alt="" @error="handleThumbError">
|
||||
<div v-if="(isPhoto(f) || isVideo(f)) && !thumbnails[thumbKey(f.file_uuid)]" class="ms-fm-thumb-loading">⟳</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="mp-play-btn" @click.stop="playVideo(f)">▶</button>
|
||||
<div class="mp-complete-mark" v-if="f.isRegistered">✓</div>
|
||||
<button v-if="isVideo(f)" class="ms-fm-play-btn" @click.stop="playVideo(f)">▶</button>
|
||||
<div class="ms-fm-check" v-if="f.isRegistered">✓</div>
|
||||
<div class="ms-fm-status-badge" v-if="f.status === 'completed'" style="background:#1e8e3e;color:#fff;">完成</div>
|
||||
<div class="ms-fm-status-badge" v-else-if="f.status === 'registered'" style="background:#f9ab00;color:#202124;">待處理</div>
|
||||
</div>
|
||||
<div class="mp-meta">
|
||||
<div class="mp-name">{{ f.file_name }}</div>
|
||||
<div class="mp-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</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>
|
||||
</section>
|
||||
|
||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
||||
<!-- 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">{{ ctxMenu.file?.file_name }}</div>
|
||||
<div class="ms-ctx-info">{{ formatSize(ctxMenu.file?.file_size || 0) }} · {{ formatDate(ctxMenu.file?.modified_time || '') }}</div>
|
||||
<div class="ms-ctx-info">狀態:<span :class="ctxMenu.file?.status === 'completed' ? 'ms-ctx-done' : ctxMenu.file?.status === 'registered' ? 'ms-ctx-pending' : 'ms-ctx-none'">{{ statusLabel(ctxMenu.file) }}</span></div>
|
||||
<div v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.file_uuid" class="ms-ctx-info">UUID:{{ ctxMenu.file.file_uuid?.slice(0, 12) }}...</div>
|
||||
</div>
|
||||
<hr class="ms-ctx-divider">
|
||||
<button v-if="!ctxMenu.file?.isRegistered" class="ms-ctx-item" @click="ctxRegister(ctxMenu.file)">📥 註冊此檔案</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.status !== 'completed'" class="ms-ctx-item" @click="ctxProcess(ctxMenu.file)">⚙️ 處理此檔案</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.status === 'completed'" class="ms-ctx-item" @click="ctxProcess(ctxMenu.file)">⚙️ 重新處理</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered" class="ms-ctx-item ms-ctx-danger" @click="ctxUnregister(ctxMenu.file)">🗑️ 移除註冊</button>
|
||||
<button v-if="isVideo(ctxMenu.file) && ctxMenu.file?.isRegistered" class="ms-ctx-item" @click="playVideo(ctxMenu.file); closeContextMenu()">▶ 播放影片</button>
|
||||
<template v-if="ctxMenu.file?.isRegistered">
|
||||
<hr class="ms-ctx-divider">
|
||||
<div class="ms-ctx-procs">
|
||||
<div class="ms-ctx-procs-title">處理器選擇</div>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procAsr"> ASR</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procYolo"> YOLO</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procFace"> Face</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procOcr"> OCR</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procPose"> Pose</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procCut"> CUT</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<VideoPlayer v-if="playing" simple :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { apiCall } from '@/api'
|
||||
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles } from '@/store'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const files = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const files = computed(() => filesCache.value)
|
||||
const loading = computed(() => !filesLoaded.value)
|
||||
const statusText = ref('')
|
||||
const filterText = ref('')
|
||||
const displayFilter = ref('all')
|
||||
@@ -100,12 +134,14 @@ const selectedFiles = ref<string[]>([])
|
||||
const playing = ref(false)
|
||||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
||||
|
||||
const thumbnails = ref<Record<string, string>>({})
|
||||
const thumbnailLoading = ref<Set<string>>(new Set())
|
||||
const thumbnails = computed(() => thumbnailsCache.value)
|
||||
const thumbKey = (uuid: string) => `${uuid}:30`
|
||||
|
||||
const sortBy = ref('time_desc')
|
||||
const filterUnregistered = ref(false)
|
||||
const filterRegistered = ref(true)
|
||||
const filterPending = ref(false)
|
||||
const filterCompleted = ref(false)
|
||||
const onlyVideos = ref(false)
|
||||
const onlyPhotos = ref(false)
|
||||
const sizeMin = ref<number | null>(null)
|
||||
@@ -130,7 +166,8 @@ const sortedFilteredFiles = computed(() => {
|
||||
// Checkbox filters
|
||||
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered)
|
||||
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.isRegistered)
|
||||
// 兩者都不勾選或都勾選:顯示全部
|
||||
if (filterPending.value) result = result.filter((f: any) => f.status === 'registered')
|
||||
if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed')
|
||||
|
||||
if (onlyVideos.value) result = result.filter(isVideo)
|
||||
if (onlyPhotos.value) result = result.filter(isPhoto)
|
||||
@@ -166,25 +203,21 @@ const sortedFilteredFiles = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(() => loadFiles())
|
||||
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose) })
|
||||
onUnmounted(() => { document.removeEventListener('click', docClickClose) })
|
||||
|
||||
function docClickClose(e: MouseEvent) {
|
||||
if (e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
|
||||
if (ctxMenu.value.show) ctxMenu.value.show = false
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
loading.value = true
|
||||
statusText.value = 'Loading...'
|
||||
try {
|
||||
console.log('Calling get_files...')
|
||||
files.value = await invoke('get_files', { args: { pageSize: 500 } })
|
||||
console.log('Files loaded:', files.value.length); console.log('First file:', JSON.stringify(files.value[0]))
|
||||
if (files.value.length > 0) {
|
||||
console.log('First file:', files.value[0])
|
||||
console.log('is_registered sample:', files.value.slice(0,5).map((f:any) => ({ name: f.file_name?.slice(0,20), is_registered: f.is_registered, type: typeof f.is_registered })))
|
||||
}
|
||||
statusText.value = `${files.value.length} files`
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load files:', e)
|
||||
statusText.value = 'Failed: ' + (e.message || e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
invalidateFiles()
|
||||
await ensureFiles()
|
||||
statusText.value = `${files.value.length} files`
|
||||
for (const f of files.value.slice(0, 6)) {
|
||||
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,16 +243,8 @@ function formatDate(date: string) {
|
||||
return new Date(date).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
async function loadThumbnail(uuid: string) {
|
||||
if (!uuid || thumbnails.value[uuid] || thumbnailLoading.value.has(uuid)) return
|
||||
thumbnailLoading.value.add(uuid)
|
||||
try {
|
||||
thumbnails.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 })
|
||||
} catch (e: any) {
|
||||
console.error('Thumbnail load failed:', e)
|
||||
} finally {
|
||||
thumbnailLoading.value.delete(uuid)
|
||||
}
|
||||
async function loadThumbnailLocal(uuid: string) {
|
||||
loadThumbnail(uuid, 30)
|
||||
}
|
||||
|
||||
function handleThumbError(e: Event) {
|
||||
@@ -238,15 +263,137 @@ function playVideo(f: any) {
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function addToPeople() {
|
||||
if (!selectedFiles.value.length) { alert('Please select files first') }
|
||||
alert(`Register ${selectedFiles.value.length} file(s)`)
|
||||
const registering = ref(false)
|
||||
const processing = ref(false)
|
||||
const unregistering = ref(false)
|
||||
const actionMsg = ref('')
|
||||
const procAsr = ref(true)
|
||||
const procYolo = ref(true)
|
||||
const procFace = ref(true)
|
||||
const procOcr = ref(false)
|
||||
const procPose = ref(false)
|
||||
const procCut = ref(true)
|
||||
|
||||
const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show: false, x: 0, y: 0, file: null })
|
||||
|
||||
function selectedProcessors(): string[] {
|
||||
const procs: string[] = []
|
||||
if (procAsr.value) procs.push('asr')
|
||||
if (procYolo.value) procs.push('yolo')
|
||||
if (procFace.value) procs.push('face')
|
||||
if (procOcr.value) procs.push('ocr')
|
||||
if (procPose.value) procs.push('pose')
|
||||
if (procCut.value) procs.push('cut')
|
||||
return procs
|
||||
}
|
||||
|
||||
function statusLabel(f: any): string {
|
||||
if (!f) return ''
|
||||
if (f.status === 'completed') return '已完成'
|
||||
if (f.status === 'registered') return '已註冊・待處理'
|
||||
if (f.status === 'processing') return '處理中'
|
||||
if (f.isRegistered) return '已註冊'
|
||||
return '未註冊'
|
||||
}
|
||||
|
||||
function openContextMenu(e: MouseEvent, f: any) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
ctxMenu.value = {
|
||||
show: true,
|
||||
x: Math.min(e.clientX, window.innerWidth - 280),
|
||||
y: Math.min(e.clientY, window.innerHeight - 320),
|
||||
file: f
|
||||
}
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
ctxMenu.value.show = false
|
||||
}
|
||||
|
||||
async function ctxRegister(f: any) {
|
||||
closeContextMenu()
|
||||
if (registering.value) return
|
||||
const procs = selectedProcessors()
|
||||
if (!procs.length) { actionMsg.value = '請至少選擇一個處理器'; return }
|
||||
registering.value = true
|
||||
actionMsg.value = '註冊中...'
|
||||
const filePath = f.file_path || f.relative_path || f.file_name
|
||||
if (!filePath) { registering.value = false; actionMsg.value = '缺少檔案路徑'; return }
|
||||
try {
|
||||
const result: any = await apiCall('register_file', { filePath })
|
||||
if (result.success) {
|
||||
actionMsg.value = `註冊成功:${f.file_name}`
|
||||
if (result.file_uuid) {
|
||||
try {
|
||||
const pResult: any = await apiCall('process_file', { fileUuid: result.file_uuid, processors: procs })
|
||||
if (pResult.success) actionMsg.value += ' → 處理已觸發'
|
||||
} catch (e: any) { console.error('[process]', e) }
|
||||
}
|
||||
} else {
|
||||
actionMsg.value = `註冊失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `註冊錯誤:${e}`
|
||||
console.error('[register]', 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 = '檔案尚未註冊'; return }
|
||||
const procs = selectedProcessors()
|
||||
if (!procs.length) { actionMsg.value = '請至少選擇一個處理器'; return }
|
||||
processing.value = true
|
||||
actionMsg.value = '處理中...'
|
||||
try {
|
||||
const result: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
|
||||
if (result.success) {
|
||||
actionMsg.value = `處理已觸發:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `處理失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `處理錯誤:${e}`
|
||||
console.error('[process]', e)
|
||||
}
|
||||
processing.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxUnregister(f: any) {
|
||||
closeContextMenu()
|
||||
if (unregistering.value) return
|
||||
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||
unregistering.value = true
|
||||
actionMsg.value = '移除中...'
|
||||
try {
|
||||
const result: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true })
|
||||
if (result.success) {
|
||||
actionMsg.value = `移除成功:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `移除失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `移除錯誤:${e}`
|
||||
console.error('[unregister]', e)
|
||||
}
|
||||
unregistering.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
sortBy.value = 'time_desc'
|
||||
filterUnregistered.value = false
|
||||
filterRegistered.value = false
|
||||
filterPending.value = false
|
||||
filterCompleted.value = false
|
||||
onlyVideos.value = false
|
||||
onlyPhotos.value = false
|
||||
sizeMin.value = null
|
||||
@@ -261,43 +408,53 @@ function resetFilters() {
|
||||
<style scoped>
|
||||
#ms-files-page { width: 100%; max-width: 1200px; margin: 0 auto; color: #202124; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans TC', sans-serif; position: relative; }
|
||||
#ms-files-page * { box-sizing: border-box; }
|
||||
.mp-panel { display: block; position: relative; overflow: visible; }
|
||||
.mp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; position: relative; z-index: 2; }
|
||||
.mp-toolbar-left { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||||
.mp-toolbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
.mp-btn { border: 1px solid #d8dce3; background: #fff; color: #202124; border-radius: 18px; font-size: 14px; padding: 10px 18px; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,.04); font-family: inherit; }
|
||||
.mp-btn-primary { font-weight: 600; background: #1967d2; color: #fff; border-color: #1967d2; }
|
||||
.mp-btn-icon { width: 40px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; box-shadow: 0 4px 10px rgba(0,0,0,.08); }
|
||||
.mp-refresh-icon { font-size: 22px; line-height: 1; font-weight: 500; transform: translateY(-1px); }
|
||||
.mp-filter-btn { width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 1px solid #d8dce3; background: #fff; cursor: pointer; font-size: 16px; }
|
||||
.mp-radio-row, .mp-check-row { font-size: 13px; color: #5f6368; display: flex; align-items: center; gap: 6px; cursor: pointer; min-height: 24px; margin-bottom: 4px; }
|
||||
.mp-search-wrap { width: 240px; }
|
||||
.mp-search { width: 100%; height: 42px; border: 1px solid #e1e5ea; border-radius: 14px; padding: 0 14px; font-size: 14px; background: #fff; outline: none; font-family: inherit; }
|
||||
.mp-status { font-size: 13px; color: #7a7f87; margin: 4px 0 14px; }
|
||||
.mp-status.show { display: block; }
|
||||
.mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||
.mp-file-card { border-radius: 14px; cursor: pointer; position: relative; }
|
||||
.mp-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
|
||||
.lt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.mp-thumb-loading { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: #9aa0a6; animation: spin 1s linear infinite; }
|
||||
.ms-fm-panel { display: block; position: relative; overflow: visible; }
|
||||
.ms-fm-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; position: relative; z-index: 2; }
|
||||
.ms-fm-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: #f8f9fa; border-radius: 8px; border: 1px solid #e8eaed; flex-wrap: wrap; }
|
||||
.ms-fm-processor-label { font-size: 12px; color: #5f6368; 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: #5f6368; display: flex; align-items: center; gap: 6px; cursor: pointer; min-height: 24px; margin-bottom: 4px; }
|
||||
.ms-fm-processor-group { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding-left: 8px; border-left: 1.5px solid #e8eaed; }
|
||||
.ms-fm-check-label { font-size: 12px; color: #5f6368; display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
||||
.ms-fm-check-label input { margin: 0; accent-color: #202124; }
|
||||
.ms-fm-status-badge { position: absolute; bottom: 4px; right: 4px; font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.ms-fm-search-wrap { width: 240px; }
|
||||
.ms-fm-search { width: 100%; height: 42px; border: 1px solid #e1e5ea; border-radius: 14px; padding: 0 14px; font-size: 14px; background: #fff; outline: none; font-family: inherit; }
|
||||
.ms-fm-status { font-size: 13px; color: #7a7f87; margin: 4px 0 14px; }
|
||||
.ms-fm-action-msg { margin-left: 12px; color: #1e8e3e; font-weight: 600; }
|
||||
.ms-fm-btn-danger { background: #d93025; color: #fff; border-color: #d93025; }
|
||||
.ms-fm-btn-danger:hover:not(:disabled) { background: #c5221f; }
|
||||
.ms-ctx-menu { position: fixed !important; z-index: 99999 !important; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 220px; max-width: 320px; font-size: 13px; color: #222; }
|
||||
.ms-ctx-header { padding: 8px 10px 4px; }
|
||||
.ms-ctx-filename { font-weight: 600; font-size: 13px; color: #202124; word-break: break-all; }
|
||||
.ms-ctx-info { font-size: 11px; color: #7a7f87; margin-top: 2px; }
|
||||
.ms-ctx-done { color: #1e8e3e; font-weight: 600; }
|
||||
.ms-ctx-pending { color: #e37400; font-weight: 600; }
|
||||
.ms-ctx-none { color: #9aa0a6; }
|
||||
.ms-ctx-divider { height: 1px; background: #eee; margin: 4px 8px; border: none; }
|
||||
.ms-ctx-item { display: block; width: 100%; text-align: left; padding: 7px 12px; cursor: pointer; font-size: 13px; color: #3c4043; border: none; background: none; border-radius: 6px; font-weight: 500; }
|
||||
.ms-ctx-item:hover { background: #f1f3f4; }
|
||||
.ms-ctx-danger { color: #d93025 !important; }
|
||||
.ms-ctx-danger:hover { background: #fce8e6 !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: #7a7f87; font-weight: 600; margin-bottom: 2px; }
|
||||
.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-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
|
||||
.ms-fm-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ms-fm-thumb-loading { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: #9aa0a6; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.mp-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, #f7f9fc, #eef2f7); color: #6b7280; text-align: center; padding: 14px; }
|
||||
.mp-doc-icon { font-size: 38px; line-height: 1; }
|
||||
.mp-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: #7a818c; text-transform: uppercase; }
|
||||
.mp-badge-type { 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; }
|
||||
.mp-complete-mark { 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; }
|
||||
.mp-file-card.is-selected .mp-thumb-wrap { border-color: #7db9ff !important; background: #eaf4ff !important; box-shadow: 0 0 0 3px rgba(125,185,255,.55) inset !important; }
|
||||
.mp-file-card.is-selected .mp-doc-thumb { background: linear-gradient(180deg, #eef7ff, #dceeff) !important; }
|
||||
.mp-meta { padding-top: 6px; }
|
||||
.mp-source { font-size: 11px; color: #8a919c; margin-bottom: 2px; }
|
||||
.mp-name { font-size: 12px; color: #42474f; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.mp-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; }
|
||||
.mp-filter-pop { position: absolute; top: 64px; right: 0; width: 250px; background: #fff; border: 1px solid #eceff3; border-radius: 24px; box-shadow: 0 16px 36px rgba(0,0,0,.08); padding: 18px 18px 16px; z-index: 999; display: none; max-height: 560px; overflow-y: auto; }
|
||||
.mp-filter-pop.show { display: block; }
|
||||
.mp-filter-title { font-size: 15px; font-weight: 700; color: #202124; margin: 10px 0 10px; }
|
||||
.mp-filter-title:first-child { margin-top: 0; }
|
||||
.mp-range-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; }
|
||||
.mp-filter-number { width: 100%; height: 32px; border: 1px solid #e1e5ea; border-radius: 10px; padding: 0 9px; font-size: 12px; color: #42474f; background: #fff; outline: none; font-family: inherit; }
|
||||
.mp-filter-reset { width: 100%; height: 34px; margin-top: 8px; border: 1px solid #d8dce3; background: #fff; color: #5f6368; border-radius: 12px; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.mp-filter-reset:hover { background: #f6f7f9; }
|
||||
.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, #f7f9fc, #eef2f7); color: #6b7280; 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: #7a818c; 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: #7db9ff !important; background: #eaf4ff !important; box-shadow: 0 0 0 3px rgba(125,185,255,.55) inset !important; }
|
||||
.ms-fm-card.is-selected .ms-fm-doc-thumb { background: linear-gradient(180deg, #eef7ff, #dceeff) !important; }
|
||||
.ms-fm-meta { padding-top: 6px; }
|
||||
.ms-fm-source { font-size: 11px; color: #8a919c; margin-bottom: 2px; }
|
||||
.ms-fm-name { font-size: 12px; color: #42474f; 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-edit-input { width: 100%; height: 32px; border: 1px solid #e1e5ea; border-radius: 10px; padding: 0 9px; font-size: 12px; color: #42474f; background: #fff; outline: none; font-family: inherit; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user