Spaces:
Sleeping
Sleeping
| <script setup> | |
| import { ref, computed, onMounted, onUnmounted } from 'vue' | |
| import { ElMessage } from 'element-plus' | |
| import { UploadFilled, VideoPlay, Download, RefreshRight, Plus, Loading, Check, Warning, Setting } from '@element-plus/icons-vue' | |
| import dayjs from 'dayjs' | |
| import axios from 'axios' | |
| // --- Configuration --- | |
| const API_BASE_URL = '/api/v1' | |
| const POLL_INTERVAL = 2000 | |
| // --- State --- | |
| const isUploading = ref(false) | |
| const showUploader = ref(true) | |
| const timer = ref(null) | |
| const uploadRef = ref(null) // 用于引用 upload 组件以清理文件 | |
| const selectedModel = ref('lama') // 新增:当前选择的模型 | |
| // Data State aligned with Backend Models | |
| const queueSummary = ref({ | |
| is_busy: false, | |
| queue_length: 0, | |
| total_active: 0 | |
| }) | |
| const currentTaskId = ref(null) | |
| const currentTaskResult = ref(null) | |
| const waitingQueue = ref([]) | |
| // --- API Interactions --- | |
| // 1. 获取队列状态 | |
| const fetchQueueStatus = async () => { | |
| try { | |
| const { data } = await axios.get(`${API_BASE_URL}/get_queue_status`) | |
| queueSummary.value = data.summary | |
| waitingQueue.value = data.waiting_queue | |
| const newCurrentTaskId = data.current_task_id | |
| currentTaskId.value = newCurrentTaskId | |
| if (newCurrentTaskId) { | |
| fetchCurrentTaskResult(newCurrentTaskId) | |
| } else { | |
| currentTaskResult.value = null | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch queue status:', error) | |
| } | |
| } | |
| // 2. 获取特定任务结果 | |
| const fetchCurrentTaskResult = async (taskId) => { | |
| try { | |
| const { data } = await axios.get(`${API_BASE_URL}/get_results`, { | |
| params: { remove_task_id: taskId } | |
| }) | |
| currentTaskResult.value = { | |
| id: taskId, | |
| status: data.status, | |
| percentage: data.percentage, | |
| video_path: 'Processing...', | |
| created_at: null, | |
| download_url: data.download_url | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch task result:', error) | |
| } | |
| } | |
| // 3. 提交任务 | |
| const handleUploadChange = async (file) => { | |
| isUploading.value = true | |
| const formData = new FormData() | |
| formData.append('video', file.raw) | |
| try { | |
| // 修改:使用 selectedModel 的值 | |
| await axios.post(`${API_BASE_URL}/submit_remove_task`, formData, { | |
| params: { cleaner_type: selectedModel.value }, | |
| headers: { 'Content-Type': 'multipart/form-data' } | |
| }) | |
| ElMessage.success({ message: `Task submitted: ${file.name}`, plain: true }) | |
| // 修改:不再隐藏上传框,而是刷新队列并清理当前文件,允许继续上传 | |
| // showUploader.value = false | |
| if (uploadRef.value) { | |
| uploadRef.value.clearFiles() | |
| } | |
| fetchQueueStatus() | |
| } catch (error) { | |
| ElMessage.error({ message: `Upload failed: ${error.message}`, plain: true }) | |
| } finally { | |
| isUploading.value = false | |
| } | |
| } | |
| // --- Computed Logic --- | |
| const tableData = computed(() => { | |
| const list = [] | |
| if (currentTaskId.value && currentTaskResult.value) { | |
| list.push({ | |
| id: currentTaskId.value, | |
| status: currentTaskResult.value.status, | |
| percentage: currentTaskResult.value.percentage, | |
| video_path: currentTaskResult.value.video_path, | |
| created_at: null, | |
| is_current: true | |
| }) | |
| } | |
| if (waitingQueue.value && waitingQueue.value.length > 0) { | |
| waitingQueue.value.forEach(task => { | |
| list.push({ | |
| id: task.id, | |
| status: task.status, | |
| percentage: task.percentage, | |
| video_path: task.video_path, | |
| created_at: task.created_at, | |
| is_current: false | |
| }) | |
| }) | |
| } | |
| return list | |
| }) | |
| const stats = computed(() => { | |
| return { | |
| totalActive: queueSummary.value.total_active, | |
| queueLength: queueSummary.value.queue_length, | |
| isBusy: queueSummary.value.is_busy ? 1 : 0 | |
| } | |
| }) | |
| // --- Helpers --- | |
| const formatDate = (dateStr) => { | |
| if (!dateStr) return '-' | |
| return dayjs(dateStr).format('MMM D, HH:mm') | |
| } | |
| const getDownloadUrl = (taskId) => { | |
| return `${API_BASE_URL}/download/${taskId}` | |
| } | |
| const getStatusConfig = (status) => { | |
| const map = { | |
| 'FINISHED': { type: 'success', label: 'Ready', icon: Check, bg: 'pill-green' }, | |
| 'PROCESSING': { type: 'primary', label: 'Processing', icon: Loading, bg: 'pill-blue' }, | |
| 'QUEUED': { type: 'info', label: 'Queued', icon: null, bg: 'pill-gray' }, | |
| 'UPLOADING': { type: 'warning', label: 'Uploading', icon: Loading, bg: 'pill-gray' }, | |
| 'ERROR': { type: 'danger', label: 'Failed', icon: Warning, bg: 'pill-red' } | |
| } | |
| return map[status] || { type: 'info', label: status, bg: 'pill-gray' } | |
| } | |
| // --- Lifecycle --- | |
| onMounted(() => { | |
| fetchQueueStatus() | |
| timer.value = setInterval(fetchQueueStatus, POLL_INTERVAL) | |
| }) | |
| onUnmounted(() => { | |
| if (timer.value) clearInterval(timer.value) | |
| }) | |
| </script> | |
| <template> | |
| <div class="oa-page"> | |
| <header class="oa-header"> | |
| <div class="oa-header-inner"> | |
| <div class="oa-brand"> | |
| <div class="oa-dot" :class="{ 'oa-dot-busy': queueSummary.is_busy }" /> | |
| <span class="oa-title">Video Tasks</span> | |
| </div> | |
| <div class="oa-actions"> | |
| <el-button | |
| class="oa-primary-btn" | |
| :icon="Plus" | |
| @click="showUploader = !showUploader" | |
| > | |
| {{ showUploader ? 'Hide upload' : 'New task' }} | |
| </el-button> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="oa-container"> | |
| <section class="oa-stats"> | |
| <div class="oa-stat-card"> | |
| <div class="oa-stat-label">System Status</div> | |
| <div class="oa-stat-value"> | |
| {{ queueSummary.is_busy ? 'Busy' : 'Idle' }} | |
| </div> | |
| </div> | |
| <div class="oa-stat-card"> | |
| <div class="oa-stat-label">Queue Length</div> | |
| <div class="oa-stat-value">{{ stats.queueLength }}</div> | |
| </div> | |
| <div class="oa-stat-card"> | |
| <div class="oa-stat-label">Total Active</div> | |
| <div class="oa-stat-value">{{ stats.totalActive }}</div> | |
| </div> | |
| </section> | |
| <transition name="el-fade-in-linear"> | |
| <section v-if="showUploader" class="oa-upload-section"> | |
| <div class="oa-controls"> | |
| <span class="oa-control-label">Model:</span> | |
| <el-radio-group v-model="selectedModel" size="small" class="oa-radio-group"> | |
| <el-radio-button label="lama">Lama (Fast)</el-radio-button> | |
| <el-radio-button label="e2fgvi_hq">E2FGVI (High Quality)</el-radio-button> | |
| </el-radio-group> | |
| </div> | |
| <el-upload | |
| ref="uploadRef" | |
| class="oa-uploader" | |
| drag | |
| action="#" | |
| :auto-upload="false" | |
| :on-change="handleUploadChange" | |
| :show-file-list="false" | |
| :disabled="isUploading" | |
| > | |
| <div class="oa-upload-inner"> | |
| <el-icon class="oa-upload-icon" v-if="!isUploading"><UploadFilled /></el-icon> | |
| <el-icon class="oa-upload-icon is-loading" v-else><Loading /></el-icon> | |
| <div class="oa-upload-text"> | |
| <span v-if="!isUploading"> | |
| <span class="oa-upload-strong">Click to upload</span> | |
| or drag video | |
| </span> | |
| <span v-else>Uploading to server...</span> | |
| </div> | |
| <div class="oa-upload-hint">MP4, MOV, AVI · Max 500MB</div> | |
| </div> | |
| </el-upload> | |
| </section> | |
| </transition> | |
| <section class="oa-table"> | |
| <div class="oa-table-head"> | |
| <h3 class="oa-section-title">Current & Queue</h3> | |
| <el-button | |
| :icon="RefreshRight" | |
| text | |
| size="small" | |
| class="oa-refresh" | |
| @click="fetchQueueStatus" | |
| > | |
| Refresh | |
| </el-button> | |
| </div> | |
| <el-table | |
| :data="tableData" | |
| class="oa-el-table" | |
| :row-style="{ height: '68px' }" | |
| empty-text="No active tasks" | |
| > | |
| <el-table-column label="Video Info" min-width="320"> | |
| <template #default="{ row }"> | |
| <div class="oa-file-cell"> | |
| <div class="oa-file-icon"> | |
| <el-icon><VideoPlay /></el-icon> | |
| </div> | |
| <div class="oa-file-info"> | |
| <div class="oa-file-name">{{ row.video_path || `Task: ${row.id.substring(0,8)}...` }}</div> | |
| <div class="oa-file-meta">{{ row.id }}</div> | |
| </div> | |
| </div> | |
| </template> | |
| </el-table-column> | |
| <el-table-column label="Status" width="150"> | |
| <template #default="{ row }"> | |
| <div class="oa-status-pill" :class="getStatusConfig(row.status).bg"> | |
| <span class="oa-status-dot"></span> | |
| {{ getStatusConfig(row.status).label }} | |
| </div> | |
| </template> | |
| </el-table-column> | |
| <el-table-column label="Progress" width="220"> | |
| <template #default="{ row }"> | |
| <div class="oa-progress"> | |
| <el-progress | |
| :percentage="row.percentage" | |
| :show-text="false" | |
| :stroke-width="4" | |
| :color="row.status === 'ERROR' ? '#ef4444' : '#10a37f'" | |
| :indeterminate="row.status === 'PROCESSING' && row.percentage === 0" | |
| class="oa-progress-bar" | |
| /> | |
| <span class="oa-progress-text"> | |
| {{ row.percentage }}% | |
| </span> | |
| </div> | |
| </template> | |
| </el-table-column> | |
| <el-table-column label="Created At" width="170" align="right"> | |
| <template #default="{ row }"> | |
| <span class="oa-date">{{ formatDate(row.created_at) }}</span> | |
| </template> | |
| </el-table-column> | |
| <el-table-column width="64" align="center"> | |
| <template #default="{ row }"> | |
| <a | |
| v-if="row.status === 'FINISHED'" | |
| :href="getDownloadUrl(row.id)" | |
| target="_blank" | |
| class="oa-download-link" | |
| > | |
| <el-button link class="oa-download"> | |
| <el-icon><Download /></el-icon> | |
| </el-button> | |
| </a> | |
| </template> | |
| </el-table-column> | |
| </el-table> | |
| </section> | |
| </main> | |
| </div> | |
| </template> | |
| <style scoped> | |
| /* --------------------------- | |
| OpenAI-like Design Tokens | |
| ---------------------------- */ | |
| :root { | |
| --oa-bg: #ffffff; | |
| --oa-surface: #f7f7f8; | |
| --oa-surface-2: #fbfbfc; | |
| --oa-border: #e6e6e9; | |
| --oa-text: #0b0c0e; | |
| --oa-text-2: #5f6368; | |
| --oa-text-3: #8a8f98; | |
| --oa-green: #10a37f; | |
| --oa-black: #0b0c0e; | |
| --oa-radius-lg: 14px; | |
| --oa-radius-md: 10px; | |
| --oa-shadow-sm: 0 1px 2px rgba(0,0,0,0.04); | |
| } | |
| /* Page + container */ | |
| .oa-page { | |
| min-height: 100vh; | |
| background: var(--oa-bg); | |
| color: var(--oa-text); | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; | |
| } | |
| .oa-container { | |
| max-width: 1040px; | |
| margin: 0 auto; | |
| padding: 28px 24px 56px; | |
| } | |
| /* Header */ | |
| .oa-header { | |
| position: sticky; | |
| top: 0; | |
| z-index: 5; | |
| background: rgba(255,255,255,0.9); | |
| backdrop-filter: blur(8px); | |
| border-bottom: 1px solid var(--oa-border); | |
| } | |
| .oa-header-inner { | |
| max-width: 1040px; | |
| margin: 0 auto; | |
| padding: 14px 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .oa-brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .oa-dot { | |
| width: 10px; | |
| height: 10px; | |
| background: #ccc; | |
| border-radius: 999px; | |
| transition: background 0.3s ease; | |
| } | |
| .oa-dot-busy { | |
| background: var(--oa-green); | |
| box-shadow: 0 0 8px rgba(16, 163, 127, 0.4); | |
| } | |
| .oa-title { | |
| font-size: 15px; | |
| font-weight: 600; | |
| letter-spacing: 0.1px; | |
| } | |
| /* Primary button */ | |
| .oa-primary-btn { | |
| background: var(--oa-black) !important; | |
| color: #fff !important; | |
| border: none !important; | |
| border-radius: 999px !important; | |
| padding: 8px 14px !important; | |
| font-weight: 600; | |
| box-shadow: var(--oa-shadow-sm); | |
| } | |
| .oa-primary-btn:hover { background: #000 !important; } | |
| /* Stats */ | |
| .oa-stats { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 14px; | |
| margin-top: 20px; | |
| margin-bottom: 22px; | |
| } | |
| .oa-stat-card { | |
| background: var(--oa-surface); | |
| border: 1px solid var(--oa-border); | |
| border-radius: var(--oa-radius-lg); | |
| padding: 18px; | |
| box-shadow: var(--oa-shadow-sm); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .oa-stat-label { | |
| font-size: 12px; | |
| color: var(--oa-text-2); | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| } | |
| .oa-stat-value { | |
| font-size: 26px; | |
| font-weight: 700; | |
| letter-spacing: -0.02em; | |
| } | |
| /* Upload & Controls */ | |
| .oa-upload-section { | |
| margin-top: 8px; | |
| margin-bottom: 26px; | |
| } | |
| .oa-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| } | |
| .oa-control-label { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: var(--oa-text-2); | |
| } | |
| /* Customizing Radio Button to look cleaner */ | |
| .oa-radio-group :deep(.el-radio-button__inner) { | |
| border-radius: 6px !important; | |
| border: 1px solid var(--oa-border); | |
| box-shadow: none !important; | |
| margin-right: 8px; | |
| padding: 8px 16px; | |
| font-weight: 500; | |
| background: var(--oa-surface); | |
| color: var(--oa-text); | |
| } | |
| .oa-radio-group :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { | |
| background-color: var(--oa-black); | |
| border-color: var(--oa-black); | |
| color: #fff; | |
| box-shadow: none; | |
| } | |
| .oa-radio-group :deep(.el-radio-button:first-child .el-radio-button__inner) { | |
| border-left: 1px solid var(--oa-border); | |
| } | |
| .oa-uploader :deep(.el-upload-dragger) { | |
| height: 128px; | |
| border: 1.5px dashed var(--oa-border); | |
| background: var(--oa-surface-2); | |
| border-radius: var(--oa-radius-lg); | |
| transition: all 0.2s ease; | |
| } | |
| .oa-uploader :deep(.el-upload-dragger:hover) { | |
| border-color: var(--oa-green); | |
| background: #f3fbf8; | |
| } | |
| .oa-upload-inner { | |
| height: 100%; | |
| display: grid; | |
| place-content: center; | |
| gap: 6px; | |
| text-align: center; | |
| } | |
| .oa-upload-icon { font-size: 22px; color: var(--oa-text-3); } | |
| .oa-upload-text { font-size: 14px; color: var(--oa-text-2); } | |
| .oa-upload-strong { color: var(--oa-green); font-weight: 700; } | |
| .oa-upload-hint { font-size: 12px; color: var(--oa-text-3); } | |
| .is-loading { animation: rotating 2s linear infinite; } | |
| /* Table Section */ | |
| .oa-table { | |
| background: var(--oa-bg); | |
| border: 1px solid var(--oa-border); | |
| border-radius: var(--oa-radius-lg); | |
| padding: 14px 12px 6px; | |
| box-shadow: var(--oa-shadow-sm); | |
| } | |
| .oa-table-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 6px 8px 12px; | |
| } | |
| .oa-section-title { | |
| margin: 0; | |
| font-size: 16px; | |
| font-weight: 700; | |
| letter-spacing: -0.01em; | |
| } | |
| .oa-refresh { color: var(--oa-text-2) !important; } | |
| /* Element Plus table overrides */ | |
| .oa-el-table { | |
| --el-table-border-color: transparent; | |
| --el-table-header-bg-color: transparent; | |
| --el-table-row-hover-bg-color: #fafafa; | |
| } | |
| .oa-el-table :deep(th.el-table__cell) { | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--oa-text-3); | |
| font-weight: 700; | |
| border-bottom: 1px solid var(--oa-border) !important; | |
| padding: 10px 8px 12px; | |
| } | |
| .oa-el-table :deep(td.el-table__cell) { | |
| border-bottom: 1px solid var(--oa-border); | |
| padding: 12px 8px; | |
| } | |
| /* File cell */ | |
| .oa-file-cell { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .oa-file-icon { | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 10px; | |
| background: var(--oa-surface); | |
| border: 1px solid var(--oa-border); | |
| display: grid; | |
| place-content: center; | |
| color: var(--oa-text); | |
| } | |
| .oa-file-name { font-size: 14px; font-weight: 600; } | |
| .oa-file-meta { font-size: 12px; color: var(--oa-text-2); } | |
| /* Status pills */ | |
| .oa-status-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 5px 10px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| letter-spacing: 0.02em; | |
| } | |
| .oa-status-dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| } | |
| /* Softer OpenAI-like pastels */ | |
| .pill-green { background: #e9f9f3; color: #0f7a5a; } | |
| .pill-blue { background: #eef3ff; color: #2a5bd7; } | |
| .pill-gray { background: #f1f2f4; color: #5f6368; } | |
| .pill-red { background: #fdecec; color: #b42318; } | |
| /* Progress */ | |
| .oa-progress { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .oa-progress-bar { flex: 1; } | |
| .oa-progress-text { | |
| font-size: 12px; | |
| color: var(--oa-text-2); | |
| width: 34px; | |
| text-align: right; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| /* Date + download */ | |
| .oa-date { | |
| font-size: 13px; | |
| color: var(--oa-text-2); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .oa-download-link { text-decoration: none; } | |
| .oa-download { color: var(--oa-text-2) !important; } | |
| .oa-download:hover { color: var(--oa-green) !important; } | |
| @keyframes rotating { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| /* Responsive */ | |
| @media (max-width: 900px) { | |
| .oa-stats { grid-template-columns: 1fr; } | |
| } | |
| </style> |