419 lines
10 KiB
Vue
419 lines
10 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="content-wrapper">
|
|||
|
|
<!-- 左侧配置面板 -->
|
|||
|
|
<div class="config-panel">
|
|||
|
|
<div class="config-tabs">
|
|||
|
|
<a-tabs v-model:activeKey="activeCategory" :tabBarStyle="{ margin: 0 }">
|
|||
|
|
<a-tab-pane v-for="cat in categoryList" :key="cat" :tab="cat" />
|
|||
|
|
</a-tabs>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 预设提示词 -->
|
|||
|
|
<div class="preset-prompts">
|
|||
|
|
<div class="preset-label">快捷提示词</div>
|
|||
|
|
<div class="preset-list">
|
|||
|
|
<a-button
|
|||
|
|
v-for="(prompt, index) in currentPrompts"
|
|||
|
|
:key="index"
|
|||
|
|
class="preset-btn"
|
|||
|
|
size="small"
|
|||
|
|
@click="applyPrompt(prompt)"
|
|||
|
|
>
|
|||
|
|
{{ prompt.length > 20 ? prompt.substring(0, 20) + '...' : prompt }}
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-container">
|
|||
|
|
<BasicForm @register="registerForm" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="action-container">
|
|||
|
|
<a-button type="primary" size="large" block @click="handleGenerate" :loading="generating" :disabled="generating">
|
|||
|
|
<Icon icon="ant-design:video-camera-outlined" />
|
|||
|
|
{{ generating ? '生成中...' : '开始生成' }}
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 右侧预览面板 -->
|
|||
|
|
<div class="preview-panel">
|
|||
|
|
<div class="panel-title">生成结果</div>
|
|||
|
|
<div class="preview-content">
|
|||
|
|
<!-- 空状态 -->
|
|||
|
|
<div v-if="!videoUrl && !generating" class="empty-state">
|
|||
|
|
<Icon icon="ant-design:video-camera-outlined" size="64" color="#ccc" />
|
|||
|
|
<p>在左侧输入视频描述,点击开始生成</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 生成中 -->
|
|||
|
|
<div v-if="generating" class="loading-state">
|
|||
|
|
<a-spin size="large" />
|
|||
|
|
<div class="loading-text">
|
|||
|
|
<p>正在生成视频,请耐心等待...</p>
|
|||
|
|
<p class="elapsed-time">已等待 {{ elapsedTimeText }}</p>
|
|||
|
|
<p class="status-text">{{ statusText }}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 生成完成 -->
|
|||
|
|
<div v-if="videoUrl && !generating" class="result-video-wrapper">
|
|||
|
|
<video :src="videoUrl" controls class="result-video" />
|
|||
|
|
<div class="video-actions">
|
|||
|
|
<a-button type="primary" @click="handleDownload">
|
|||
|
|
<Icon icon="ant-design:download-outlined" />
|
|||
|
|
下载视频
|
|||
|
|
</a-button>
|
|||
|
|
<a-button @click="handleReset">
|
|||
|
|
<Icon icon="ant-design:redo-outlined" />
|
|||
|
|
重新生成
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 生成失败 -->
|
|||
|
|
<div v-if="errorMessage && !generating" class="error-state">
|
|||
|
|
<Icon icon="ant-design:close-circle-outlined" size="64" color="#ff4d4f" />
|
|||
|
|
<p class="error-text">{{ errorMessage }}</p>
|
|||
|
|
<a-button type="primary" @click="handleReset">重试</a-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script lang="ts" setup>
|
|||
|
|
import { ref, computed, onUnmounted } from 'vue';
|
|||
|
|
import { BasicForm, useForm } from '@/components/Form';
|
|||
|
|
import { useMessage } from '@/hooks/web/useMessage';
|
|||
|
|
import { Icon } from '@/components/Icon';
|
|||
|
|
import { submitVideoTask, queryVideoTask, getPresetPrompts } from './AiVideo.api';
|
|||
|
|
import { videoFormSchema, categoryList, fallbackPrompts } from './AiVideo.data';
|
|||
|
|
|
|||
|
|
const { createMessage } = useMessage();
|
|||
|
|
|
|||
|
|
const activeCategory = ref('通用演示');
|
|||
|
|
const generating = ref(false);
|
|||
|
|
const videoUrl = ref('');
|
|||
|
|
const errorMessage = ref('');
|
|||
|
|
const elapsedSeconds = ref(0);
|
|||
|
|
const statusText = ref('任务已提交,排队中...');
|
|||
|
|
|
|||
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|||
|
|
let elapsedTimer: ReturnType<typeof setInterval> | null = null;
|
|||
|
|
|
|||
|
|
// 预设提示词(优先从后端加载)
|
|||
|
|
const promptsMap = ref<Record<string, string[]>>({ ...fallbackPrompts });
|
|||
|
|
|
|||
|
|
// 加载后端预设提示词
|
|||
|
|
getPresetPrompts()
|
|||
|
|
.then((data) => {
|
|||
|
|
if (data && Object.keys(data).length > 0) {
|
|||
|
|
promptsMap.value = data;
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.catch(() => {
|
|||
|
|
// 使用备用提示词
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const currentPrompts = computed(() => {
|
|||
|
|
return promptsMap.value[activeCategory.value] || [];
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const elapsedTimeText = computed(() => {
|
|||
|
|
const minutes = Math.floor(elapsedSeconds.value / 60);
|
|||
|
|
const seconds = elapsedSeconds.value % 60;
|
|||
|
|
if (minutes > 0) {
|
|||
|
|
return `${minutes}分${seconds}秒`;
|
|||
|
|
}
|
|||
|
|
return `${seconds}秒`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const [registerForm, { validate, setFieldsValue }] = useForm({
|
|||
|
|
schemas: videoFormSchema,
|
|||
|
|
labelWidth: 100,
|
|||
|
|
actionColOptions: { span: 24 },
|
|||
|
|
showActionButtonGroup: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function applyPrompt(prompt: string) {
|
|||
|
|
setFieldsValue({ prompt });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleGenerate() {
|
|||
|
|
try {
|
|||
|
|
const values = await validate();
|
|||
|
|
if (!values.prompt || !values.prompt.trim()) {
|
|||
|
|
createMessage.warning('请输入视频描述');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重置状态
|
|||
|
|
generating.value = true;
|
|||
|
|
videoUrl.value = '';
|
|||
|
|
errorMessage.value = '';
|
|||
|
|
elapsedSeconds.value = 0;
|
|||
|
|
statusText.value = '任务已提交,排队中...';
|
|||
|
|
|
|||
|
|
// 启动计时器
|
|||
|
|
elapsedTimer = setInterval(() => {
|
|||
|
|
elapsedSeconds.value++;
|
|||
|
|
}, 1000);
|
|||
|
|
|
|||
|
|
// 提交任务
|
|||
|
|
const submitResult = await submitVideoTask({
|
|||
|
|
prompt: values.prompt.trim(),
|
|||
|
|
category: activeCategory.value,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!submitResult || !submitResult.taskId) {
|
|||
|
|
throw new Error(submitResult?.message || '提交任务失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
statusText.value = '视频生成中...';
|
|||
|
|
|
|||
|
|
// 开始轮询
|
|||
|
|
pollTimer = setInterval(async () => {
|
|||
|
|
try {
|
|||
|
|
const queryResult = await queryVideoTask(submitResult.taskId);
|
|||
|
|
if (queryResult.status === 'SUCCESS') {
|
|||
|
|
clearTimers();
|
|||
|
|
generating.value = false;
|
|||
|
|
videoUrl.value = queryResult.videoUrl;
|
|||
|
|
createMessage.success('视频生成成功!');
|
|||
|
|
} else if (queryResult.status === 'FAIL') {
|
|||
|
|
clearTimers();
|
|||
|
|
generating.value = false;
|
|||
|
|
errorMessage.value = queryResult.message || '视频生成失败';
|
|||
|
|
}
|
|||
|
|
// PROCESSING状态继续轮询
|
|||
|
|
} catch (e: any) {
|
|||
|
|
clearTimers();
|
|||
|
|
generating.value = false;
|
|||
|
|
errorMessage.value = '查询任务状态失败: ' + (e.message || '未知错误');
|
|||
|
|
}
|
|||
|
|
}, 5000);
|
|||
|
|
} catch (error: any) {
|
|||
|
|
clearTimers();
|
|||
|
|
generating.value = false;
|
|||
|
|
if (error?.errorFields) {
|
|||
|
|
// 表单验证失败,不显示额外错误
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
errorMessage.value = error.message || '提交任务失败';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearTimers() {
|
|||
|
|
if (pollTimer) {
|
|||
|
|
clearInterval(pollTimer);
|
|||
|
|
pollTimer = null;
|
|||
|
|
}
|
|||
|
|
if (elapsedTimer) {
|
|||
|
|
clearInterval(elapsedTimer);
|
|||
|
|
elapsedTimer = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleReset() {
|
|||
|
|
videoUrl.value = '';
|
|||
|
|
errorMessage.value = '';
|
|||
|
|
elapsedSeconds.value = 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleDownload() {
|
|||
|
|
if (!videoUrl.value) return;
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = videoUrl.value;
|
|||
|
|
a.download = `ai-video-${Date.now()}.mp4`;
|
|||
|
|
a.target = '_blank';
|
|||
|
|
a.click();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
clearTimers();
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style lang="less" scoped>
|
|||
|
|
.content-wrapper {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
gap: 16px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.config-panel {
|
|||
|
|
width: 550px;
|
|||
|
|
min-width: 350px;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
padding: 20px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
|
|||
|
|
.config-tabs {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
|
|||
|
|
:deep(.ant-tabs-nav::before) {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.ant-tabs-tab) {
|
|||
|
|
padding: 8px 0;
|
|||
|
|
margin: 0 24px 0 0;
|
|||
|
|
font-size: 15px;
|
|||
|
|
|
|||
|
|
&.ant-tabs-tab-active .ant-tabs-tab-btn {
|
|||
|
|
color: #1890ff;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.ant-tabs-ink-bar) {
|
|||
|
|
background: #1890ff;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preset-prompts {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
|
|||
|
|
.preset-label {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #8c8c8c;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preset-list {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px;
|
|||
|
|
|
|||
|
|
.preset-btn {
|
|||
|
|
color: #595959;
|
|||
|
|
border-color: #d9d9d9;
|
|||
|
|
font-size: 12px;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
color: #1890ff;
|
|||
|
|
border-color: #1890ff;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form-container {
|
|||
|
|
flex: 1;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-container {
|
|||
|
|
margin-top: 20px;
|
|||
|
|
padding-top: 20px;
|
|||
|
|
border-top: 1px solid #f0f0f0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-panel {
|
|||
|
|
flex: 1;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
padding: 20px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
|
|||
|
|
.preview-content {
|
|||
|
|
flex: 1;
|
|||
|
|
background: #f7f8fc;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-title {
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #1f2329;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
padding-left: 8px;
|
|||
|
|
border-left: 4px solid #1890ff;
|
|||
|
|
line-height: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-state {
|
|||
|
|
text-align: center;
|
|||
|
|
color: #8f959e;
|
|||
|
|
|
|||
|
|
p {
|
|||
|
|
margin-top: 16px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-state {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 16px;
|
|||
|
|
|
|||
|
|
.loading-text {
|
|||
|
|
text-align: center;
|
|||
|
|
color: #595959;
|
|||
|
|
|
|||
|
|
p {
|
|||
|
|
margin: 4px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.elapsed-time {
|
|||
|
|
font-size: 20px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #1890ff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-text {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #8c8c8c;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-video-wrapper {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
padding: 16px;
|
|||
|
|
|
|||
|
|
.result-video {
|
|||
|
|
flex: 1;
|
|||
|
|
width: 100%;
|
|||
|
|
max-height: calc(100% - 60px);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
background: #000;
|
|||
|
|
object-fit: contain;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.video-actions {
|
|||
|
|
margin-top: 16px;
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-state {
|
|||
|
|
text-align: center;
|
|||
|
|
|
|||
|
|
.error-text {
|
|||
|
|
margin: 16px 0;
|
|||
|
|
color: #ff4d4f;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|