68 lines
5.7 KiB
Vue
68 lines
5.7 KiB
Vue
<template>
|
||
<section v-if="task?.status === 'processing'" class="task-progress" aria-label="视频生成进度">
|
||
<div class="progress-head"><strong>{{ phaseLabel }}</strong><span>{{ elapsedLabel }}</span></div>
|
||
<template v-if="fresh && progress?.totalSteps && progress?.step != null">
|
||
<div class="progress-steps">当前阶段 {{ progress.step }} / {{ progress.totalSteps }} 步 · {{ percent }}%</div>
|
||
<progress :value="progress.step" :max="progress.totalSteps" aria-label="当前阶段进度" />
|
||
</template>
|
||
<p v-if="fresh && progress?.phase === 'queued'">前面还有 {{ progress.queueAhead ?? '未知数量的' }} 个任务;排队时间取决于前方任务。</p>
|
||
<p v-if="samplingFresh && progress?.samplingMaxSeconds">采样预计还需约 {{ timeRange(progress.samplingMinSeconds, progress.samplingMaxSeconds) }}<small>仅指当前采样阶段,后续解码、编码和保存另计。</small></p>
|
||
<p v-else-if="fresh && progress?.phase === 'sampling' && progress?.step === progress?.totalSteps && progress?.totalSteps">采样已结束,正在进入后续处理。</p>
|
||
<p v-else-if="fresh && progress?.phase === 'sampling'">正在收集采样速度,暂无法估算剩余时间。</p>
|
||
<p v-if="fresh && progress?.historyMaxSeconds">同显卡、同规格通常耗时约 {{ timeRange(progress.historyMinSeconds, progress.historyMaxSeconds) }}<small>参考 {{ progress.historySamples }} 次成功执行,不含排队与下载;属于估算。</small></p>
|
||
<p v-else-if="!waiting && !finishing">整段视频预计耗时:暂无足够的同规格记录。</p>
|
||
<p v-if="finishing">视频生成已结束,正在回传并保存到本地。</p>
|
||
<p v-else-if="task?.stage === 'preparing_first_frame'">正在融合全部绑定素材生成完整首帧,完成后自动提交视频。</p>
|
||
<p v-else-if="task?.stage === 'first_frame_ready'">首帧已保存,正在准备视频请求。</p>
|
||
<p v-else-if="task?.stage === 'cancelling'">已请求停止此任务,正在等待显卡确认。</p>
|
||
<p v-else-if="!fresh && !waiting" class="progress-note">{{ progress ? '进度更新暂时延迟,仍在跟踪原任务。' : '正在读取显卡进度…' }}</p>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
const props = defineProps<{ task?: Record<string, any> | null }>()
|
||
const clock = ref(Date.now())
|
||
let timer: ReturnType<typeof setInterval> | undefined
|
||
onMounted(() => { timer = setInterval(() => { clock.value = Date.now() }, 1000) })
|
||
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||
const progress = computed(() => {
|
||
if (props.task?.progress) return props.task.progress
|
||
try { return JSON.parse(props.task?.params || '{}').progress || null } catch { return null }
|
||
})
|
||
const finishing = computed(() => ['transferring', 'downloading', 'saving'].includes(props.task?.stage))
|
||
const fresh = computed(() => !finishing.value && props.task?.stage !== 'cancelling' && progress.value && clock.value - progress.value.observedAt < 90000)
|
||
const samplingFresh = computed(() => fresh.value && clock.value - progress.value.observedAt < 30000)
|
||
const waiting = computed(() => ['waiting_predecessor', 'blocked_predecessor', 'cancelling', 'preparing_first_frame', 'first_frame_ready'].includes(props.task?.stage))
|
||
const phases: Record<string, string> = { queued:'等待显卡队列', running:'显卡正在处理', sampling:'视频采样中', decoding:'视频 / 音频解码中',
|
||
encoding:'编码与保存中', loading:'模型 / 素材加载中', conditioning:'参考素材与提示词处理', postprocessing:'声音 / 字幕后处理',
|
||
preparing_first_frame:'第 1 步 · 生成完整分镜首帧', first_frame_ready:'第 2 步 · 提交视频', waiting_predecessor:'等待前镜头完成', blocked_predecessor:'等待处理前镜头', cancelling:'正在确认取消', transferring:'视频回传中', downloading:'视频下载中', saving:'保存视频中', submitting:'提交任务中' }
|
||
const phaseLabel = computed(() => {
|
||
const stage = props.task?.stage
|
||
return phases[waiting.value || ['transferring','downloading','saving','submitting'].includes(stage) ? stage : fresh.value ? progress.value.phase : stage] || '生成任务处理中'
|
||
})
|
||
const elapsedLabel = computed(() => {
|
||
const start = Date.parse(props.task?.created_at || props.task?.createdAt || '')
|
||
return Number.isFinite(start) ? `已提交 ${formatTime(Math.max(0, (clock.value - start) / 1000))}` : ''
|
||
})
|
||
const percent = computed(() => Math.min(100, Math.round(progress.value.step / progress.value.totalSteps * 100)))
|
||
function formatTime(seconds: number) {
|
||
const value = Math.max(0, Math.floor(seconds))
|
||
if (value < 60) return `${value} 秒`
|
||
if (value < 3600) return `${Math.floor(value / 60)} 分 ${value % 60} 秒`
|
||
return `${Math.floor(value / 3600)} 小时 ${Math.floor(value % 3600 / 60)} 分`
|
||
}
|
||
function timeRange(low: number, high: number) {
|
||
// Keep the server's estimated range, rather than an invented local countdown.
|
||
return high < 60 ? `${Math.max(1, Math.floor(low))}–${Math.ceil(high)} 秒` : `${Math.max(1, Math.floor(low / 60))}–${Math.max(1, Math.ceil(high / 60))} 分钟`
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.task-progress { margin: 12px 16px; padding: 12px 14px; background: var(--bg-secondary, #f7f8fa); border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; font-size: 13px; line-height: 1.6; }
|
||
.progress-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||
.progress-head span, small, .progress-note { color: var(--text-secondary, #737b8b); }
|
||
.progress-steps { margin-top: 8px; font-variant-numeric: tabular-nums; }
|
||
progress { display: block; width: 100%; height: 8px; margin: 6px 0; accent-color: #ff7714; }
|
||
p { margin: 6px 0 0; } small { display: block; font-size: 12px; }
|
||
</style>
|