feat: 对接项目生成配置与单集成片

This commit is contained in:
GouJ
2026-09-21 10:03:50 +08:00
parent 1bbfd30627
commit 01f274cd8e
9 changed files with 722 additions and 8 deletions
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onScopeDispose, ref, watch } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NProgress, NSpin } from 'naive-ui'
import { mediaAssetUrl } from '../../../lib/assets'
import { errorMessage } from '../../../lib/http'
import { productionApi } from '../api'
import type { EpisodeAssemblyResult, ProjectAssemblyReadiness } from '../types'
const props = defineProps<{ projectId: string }>()
const readiness = ref<ProjectAssemblyReadiness | null>(null)
const loading = ref(false)
const assembling = ref('')
const error = ref('')
const results = ref<Record<string, EpisodeAssemblyResult>>({})
let controller: AbortController | null = null
async function refresh() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = ''
try {
readiness.value = await productionApi.assemblyReadiness(props.projectId, controller.signal)
} catch (cause) {
if (!controller.signal.aborted) error.value = errorMessage(cause)
} finally {
if (!controller.signal.aborted) loading.value = false
}
}
async function assemble(episodeId: string) {
if (assembling.value) return
assembling.value = episodeId
error.value = ''
try {
const result = await productionApi.assembleEpisode(episodeId)
results.value = { ...results.value, [episodeId]: result }
await refresh()
} catch (cause) {
error.value = errorMessage(cause)
} finally {
assembling.value = ''
}
}
function resultUrl(episodeId: string) {
const result = results.value[episodeId]
return result ? mediaAssetUrl(result.videoUrl) : null
}
watch(
() => props.projectId,
() => {
results.value = {}
void refresh()
},
{ immediate: true }
)
onScopeDispose(() => controller?.abort())
</script>
<template>
<NCollapse class="mt-4">
<NCollapseItem name="episode-assembly" title="单集成片拼接">
<NSpin :show="loading">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs text-muted">
Beat Shot 顺序拼接当前有效主视频V1 只输出无声视频不包含对白配乐字幕和转场
</p>
<NButton size="small" :disabled="loading" @click="refresh">刷新就绪状态</NButton>
</div>
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
<p v-if="readiness" class="mt-3 text-xs">
已就绪 {{ readiness.ready }}/{{ readiness.total }} · 阻塞 {{ readiness.blocked }}
</p>
<div v-if="readiness?.items.length" class="assembly-list mt-3">
<article v-for="item in readiness.items" :key="item.episodeId" class="assembly-row">
<div class="min-w-0">
<h4 class="truncate text-sm font-semibold">
{{ item.episodeNo }} · {{ item.title }}
</h4>
<p class="mt-1 text-xs text-muted">
主视频 {{ item.readyShots }}/{{ item.totalShots }} · {{ item.totalDurationSeconds }}
</p>
<NProgress
class="mt-2"
type="line"
:show-indicator="false"
:percentage="
item.totalShots ? Math.round((item.readyShots / item.totalShots) * 100) : 0
"
:status="item.ready ? 'success' : 'default'"
/>
<p
v-for="(issue, index) in item.issues.slice(0, 3)"
:key="index"
class="mt-1 text-xs text-muted"
>
<span v-if="issue.shotNo">镜头 {{ issue.shotNo }}</span>{{ issue.reason }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<NButton
size="small"
:disabled="!item.ready || !!assembling"
:loading="assembling === item.episodeId"
@click="assemble(item.episodeId)"
>生成无声成片</NButton
>
<NButton
v-if="resultUrl(item.episodeId)"
tag="a"
size="small"
:href="resultUrl(item.episodeId) || undefined"
target="_blank"
rel="noopener"
>查看成片</NButton
>
</div>
</article>
</div>
<p v-else-if="readiness && !loading" class="mt-3 text-xs text-muted">当前项目还没有可拼接的剧集</p>
</NSpin>
</NCollapseItem>
</NCollapse>
</template>
<style scoped>
@reference "../../../styles/styles.css";
.assembly-list {
@apply grid gap-2;
}
.assembly-row {
@apply grid grid-cols-[minmax(0,_1fr)_auto] items-center gap-4 p-4 bg-(--app-subtle);
}
@media (max-width: 760px) {
.assembly-row {
@apply grid-cols-[minmax(0,_1fr)];
}
}
</style>