feat: pending identity sorting, face detail modal, video player fps, processor counts
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<div class="ms-fm-toolbar">
|
||||
<div class="ms-fm-toolbar-left">
|
||||
<button class="ms-fm-icon-btn" type="button" title="Refresh" @click="loadFiles">⟳</button>
|
||||
<button class="ms-fm-icon-btn" type="button" title="Refresh Status" @click="refreshAllStatus">⟡</button>
|
||||
<label class="ms-fm-radio-label">
|
||||
<input type="radio" name="ms-display-filter" value="all" v-model="displayFilter"> All
|
||||
</label>
|
||||
@@ -39,6 +40,8 @@
|
||||
<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="filterNotIngested"> 未入庫</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterIngested"> 已入庫</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>
|
||||
@@ -62,6 +65,13 @@
|
||||
</div>
|
||||
|
||||
<div class="ms-fm-status">{{ statusText }}<span v-if="actionMsg" class="ms-fm-action-msg">{{ actionMsg }}</span></div>
|
||||
<div class="ms-fm-axis-bar">
|
||||
<span class="ms-fm-axis-item">📋 已註冊 <strong>{{ registeredCount }}</strong> · 未註冊 <strong>{{ unregisteredCount }}</strong></span>
|
||||
<span class="ms-fm-axis-sep">|</span>
|
||||
<span class="ms-fm-axis-item">⚙️ 待處理 <strong>{{ pendingCount }}</strong> · 處理中 <strong>{{ processingCount }}</strong> · 已處理 <strong>{{ completedCount }}</strong></span>
|
||||
<span class="ms-fm-axis-sep">|</span>
|
||||
<span class="ms-fm-axis-item">🗄️ 已入庫 <strong>{{ ingestedCount }}</strong> · 未入庫 <strong>{{ notIngestedCount }}</strong></span>
|
||||
</div>
|
||||
|
||||
<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)">
|
||||
@@ -74,9 +84,15 @@
|
||||
<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>
|
||||
<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 class="ms-fm-badges">
|
||||
<div class="ms-fm-badge-row" :class="f.isRegistered ? 'ms-badge-ok' : 'ms-badge-off'">📋 {{ f.isRegistered ? '已註冊' : '未註冊' }}</div>
|
||||
<div class="ms-fm-badge-row" :class="statusBadgeClass(f)">{{ statusBadgeIcon(f) }} {{ statusBadgeLabel(f) }}</div>
|
||||
<div class="ms-fm-badge-row" :class="ingestedBadgeClass(f)">{{ ingestedBadgeIcon(f) }} {{ ingestedBadgeLabel(f) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="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 class="ms-fm-meta">
|
||||
<div class="ms-fm-name">{{ f.file_name }}</div>
|
||||
@@ -98,18 +114,22 @@
|
||||
<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 && !ctxMenu.file?.ingested" class="ms-ctx-item" @click="ctxIngest(ctxMenu.file)">📦 觸發入庫</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.ingested" class="ms-ctx-item" @click="ctxCheckout(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>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('asr')"><input type="checkbox" v-model="procAsr"> ASR <span class="ms-proc-count">{{ procCountLabel('asr', 'segment') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('asrx')"><input type="checkbox" v-model="procAsrx"> ASRX <span class="ms-proc-count">{{ procCountLabel('asrx', 'segment') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('yolo')"><input type="checkbox" v-model="procYolo"> YOLO <span class="ms-proc-count">{{ procCountLabel('yolo', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('face')"><input type="checkbox" v-model="procFace"> Face <span class="ms-proc-count">{{ procCountLabel('face', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('ocr')"><input type="checkbox" v-model="procOcr"> OCR <span class="ms-proc-count">{{ procCountLabel('ocr', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('pose')"><input type="checkbox" v-model="procPose"> Pose <span class="ms-proc-count">{{ procCountLabel('pose', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('appearance')"><input type="checkbox" v-model="procAppearance"> Appearance <span class="ms-proc-count">{{ procCountLabel('appearance', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('cut')"><input type="checkbox" v-model="procCut"> CUT <span class="ms-proc-count">{{ procCountLabel('cut', 'frame') }}</span></label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -121,7 +141,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { apiCall } from '@/api'
|
||||
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles } from '@/store'
|
||||
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, getFileStatus, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, fileStatusCache } from '@/store'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const files = computed(() => filesCache.value)
|
||||
@@ -142,6 +162,8 @@ const filterUnregistered = ref(false)
|
||||
const filterRegistered = ref(true)
|
||||
const filterPending = ref(false)
|
||||
const filterCompleted = ref(false)
|
||||
const filterNotIngested = ref(false)
|
||||
const filterIngested = ref(false)
|
||||
const onlyVideos = ref(false)
|
||||
const onlyPhotos = ref(false)
|
||||
const sizeMin = ref<number | null>(null)
|
||||
@@ -166,8 +188,10 @@ 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 (filterPending.value) result = result.filter((f: any) => f.status === 'registered' || f.status === 'pending')
|
||||
if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed')
|
||||
if (filterNotIngested.value && !filterIngested.value) result = result.filter((f: any) => f.isRegistered && !f.ingested)
|
||||
else if (filterIngested.value && !filterNotIngested.value) result = result.filter((f: any) => f.ingested)
|
||||
|
||||
if (onlyVideos.value) result = result.filter(isVideo)
|
||||
if (onlyPhotos.value) result = result.filter(isPhoto)
|
||||
@@ -203,6 +227,14 @@ const sortedFilteredFiles = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const registeredCount = computed(() => files.value.filter((f: any) => f.isRegistered).length)
|
||||
const unregisteredCount = computed(() => files.value.filter((f: any) => !f.isRegistered).length)
|
||||
const pendingCount = computed(() => files.value.filter((f: any) => f.status === 'pending' || f.status === 'registered').length)
|
||||
const processingCount = computed(() => files.value.filter((f: any) => f.status === 'processing').length)
|
||||
const completedCount = computed(() => files.value.filter((f: any) => f.status === 'completed').length)
|
||||
const ingestedCount = computed(() => files.value.filter((f: any) => f.ingested).length)
|
||||
const notIngestedCount = computed(() => files.value.filter((f: any) => f.isRegistered && !f.ingested).length)
|
||||
|
||||
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose) })
|
||||
onUnmounted(() => { document.removeEventListener('click', docClickClose) })
|
||||
|
||||
@@ -215,10 +247,33 @@ async function loadFiles() {
|
||||
statusText.value = 'Loading...'
|
||||
invalidateFiles()
|
||||
await ensureFiles()
|
||||
statusText.value = `${files.value.length} files`
|
||||
statusText.value = `${files.value.length} 個檔案`
|
||||
for (const f of files.value.slice(0, 6)) {
|
||||
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
|
||||
}
|
||||
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid && f.status !== 'completed')
|
||||
for (const f of registeredFiles) {
|
||||
try {
|
||||
const progress: any = await apiCall('get_progress', { fileUuid: f.file_uuid })
|
||||
if (progress && progress.overall_progress < 100 && progress.processors?.some((p: any) => p.status === 'running')) {
|
||||
f.status = 'processing'
|
||||
fileProgressCache.value[f.file_uuid] = {
|
||||
file_uuid: f.file_uuid,
|
||||
overall_progress: progress.overall_progress,
|
||||
processors: progress.processors,
|
||||
}
|
||||
pollProgress(f.file_uuid)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('check progress failed:', f.file_uuid, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllStatus() {
|
||||
statusText.value = 'Refreshing status...'
|
||||
await refreshAllFilesStatus()
|
||||
statusText.value = `${files.value.length} 個檔案`
|
||||
}
|
||||
|
||||
function isVideo(f: any) {
|
||||
@@ -265,13 +320,17 @@ function playVideo(f: any) {
|
||||
|
||||
const registering = ref(false)
|
||||
const processing = ref(false)
|
||||
const ingesting = ref(false)
|
||||
const checkouting = ref(false)
|
||||
const unregistering = ref(false)
|
||||
const actionMsg = ref('')
|
||||
const procAsr = ref(true)
|
||||
const procAsrx = ref(true)
|
||||
const procYolo = ref(true)
|
||||
const procFace = ref(true)
|
||||
const procOcr = ref(false)
|
||||
const procPose = ref(false)
|
||||
const procOcr = ref(true)
|
||||
const procPose = 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 })
|
||||
@@ -279,24 +338,90 @@ const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show:
|
||||
function selectedProcessors(): string[] {
|
||||
const procs: string[] = []
|
||||
if (procAsr.value) procs.push('asr')
|
||||
if (procAsrx.value) procs.push('asrx')
|
||||
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 (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 statusLabel(f: any): string {
|
||||
if (!f) return ''
|
||||
if (f.status === 'completed') return '已完成'
|
||||
if (f.status === 'registered') return '已註冊・待處理'
|
||||
if (f.status === 'processing') return '處理中'
|
||||
if (f.status === 'failed') return '處理失敗'
|
||||
if (f.status === 'pending') return '待處理'
|
||||
if (f.status === 'registered') return '待處理'
|
||||
if (f.isRegistered) return '已註冊'
|
||||
return '未註冊'
|
||||
}
|
||||
|
||||
function openContextMenu(e: MouseEvent, f: any) {
|
||||
function statusBadgeClass(f: any): string {
|
||||
if (f.status === 'processing') return 'ms-badge-proc'
|
||||
if (f.status === 'completed') return 'ms-badge-done'
|
||||
if (f.status === 'failed') return 'ms-badge-fail'
|
||||
if (f.status === 'pending' || f.status === 'registered') return 'ms-badge-warn'
|
||||
return 'ms-badge-off'
|
||||
}
|
||||
|
||||
function statusBadgeIcon(f: any): string {
|
||||
if (f.status === 'processing') return '⏳'
|
||||
if (f.status === 'completed') return '✅'
|
||||
if (f.status === 'failed') return '❌'
|
||||
if (f.status === 'pending' || f.status === 'registered') return '⏸️'
|
||||
return '⏺️'
|
||||
}
|
||||
|
||||
function statusBadgeLabel(f: any): string {
|
||||
if (!f) return ''
|
||||
if (f.status === 'completed') return '已處理'
|
||||
if (f.status === 'processing') return '處理中'
|
||||
if (f.status === 'failed') return '處理失敗'
|
||||
if (f.status === 'pending' || f.status === 'registered') return '待處理'
|
||||
return '—'
|
||||
}
|
||||
|
||||
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 '已入庫'
|
||||
if (f.isRegistered) return '未入庫'
|
||||
return '—'
|
||||
}
|
||||
|
||||
async function openContextMenu(e: MouseEvent, f: any) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
ctxMenu.value = {
|
||||
@@ -305,6 +430,9 @@ function openContextMenu(e: MouseEvent, f: any) {
|
||||
y: Math.min(e.clientY, window.innerHeight - 320),
|
||||
file: f
|
||||
}
|
||||
if (f.isRegistered && f.file_uuid) {
|
||||
await loadProcessorCounts(f.file_uuid, true)
|
||||
}
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
@@ -354,6 +482,9 @@ async function ctxProcess(f: any) {
|
||||
const result: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
|
||||
if (result.success) {
|
||||
actionMsg.value = `處理已觸發:${f.file_name}`
|
||||
f.status = 'processing'
|
||||
fileStatusCache.value[f.file_uuid] = 'processing'
|
||||
pollProgress(f.file_uuid)
|
||||
} else {
|
||||
actionMsg.value = `處理失敗:${result.message}`
|
||||
}
|
||||
@@ -362,8 +493,6 @@ async function ctxProcess(f: any) {
|
||||
console.error('[process]', e)
|
||||
}
|
||||
processing.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxUnregister(f: any) {
|
||||
@@ -388,12 +517,58 @@ async function ctxUnregister(f: any) {
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxIngest(f: any) {
|
||||
closeContextMenu()
|
||||
if (ingesting.value) return
|
||||
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||
ingesting.value = true
|
||||
actionMsg.value = '入庫中...'
|
||||
try {
|
||||
const result: any = await apiCall('ingest_file', { fileUuid: f.file_uuid })
|
||||
if (result.success) {
|
||||
actionMsg.value = `入庫成功:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `入庫失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `入庫錯誤:${e}`
|
||||
console.error('[ingest]', e)
|
||||
}
|
||||
ingesting.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxCheckout(f: any) {
|
||||
closeContextMenu()
|
||||
if (checkouting.value) return
|
||||
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||
checkouting.value = true
|
||||
actionMsg.value = '出庫中...'
|
||||
try {
|
||||
const result: any = await apiCall('checkout_file', { fileUuid: f.file_uuid })
|
||||
if (result.success) {
|
||||
actionMsg.value = `出庫成功:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `出庫失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `出庫錯誤:${e}`
|
||||
console.error('[checkout]', e)
|
||||
}
|
||||
checkouting.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
|
||||
filterNotIngested.value = false
|
||||
filterIngested.value = false
|
||||
onlyVideos.value = false
|
||||
onlyPhotos.value = false
|
||||
sizeMin.value = null
|
||||
@@ -439,6 +614,12 @@ function resetFilters() {
|
||||
.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-proc-done { color: #059669; }
|
||||
.ms-proc-done .ms-proc-count { color: #059669; font-weight: 600; }
|
||||
.ms-proc-count { margin-left: 4px; font-size: 11px; color: #8a919c; }
|
||||
.ms-fm-progress { position: relative; height: 20px; background: #e8eaed; border-radius: 4px; margin-top: 4px; overflow: hidden; }
|
||||
.ms-fm-progress-bar { height: 100%; background: linear-gradient(90deg, #1a73e8, #4285f4); border-radius: 4px; transition: width 0.3s; }
|
||||
.ms-fm-progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: #202124; }
|
||||
.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; }
|
||||
@@ -457,4 +638,20 @@ function resetFilters() {
|
||||
.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; }
|
||||
|
||||
/* Three-axis badges */
|
||||
.ms-fm-badges { position: absolute; bottom: 4px; right: 4px; 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: #1a73e8; color: #fff; }
|
||||
.ms-badge-off { background: rgba(0,0,0,.48); color: #ccc; }
|
||||
.ms-badge-proc { background: #1a73e8; color: #fff; animation: badge-pulse 1.2s ease-in-out infinite; }
|
||||
.ms-badge-done { background: #1e8e3e; color: #fff; }
|
||||
.ms-badge-warn { background: #f9ab00; color: #202124; }
|
||||
.ms-badge-fail { background: #d93025; color: #fff; }
|
||||
@keyframes badge-pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
|
||||
|
||||
/* Three-axis status bar */
|
||||
.ms-fm-axis-bar { font-size: 12px; color: #5f6368; margin: 2px 0 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.ms-fm-axis-item strong { color: #202124; }
|
||||
.ms-fm-axis-sep { color: #dadce0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user