feat: 实现分镜视频功能和提示词优化功能
主要功能: 1. 分镜视频创作功能 - 支持文生图生成分镜图 - 支持直接上传分镜图生成视频 - 两步式流程:生成分镜图 -> 生成视频 - 完整的任务管理和状态轮询 2. 提示词优化功能 - 为所有创作页面添加一键优化按钮 - 支持三种优化类型:文生视频、图生视频、分镜视频 - 使用GPT-4o-mini进行智能优化 - 完善的错误处理和用户体验 技术改进: - 使用@Async和@Transactional优化异步处理 - 增强错误处理和超时控制 - 改进前端状态管理和用户体验 - 添加完整的代码审查文档
This commit is contained in:
@@ -127,6 +127,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import cleanupApi from '@/api/cleanup'
|
||||
import api from '@/api/request'
|
||||
|
||||
// 响应式数据
|
||||
const loadingStats = ref(false)
|
||||
@@ -167,18 +169,9 @@ const testGetStats = async () => {
|
||||
statsError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/cleanup-stats', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
if (response.ok) {
|
||||
statsResult.value = await response.json()
|
||||
ElMessage.success('获取统计信息成功')
|
||||
} else {
|
||||
statsError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await cleanupApi.getCleanupStats()
|
||||
statsResult.value = response.data
|
||||
ElMessage.success('获取统计信息成功')
|
||||
} catch (error) {
|
||||
statsError.value = error.message
|
||||
ElMessage.error('获取统计信息失败')
|
||||
@@ -193,20 +186,9 @@ const testFullCleanup = async () => {
|
||||
cleanupError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/full-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
cleanupResult.value = await response.json()
|
||||
ElMessage.success('完整清理执行成功')
|
||||
} else {
|
||||
cleanupError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await cleanupApi.performFullCleanup()
|
||||
cleanupResult.value = response.data
|
||||
ElMessage.success('完整清理执行成功')
|
||||
} catch (error) {
|
||||
cleanupError.value = error.message
|
||||
ElMessage.error('执行完整清理失败')
|
||||
@@ -224,20 +206,9 @@ const testUserCleanup = async () => {
|
||||
userCleanupError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/cleanup/user-tasks/${userCleanupForm.username}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
userCleanupResult.value = await response.json()
|
||||
ElMessage.success(`用户 ${userCleanupForm.username} 的任务清理完成`)
|
||||
} else {
|
||||
userCleanupError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await cleanupApi.cleanupUserTasks(userCleanupForm.username)
|
||||
userCleanupResult.value = response.data
|
||||
ElMessage.success(`用户 ${userCleanupForm.username} 的任务清理完成`)
|
||||
} catch (error) {
|
||||
userCleanupError.value = error.message
|
||||
ElMessage.error('清理用户任务失败')
|
||||
@@ -252,13 +223,9 @@ const testQueueStatus = async () => {
|
||||
queueError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/diagnostic/queue-status')
|
||||
if (response.ok) {
|
||||
queueResult.value = await response.json()
|
||||
ElMessage.success('获取队列状态成功')
|
||||
} else {
|
||||
queueError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await api.get('/diagnostic/queue-status')
|
||||
queueResult.value = response.data
|
||||
ElMessage.success('获取队列状态成功')
|
||||
} catch (error) {
|
||||
queueError.value = error.message
|
||||
ElMessage.error('获取队列状态失败')
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
rows="6"
|
||||
></textarea>
|
||||
<div class="optimize-btn">
|
||||
<button class="optimize-button">
|
||||
✨ 一键优化
|
||||
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
|
||||
✨ {{ optimizingPrompt ? '优化中...' : '一键优化' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,6 +277,7 @@ import { ElMessage, ElLoading } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -301,6 +302,7 @@ const taskStatus = ref('')
|
||||
const stopPolling = ref(null)
|
||||
const showInProgress = ref(false)
|
||||
const watermarkOption = ref('without')
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
|
||||
// 用户菜单相关
|
||||
const showUserMenu = ref(false)
|
||||
@@ -602,6 +604,72 @@ const formatDate = (dateString) => {
|
||||
return `${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 优化提示词
|
||||
const optimizePromptHandler = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
ElMessage.warning('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (inputText.value.length > 2000) {
|
||||
ElMessage.warning('提示词过长,请控制在2000字符以内')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
optimizingPrompt.value = true
|
||||
const loading = ElLoading.service({
|
||||
lock: false,
|
||||
text: '正在优化提示词,请稍候...',
|
||||
background: 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
const response = await optimizePrompt(inputText.value.trim(), 'image-to-video')
|
||||
|
||||
loading.close()
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const data = response.data.data
|
||||
const optimized = data.optimizedPrompt
|
||||
|
||||
// 检查是否真正优化了
|
||||
if (data.optimized && optimized !== inputText.value.trim()) {
|
||||
inputText.value = optimized
|
||||
ElMessage.success('提示词优化成功!')
|
||||
} else {
|
||||
ElMessage.warning('提示词已优化,但可能无明显变化')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '优化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('优化提示词失败:', error)
|
||||
|
||||
let errorMessage = '优化提示词失败'
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 400) {
|
||||
errorMessage = error.response.data?.message || '请求参数错误'
|
||||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请稍后重试'
|
||||
} else if (status >= 500) {
|
||||
errorMessage = '服务器错误,请稍后重试'
|
||||
} else {
|
||||
errorMessage = error.response.data?.message || '优化失败'
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接'
|
||||
} else {
|
||||
errorMessage = error.message || '优化失败'
|
||||
}
|
||||
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
optimizingPrompt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建同款
|
||||
const createSimilar = () => {
|
||||
// 保持当前设置,重新生成
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图生视频</span>
|
||||
</div>
|
||||
<div class="nav-item active storyboard-item">
|
||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>分镜视频</span>
|
||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
||||
@@ -208,6 +208,10 @@ const goToImageToVideo = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
const goToStoryboardVideoCreate = () => {
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
const goToCreate = (work) => {
|
||||
// 跳转到分镜视频创作页面
|
||||
router.push('/storyboard-video/create')
|
||||
|
||||
@@ -35,14 +35,26 @@
|
||||
|
||||
<!-- 分镜步骤标签 -->
|
||||
<div class="storyboard-steps">
|
||||
<div class="step active">生成分镜图</div>
|
||||
<div class="step">生成视频</div>
|
||||
<div
|
||||
class="step"
|
||||
:class="{ active: currentStep === 'generate' }"
|
||||
@click="switchToGenerateStep"
|
||||
>
|
||||
生成分镜图
|
||||
</div>
|
||||
<div
|
||||
class="step"
|
||||
:class="{ active: currentStep === 'video', disabled: !generatedImageUrl && !uploadedImage }"
|
||||
@click="switchToVideoStep"
|
||||
>
|
||||
生成视频
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成分镜图区域 -->
|
||||
<div class="storyboard-section">
|
||||
<!-- 生成分镜图区域 - 只在第一步显示 -->
|
||||
<div class="storyboard-section" v-if="currentStep === 'generate'">
|
||||
<div class="image-upload-btn" @click="uploadImage">
|
||||
<span>+ 图片 (可选)</span>
|
||||
<span>+ 上传分镜图 (可直接生成视频)</span>
|
||||
</div>
|
||||
|
||||
<!-- 已上传的图片预览 -->
|
||||
@@ -59,13 +71,21 @@
|
||||
rows="6"
|
||||
></textarea>
|
||||
<div class="optimize-btn">
|
||||
<button class="optimize-button">
|
||||
✨ 一键优化
|
||||
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
|
||||
✨ {{ optimizingPrompt ? '优化中...' : '一键优化' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成视频区域 - 只在第二步显示 -->
|
||||
<div class="storyboard-section" v-if="currentStep === 'video'">
|
||||
<div class="generated-image-preview">
|
||||
<img v-if="generatedImageUrl || uploadedImage" :src="generatedImageUrl || uploadedImage" alt="分镜图" />
|
||||
<div v-else class="placeholder-text">暂无分镜图</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频设置 -->
|
||||
<div class="video-settings">
|
||||
<div class="setting-item">
|
||||
@@ -90,8 +110,12 @@
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<div class="generate-section">
|
||||
<button class="generate-btn" @click="startGenerate">
|
||||
开始生成
|
||||
<button
|
||||
class="generate-btn"
|
||||
@click="handleGenerateClick"
|
||||
:disabled="inProgress || (!uploadedImage && !inputText.trim() && currentStep === 'generate') || (currentStep === 'video' && !generatedImageUrl && !uploadedImage)"
|
||||
>
|
||||
{{ getButtonText() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +129,17 @@
|
||||
</div>
|
||||
|
||||
<div class="preview-content">
|
||||
<div class="preview-placeholder">
|
||||
<!-- 显示上传的图片或生成的图片 -->
|
||||
<div v-if="uploadedImage || generatedImageUrl" class="preview-image">
|
||||
<img :src="uploadedImage || generatedImageUrl" alt="分镜图" />
|
||||
</div>
|
||||
<!-- 显示加载状态 -->
|
||||
<div v-else-if="inProgress" class="preview-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">{{ currentStep === 'generate' ? '正在生成分镜图,请稍候...' : '正在生成视频,请稍候...' }}</div>
|
||||
</div>
|
||||
<!-- 显示占位符 -->
|
||||
<div v-else class="preview-placeholder">
|
||||
<div class="placeholder-text">开始创作您的第一个作品吧!</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,8 +150,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createStoryboardTask, getStoryboardTask } from '@/api/storyboardVideo'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -126,9 +164,14 @@ const inputText = ref('')
|
||||
const aspectRatio = ref('16:9')
|
||||
const hdMode = ref(false)
|
||||
const inProgress = ref(false)
|
||||
const currentStep = ref('generate') // 'generate' 或 'video'
|
||||
|
||||
// 图片上传
|
||||
const uploadedImage = ref('')
|
||||
const generatedImageUrl = ref('')
|
||||
const taskId = ref('')
|
||||
const pollIntervalId = ref(null) // 保存轮询定时器ID
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
|
||||
// 导航函数
|
||||
const goBack = () => {
|
||||
@@ -143,6 +186,70 @@ const goToImageToVideo = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
// 切换到生成分镜图步骤
|
||||
const switchToGenerateStep = () => {
|
||||
currentStep.value = 'generate'
|
||||
}
|
||||
|
||||
// 切换到视频步骤
|
||||
const switchToVideoStep = () => {
|
||||
// 如果有上传的图片或生成的分镜图,可以切换
|
||||
if (uploadedImage.value || generatedImageUrl.value) {
|
||||
currentStep.value = 'video'
|
||||
} else {
|
||||
ElMessage.warning('请先上传分镜图或生成分镜图')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取按钮文本
|
||||
const getButtonText = () => {
|
||||
if (currentStep.value === 'video') {
|
||||
return '生成视频'
|
||||
}
|
||||
// 第一步:根据是否有图片和提示词显示不同文本
|
||||
if (uploadedImage.value) {
|
||||
return '使用上传图片生成视频'
|
||||
}
|
||||
if (inputText.value.trim()) {
|
||||
return '开始生成分镜图'
|
||||
}
|
||||
return '开始生成'
|
||||
}
|
||||
|
||||
// 处理生成按钮点击
|
||||
const handleGenerateClick = () => {
|
||||
console.log('handleGenerateClick 被调用,当前步骤:', currentStep.value)
|
||||
|
||||
// 如果已经切换到视频步骤,直接生成视频
|
||||
if (currentStep.value === 'video') {
|
||||
startVideoGenerate()
|
||||
return
|
||||
}
|
||||
|
||||
// 第一步的逻辑:
|
||||
// 1. 如果上传了图片,直接使用上传的图片生成视频
|
||||
if (uploadedImage.value) {
|
||||
// 使用上传的图片作为分镜图
|
||||
generatedImageUrl.value = uploadedImage.value
|
||||
// 切换到视频步骤并生成视频
|
||||
currentStep.value = 'video'
|
||||
// 延迟一点,让UI更新
|
||||
setTimeout(() => {
|
||||
startVideoGenerate()
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 如果只有提示词,生成分镜图
|
||||
if (inputText.value.trim()) {
|
||||
startGenerate()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 如果都没有,提示用户
|
||||
ElMessage.warning('请上传分镜图或输入提示词')
|
||||
}
|
||||
|
||||
// 图片上传处理
|
||||
const uploadImage = () => {
|
||||
const input = document.createElement('input')
|
||||
@@ -154,6 +261,10 @@ const uploadImage = () => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
uploadedImage.value = e.target.result
|
||||
// 上传图片后,自动使用上传的图片作为分镜图
|
||||
generatedImageUrl.value = e.target.result
|
||||
// 如果有上传的图片,可以自动切换到视频步骤(可选)
|
||||
// currentStep.value = 'video'
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
@@ -162,24 +273,327 @@ const uploadImage = () => {
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
// 保存上传图片的值,用于判断是否与生成的图片相同
|
||||
const wasSameImage = uploadedImage.value && generatedImageUrl.value === uploadedImage.value
|
||||
uploadedImage.value = ''
|
||||
// 如果上传的图片被删除,且它被用作生成的分镜图,也清除生成的图片URL
|
||||
if (wasSameImage) {
|
||||
generatedImageUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const startGenerate = () => {
|
||||
// 优化提示词
|
||||
const optimizePromptHandler = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
alert('请输入描述文字')
|
||||
ElMessage.warning('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
inProgress.value = true
|
||||
alert('开始生成分镜图...')
|
||||
// 长度检查
|
||||
if (inputText.value.length > 2000) {
|
||||
ElMessage.warning('提示词过长,请控制在2000字符以内')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用真实生成API
|
||||
setTimeout(() => {
|
||||
inProgress.value = false
|
||||
alert('分镜图生成完成!')
|
||||
}, 3000)
|
||||
try {
|
||||
optimizingPrompt.value = true
|
||||
const loading = ElLoading.service({
|
||||
lock: false,
|
||||
text: '正在优化提示词,请稍候...',
|
||||
background: 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
const response = await optimizePrompt(inputText.value.trim(), 'storyboard')
|
||||
|
||||
loading.close()
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const data = response.data.data
|
||||
const optimized = data.optimizedPrompt
|
||||
|
||||
// 检查是否真正优化了
|
||||
if (data.optimized && optimized !== inputText.value.trim()) {
|
||||
inputText.value = optimized
|
||||
ElMessage.success('提示词优化成功!')
|
||||
} else {
|
||||
ElMessage.warning('提示词已优化,但可能无明显变化')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '优化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('优化提示词失败:', error)
|
||||
|
||||
let errorMessage = '优化提示词失败'
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 400) {
|
||||
errorMessage = error.response.data?.message || '请求参数错误'
|
||||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请稍后重试'
|
||||
} else if (status >= 500) {
|
||||
errorMessage = '服务器错误,请稍后重试'
|
||||
} else {
|
||||
errorMessage = error.response.data?.message || '优化失败'
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接'
|
||||
} else {
|
||||
errorMessage = error.message || '优化失败'
|
||||
}
|
||||
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
optimizingPrompt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始生成分镜图
|
||||
const startGenerate = async () => {
|
||||
console.log('startGenerate 被调用')
|
||||
|
||||
if (!inputText.value.trim()) {
|
||||
ElMessage.warning('请输入描述文字')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('开始创建任务,参数:', {
|
||||
prompt: inputText.value,
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
imageUrl: uploadedImage.value || null
|
||||
})
|
||||
|
||||
inProgress.value = true
|
||||
ElMessage.info('开始生成分镜图...')
|
||||
|
||||
// 调用API创建任务
|
||||
const response = await createStoryboardTask({
|
||||
prompt: inputText.value,
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
imageUrl: uploadedImage.value || null
|
||||
})
|
||||
|
||||
console.log('API响应:', response)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
ElMessage.success('分镜图任务创建成功!')
|
||||
taskId.value = response.data.data.taskId
|
||||
console.log('Task created:', response.data.data)
|
||||
|
||||
// 开始轮询任务状态,获取生成的图片
|
||||
// inProgress 将在轮询完成时设置为 false
|
||||
pollTaskStatus(response.data.data.taskId)
|
||||
} else {
|
||||
console.error('创建任务失败,响应:', response.data)
|
||||
ElMessage.error(response.data?.message || '创建任务失败')
|
||||
inProgress.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成分镜图失败,完整错误:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error.message,
|
||||
response: error.response,
|
||||
stack: error.stack
|
||||
})
|
||||
ElMessage.error('生成分镜图失败: ' + (error.response?.data?.message || error.message))
|
||||
inProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询任务状态
|
||||
const pollTaskStatus = async (taskId) => {
|
||||
// 清除之前的轮询(如果存在)
|
||||
if (pollIntervalId.value) {
|
||||
clearInterval(pollIntervalId.value)
|
||||
pollIntervalId.value = null
|
||||
}
|
||||
|
||||
const maxAttempts = 30
|
||||
let attempts = 0
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
// 先检查是否超过最大尝试次数
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(poll)
|
||||
pollIntervalId.value = null
|
||||
inProgress.value = false
|
||||
ElMessage.warning('任务超时,请稍后查看')
|
||||
return
|
||||
}
|
||||
|
||||
attempts++
|
||||
|
||||
try {
|
||||
// 调用获取任务详情的API
|
||||
const response = await getStoryboardTask(taskId)
|
||||
console.log('轮询任务状态:', response.data)
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const task = response.data.data
|
||||
console.log('任务状态:', task.status, '图片URL:', task.resultUrl)
|
||||
|
||||
if (task.status === 'COMPLETED' && task.resultUrl) {
|
||||
clearInterval(poll)
|
||||
pollIntervalId.value = null
|
||||
console.log('分镜图生成完成,图片URL:', task.resultUrl)
|
||||
generatedImageUrl.value = task.resultUrl
|
||||
inProgress.value = false
|
||||
ElMessage.success('分镜图生成完成!')
|
||||
// 自动跳转到第二步
|
||||
setTimeout(() => {
|
||||
currentStep.value = 'video'
|
||||
}, 500)
|
||||
return
|
||||
} else if (task.status === 'FAILED') {
|
||||
clearInterval(poll)
|
||||
pollIntervalId.value = null
|
||||
inProgress.value = false
|
||||
ElMessage.error('分镜图生成失败: ' + (task.errorMessage || '未知错误'))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error)
|
||||
// 错误时继续轮询,直到达到最大次数
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// 保存轮询ID
|
||||
pollIntervalId.value = poll
|
||||
}
|
||||
|
||||
// 将图片URL转换为File对象
|
||||
const urlToFile = async (url, filename) => {
|
||||
try {
|
||||
let blob
|
||||
|
||||
// 如果是base64格式
|
||||
if (url.startsWith('data:image')) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status}`)
|
||||
}
|
||||
blob = await response.blob()
|
||||
return new File([blob], filename, { type: blob.type })
|
||||
} else {
|
||||
// 如果是普通URL(可能跨域)
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status}`)
|
||||
}
|
||||
blob = await response.blob()
|
||||
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
|
||||
} catch (fetchError) {
|
||||
// 如果fetch失败(可能是CORS),尝试通过代理或提示用户
|
||||
console.error('直接获取图片失败,可能是CORS问题:', fetchError)
|
||||
throw new Error('无法加载图片,请确保图片URL可以访问')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('转换图片URL失败:', error)
|
||||
throw new Error('无法加载图片: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
// 生成视频
|
||||
const startVideoGenerate = async () => {
|
||||
console.log('startVideoGenerate 被调用')
|
||||
|
||||
// 优先使用上传的图片,其次使用生成的分镜图
|
||||
const imageUrl = uploadedImage.value || generatedImageUrl.value
|
||||
|
||||
if (!imageUrl) {
|
||||
ElMessage.warning('请先上传分镜图或生成分镜图')
|
||||
return
|
||||
}
|
||||
|
||||
// 提示词可选,如果没有则使用默认提示词或空字符串
|
||||
// 图生视频API可能需要提示词,使用上传图片时的提示词或默认提示词
|
||||
let prompt = inputText.value.trim()
|
||||
if (!prompt && uploadedImage.value) {
|
||||
// 如果用户只上传了图片没有输入提示词,可以使用空字符串或默认提示词
|
||||
prompt = '根据图片生成视频' // 默认提示词
|
||||
}
|
||||
|
||||
try {
|
||||
inProgress.value = true
|
||||
ElMessage.info('开始生成视频...')
|
||||
|
||||
// 将图片URL转换为File对象
|
||||
console.log('转换图片URL:', imageUrl)
|
||||
let imageFile
|
||||
|
||||
// 如果是base64格式(上传的图片),直接转换
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
const base64Data = imageUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: 'image/jpeg' })
|
||||
imageFile = new File([blob], 'storyboard-image.jpg', { type: 'image/jpeg' })
|
||||
} else {
|
||||
// 如果是URL,使用fetch转换
|
||||
imageFile = await urlToFile(imageUrl, 'storyboard-image.jpg')
|
||||
}
|
||||
|
||||
// 调用图生视频API
|
||||
console.log('调用图生视频API,参数:', {
|
||||
prompt: prompt,
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
duration: 5,
|
||||
firstFrame: imageFile
|
||||
})
|
||||
|
||||
const response = await imageToVideoApi.createTask({
|
||||
firstFrame: imageFile,
|
||||
prompt: prompt,
|
||||
aspectRatio: aspectRatio.value,
|
||||
duration: 5, // 默认5秒
|
||||
hdMode: hdMode.value
|
||||
})
|
||||
|
||||
console.log('图生视频API响应:', response)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const taskId = response.data.data.taskId
|
||||
ElMessage.success('视频任务创建成功!')
|
||||
console.log('视频任务创建成功,任务ID:', taskId)
|
||||
|
||||
// 跳转到图生视频结果页面或者在这里显示结果
|
||||
// 可以轮询任务状态或跳转到任务详情页
|
||||
router.push(`/image-to-video/detail/${taskId}`)
|
||||
} else {
|
||||
console.error('创建视频任务失败,响应:', response.data)
|
||||
ElMessage.error(response.data?.message || '创建视频任务失败')
|
||||
inProgress.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成视频失败,完整错误:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error.message,
|
||||
response: error.response,
|
||||
stack: error.stack
|
||||
})
|
||||
ElMessage.error('生成视频失败: ' + (error.response?.data?.message || error.message))
|
||||
inProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
onBeforeUnmount(() => {
|
||||
if (pollIntervalId.value) {
|
||||
clearInterval(pollIntervalId.value)
|
||||
pollIntervalId.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -381,11 +795,44 @@ const startGenerate = () => {
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.step:hover:not(.active) {
|
||||
.step:hover:not(.active):not(.disabled) {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.step.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.step.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 生成的图片预览 */
|
||||
.generated-image-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #0a0a0a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.generated-image-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.generated-image-preview .placeholder-text {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 分镜图区域 */
|
||||
.storyboard-section {
|
||||
display: flex;
|
||||
@@ -658,6 +1105,55 @@ const startGenerate = () => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 预览图片 */
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-image img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #2a2a2a;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.main-content {
|
||||
|
||||
@@ -387,6 +387,7 @@ import {
|
||||
Delete,
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import cleanupApi from '@/api/cleanup'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -507,18 +508,9 @@ const getAuthHeaders = () => {
|
||||
const refreshStats = async () => {
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/cleanup-stats', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
if (response.ok) {
|
||||
cleanupStats.value = await response.json()
|
||||
ElMessage.success('统计信息刷新成功')
|
||||
} else {
|
||||
ElMessage.error('获取统计信息失败')
|
||||
}
|
||||
const response = await cleanupApi.getCleanupStats()
|
||||
cleanupStats.value = response.data
|
||||
ElMessage.success('统计信息刷新成功')
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
ElMessage.error('获取统计信息失败')
|
||||
@@ -530,23 +522,11 @@ const refreshStats = async () => {
|
||||
const performFullCleanup = async () => {
|
||||
loadingCleanup.value = true
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/full-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
ElMessage.success('完整清理执行成功')
|
||||
console.log('清理结果:', result)
|
||||
// 刷新统计信息
|
||||
await refreshStats()
|
||||
} else {
|
||||
ElMessage.error('执行完整清理失败')
|
||||
}
|
||||
const response = await cleanupApi.performFullCleanup()
|
||||
ElMessage.success('完整清理执行成功')
|
||||
console.log('清理结果:', response.data)
|
||||
// 刷新统计信息
|
||||
await refreshStats()
|
||||
} catch (error) {
|
||||
console.error('执行完整清理失败:', error)
|
||||
ElMessage.error('执行完整清理失败')
|
||||
@@ -566,6 +546,27 @@ const performUserCleanup = async () => {
|
||||
const valid = await userCleanupFormRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loadingUserCleanup.value = true
|
||||
try {
|
||||
const response = await cleanupApi.cleanupUserTasks(userCleanupForm.username)
|
||||
ElMessage.success('用户任务清理成功')
|
||||
console.log('清理结果:', response.data)
|
||||
// 刷新统计信息
|
||||
await refreshStats()
|
||||
// 关闭对话框
|
||||
handleCloseUserCleanupDialog()
|
||||
} catch (error) {
|
||||
console.error('清理用户任务失败:', error)
|
||||
ElMessage.error('清理用户任务失败')
|
||||
} finally {
|
||||
loadingUserCleanup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performUserCleanup_old = async () => {
|
||||
const valid = await userCleanupFormRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loadingUserCleanup.value = true
|
||||
try {
|
||||
const response = await fetch(`/api/cleanup/user-tasks/${userCleanupForm.username}`, {
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
rows="8"
|
||||
></textarea>
|
||||
<div class="optimize-btn">
|
||||
<button class="optimize-button">
|
||||
✨ 一键优化
|
||||
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
|
||||
✨ {{ optimizingPrompt ? '优化中...' : '一键优化' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,6 +241,7 @@ import { textToVideoApi } from '@/api/textToVideo'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -257,6 +258,7 @@ const taskStatus = ref('')
|
||||
const stopPolling = ref(null)
|
||||
const showInProgress = ref(false)
|
||||
const watermarkOption = ref('without')
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
|
||||
// 用户菜单相关
|
||||
const showUserMenu = ref(false)
|
||||
@@ -477,6 +479,72 @@ const formatDate = (dateString) => {
|
||||
return `${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 优化提示词
|
||||
const optimizePromptHandler = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
ElMessage.warning('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (inputText.value.length > 2000) {
|
||||
ElMessage.warning('提示词过长,请控制在2000字符以内')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
optimizingPrompt.value = true
|
||||
const loading = ElLoading.service({
|
||||
lock: false,
|
||||
text: '正在优化提示词,请稍候...',
|
||||
background: 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
const response = await optimizePrompt(inputText.value.trim(), 'text-to-video')
|
||||
|
||||
loading.close()
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const data = response.data.data
|
||||
const optimized = data.optimizedPrompt
|
||||
|
||||
// 检查是否真正优化了
|
||||
if (data.optimized && optimized !== inputText.value.trim()) {
|
||||
inputText.value = optimized
|
||||
ElMessage.success('提示词优化成功!')
|
||||
} else {
|
||||
ElMessage.warning('提示词已优化,但可能无明显变化')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '优化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('优化提示词失败:', error)
|
||||
|
||||
let errorMessage = '优化提示词失败'
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 400) {
|
||||
errorMessage = error.response.data?.message || '请求参数错误'
|
||||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请稍后重试'
|
||||
} else if (status >= 500) {
|
||||
errorMessage = '服务器错误,请稍后重试'
|
||||
} else {
|
||||
errorMessage = error.response.data?.message || '优化失败'
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接'
|
||||
} else {
|
||||
errorMessage = error.message || '优化失败'
|
||||
}
|
||||
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
optimizingPrompt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建同款
|
||||
const createSimilar = () => {
|
||||
// 保持当前设置,重新生成
|
||||
|
||||
Reference in New Issue
Block a user