feat: 添加任务状态级联触发器,优化支付和做同款功能

主要更新:
- 添加 MySQL 触发器实现 task_status 表到其他表的状态级联
- 移除控制器中的多表状态检查代码
- 完善做同款功能,支持参数传递
- 支付宝 USD 转 CNY 汇率转换
- 修复状态枚举映射问题

注意: 触发器仅在 task_status 更新时触发,部分代码仍直接更新业务表
This commit is contained in:
AIGC Developer
2025-12-08 13:54:02 +08:00
parent 624d560fb4
commit 3c37006ebd
84 changed files with 5325 additions and 1668 deletions

View File

@@ -15,12 +15,47 @@
<span class="points-number">{{ userStore.availablePoints }}</span>
</div>
<LanguageSwitcher />
<div class="user-avatar">
<div class="user-avatar" @click="toggleUserMenu" ref="userAvatarRef">
<img src="/images/backgrounds/avatar-default.svg" :alt="t('video.storyboard.userAvatar')" />
</div>
</div>
</header>
<!-- 用户菜单下拉 -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<!-- 管理员功能 -->
<template v-if="userStore.isAdmin">
<div class="menu-item" @click.stop="goToDashboard">
<el-icon><User /></el-icon>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click.stop="goToOrders">
<el-icon><Document /></el-icon>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToMembers">
<el-icon><User /></el-icon>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToSystemSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<!-- 退出登录 -->
<div class="menu-item" @click.stop="logout">
<el-icon><SwitchButton /></el-icon>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 左侧设置面板 -->
@@ -175,7 +210,7 @@
<button
class="generate-btn"
@click="handleGenerateClick"
:disabled="!isAuthenticated || inProgress || isCreatingTask || (currentStep === 'generate' && !hasUploadedImages && !inputText.trim()) || (currentStep === 'video' && !generatedImageUrl && !hasUploadedImages)"
:disabled="!isAuthenticated || isCreatingTask || (currentStep === 'generate' && !hasUploadedImages && !inputText.trim()) || (currentStep === 'video' && !generatedImageUrl && !hasUploadedImages)"
>
{{ isAuthenticated ? getButtonText() : t('video.storyboard.pleaseLogin') }}
</button>
@@ -205,10 +240,16 @@
<!-- 生成中的状态 -->
<div v-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ currentStep === 'generate' ? t('video.storyboard.generatingStoryboardText') : t('video.storyboard.generatingVideoText') }}</div>
<div class="progress-bar-large">
<div class="progress-fill-large" :style="{ width: (currentStep === 'video' ? videoProgress : 50) + '%' }"></div>
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : (currentStep === 'generate' ? t('video.storyboard.generatingStoryboardText') : t('video.storyboard.generatingVideoText')) }}</div>
<!-- 排队中不确定进度条 -->
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中动态进度条 -->
<div v-else class="progress-bar-large">
<div class="progress-fill-large animated" :style="{ width: (currentStep === 'video' ? videoProgress : 50) + '%' }"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ currentStep === 'video' ? videoProgress : 50 }}%</div>
</div>
</div>
@@ -374,9 +415,19 @@
</div>
</div>
<!-- 做同款按钮 -->
<!-- 操作按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">{{ t('profile.createSimilar') }}</button>
<button
v-if="task.status === 'COMPLETED' && task.videoResultUrl"
class="download-btn"
@click="downloadHistoryVideo(task)"
:title="t('video.storyboard.downloadVideo')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
</button>
</div>
</div>
</div>
@@ -389,8 +440,9 @@
<script setup>
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks } from '@/api/storyboardVideo'
import { imageToVideoApi } from '@/api/imageToVideo'
@@ -402,6 +454,7 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 计算是否已登录
@@ -426,6 +479,23 @@ const pollIntervalId = ref(null) // 保存轮询定时器ID
const optimizingPrompt = ref(false) // 优化提示词状态
const optimizingVideoPrompt = ref(false) // 优化视频提示词状态
// 用户菜单相关
const showUserMenu = ref(false)
const userAvatarRef = ref(null)
// 计算菜单位置
const menuStyle = computed(() => {
if (!userAvatarRef.value || !showUserMenu.value) return {}
const rect = userAvatarRef.value.getBoundingClientRect()
return {
position: 'fixed',
top: `${rect.bottom + 8}px`,
right: `${window.innerWidth - rect.right}px`,
zIndex: 99999
}
})
// 为了兼容性,保留 uploadedImage 作为计算属性(返回第一张图片)
const uploadedImage = computed(() => {
if (uploadedImages.value.length > 0 && uploadedImages.value[0]?.url) {
@@ -472,6 +542,67 @@ const goToImageToVideo = () => {
router.push('/image-to-video/create')
}
// 用户菜单相关方法
const toggleUserMenu = () => {
// 未登录时跳转到登录页面
if (!isAuthenticated.value) {
router.push('/login')
return
}
showUserMenu.value = !showUserMenu.value
}
const goToProfile = () => {
showUserMenu.value = false
router.push('/profile')
}
const goToMyWorks = () => {
showUserMenu.value = false
router.push('/works')
}
const goToSubscription = () => {
showUserMenu.value = false
router.push('/subscription')
}
const goToSettings = () => {
showUserMenu.value = false
router.push('/settings')
}
const goToDashboard = () => {
showUserMenu.value = false
router.push('/admin/dashboard')
}
const goToOrders = () => {
showUserMenu.value = false
router.push('/admin/orders')
}
const goToMembers = () => {
showUserMenu.value = false
router.push('/member-management')
}
const goToSystemSettings = () => {
showUserMenu.value = false
router.push('/system-settings')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
}
const logout = () => {
showUserMenu.value = false
userStore.logout()
router.push('/login')
}
// 切换到生成分镜图步骤
const switchToGenerateStep = () => {
currentStep.value = 'generate'
@@ -851,7 +982,25 @@ const startGenerate = async () => {
} else {
// 任务创建失败,重置所有状态
console.error('创建任务失败,响应:', response.data)
ElMessage.error(response.data?.message || t('video.storyboard.createTaskFailed'))
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
@@ -865,7 +1014,25 @@ const startGenerate = async () => {
response: error.response,
stack: error.stack
})
ElMessage.error(t('video.storyboard.generateFailed') + ': ' + (error.response?.data?.message || error.message))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.storyboard.generateFailed') + ': ' + errorMsg)
}
// 异常情况,重置所有状态
inProgress.value = false
isCreatingTask.value = false
@@ -1137,12 +1304,48 @@ const startVideoGenerate = async () => {
pollTaskStatus(taskId.value)
} else {
console.error('启动视频生成失败,响应:', response.data)
ElMessage.error(response.data?.message || t('video.storyboard.videoStartFailed'))
const errorMsg = response.data?.message || t('video.storyboard.videoStartFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
}
} catch (error) {
console.error('启动视频生成失败,完整错误:', error)
ElMessage.error(t('video.storyboard.videoStartFailed') + ': ' + (error.response?.data?.message || error.message))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.storyboard.videoStartFailed') + ': ' + errorMsg)
}
inProgress.value = false
}
return
@@ -1253,7 +1456,25 @@ const startVideoGenerate = async () => {
pollTaskStatus(newTaskId)
} else {
console.error('创建任务失败,响应:', response.data)
ElMessage.error(response.data?.message || t('video.storyboard.createTaskFailed'))
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
}
} catch (error) {
@@ -1263,7 +1484,25 @@ const startVideoGenerate = async () => {
response: error.response,
stack: error.stack
})
ElMessage.error(t('video.storyboard.generateVideoFailed') + ': ' + (error.response?.data?.message || error.message))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.storyboard.generateVideoFailed') + ': ' + errorMsg)
}
inProgress.value = false
}
}
@@ -1379,14 +1618,15 @@ const loadHistory = async () => {
}
try {
const response = await getUserStoryboardTasks(0, 10)
// 请求更多条数以确保能筛选出足够的 COMPLETED 任务
const response = await getUserStoryboardTasks(0, 50)
console.log('分镜视频历史记录API响应:', response)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 10)
).slice(0, 5)
console.log('获取到的任务列表:', tasks)
@@ -1395,7 +1635,10 @@ const loadHistory = async () => {
...task,
resultUrl: task.resultUrl && !task.resultUrl.startsWith('data:image')
? processHistoryUrl(task.resultUrl)
: task.resultUrl
: task.resultUrl,
imageUrl: task.imageUrl && !task.imageUrl.startsWith('data:image')
? processHistoryUrl(task.imageUrl)
: task.imageUrl
}))
console.log('历史记录加载成功:', historyTasks.value.length, '条')
@@ -1421,20 +1664,25 @@ const createSimilarFromHistory = (task) => {
if (task.hdMode !== undefined) {
hdMode.value = task.hdMode
}
// 如果有分镜图URL加载图片支持所有URL类型
if (task.resultUrl) {
generatedImageUrl.value = task.resultUrl
// 优先使用用户上传的参考图片imageUrl如果没有则使用生成的分镜图resultUrl
const imageToUse = task.imageUrl || task.resultUrl
if (imageToUse) {
generatedImageUrl.value = imageToUse
uploadedImages.value = [{
url: task.resultUrl,
url: imageToUse,
file: null,
name: '分镜图'
name: task.imageUrl ? '参考图片' : '分镜图'
}]
// 设置 taskId以便可以直接调用生成视频API
if (task.taskId) {
// 如果是已完成的分镜图任务且有resultUrl设置taskId以便直接生成视频
if (task.resultUrl && task.taskId) {
taskId.value = task.taskId
// 切换到视频步骤
currentStep.value = 'video'
} else {
// 只有参考图,停留在生成步骤
currentStep.value = 'generate'
}
// 切换到视频步骤
currentStep.value = 'video'
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -1537,17 +1785,63 @@ const getStatusText = (status) => {
}
// 下载视频
const downloadVideo = () => {
if (videoResultUrl.value) {
const downloadVideo = async () => {
if (!videoResultUrl.value) {
ElMessage.error(t('video.storyboard.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
// 获取视频文件
const response = await fetch(videoResultUrl.value)
const blob = await response.blob()
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = videoResultUrl.value
link.download = `storyboard_video_${taskId.value}.mp4`
link.href = url
link.download = `storyboard_video_${taskId.value || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.storyboard.downloadStarted'))
} else {
} catch (error) {
console.error('下载失败:', error)
// 备用方案:直接打开链接
window.open(videoResultUrl.value, '_blank')
}
}
// 下载历史记录中的视频
const downloadHistoryVideo = async (task) => {
if (!task || !task.videoResultUrl) {
ElMessage.error(t('video.storyboard.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
const response = await fetch(task.videoResultUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `storyboard_video_${task.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.storyboard.downloadStarted'))
} catch (error) {
console.error('下载失败:', error)
window.open(task.videoResultUrl, '_blank')
}
}
@@ -1848,6 +2142,20 @@ const checkLastTaskStatus = async () => {
}
onMounted(async () => {
// 处理"做同款"传递的路由参数
if (route.query.prompt) {
inputText.value = route.query.prompt
if (route.query.aspectRatio) {
aspectRatio.value = route.query.aspectRatio
}
if (route.query.duration) {
duration.value = route.query.duration
}
ElMessage.success(t('video.storyboardVideo.historyParamsFilled') || '已填充历史参数')
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
}
loadHistory()
// 延迟恢复任务,避免与创建任务冲突
setTimeout(async () => {
@@ -1886,6 +2194,40 @@ onBeforeUnmount(() => {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 用户菜单样式 - 深色主题 */
.user-menu-teleport {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 160px;
overflow: hidden;
z-index: 99999;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
color: white;
font-size: 14px;
}
.menu-item:hover {
background: #2a2a2a;
}
.menu-item .el-icon {
margin-right: 8px;
font-size: 16px;
}
.menu-item:not(:last-child) {
border-bottom: 1px solid #333;
}
/* 顶部导航栏 */
.top-header {
display: flex;
@@ -3105,6 +3447,37 @@ onBeforeUnmount(() => {
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
/* 生成中进度条动态动画 - 使用蓝绿渐变风格 */
.progress-fill-large.animated {
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
/* 不确定进度条(排队中) */
.progress-bar-large.indeterminate {
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.progress-fill-indeterminate {
width: 30%;
height: 100%;
background: linear-gradient(90deg, transparent, #409eff, #67c23a, #409eff, transparent);
border-radius: 4px;
animation: progress-move 1.5s ease-in-out infinite;
}
/* 进度百分比文字 */
.progress-percentage {
font-size: 14px;
color: #60a5fa;
font-weight: 600;
margin-top: 12px;
text-align: center;
}
/* 完成状态 */
@@ -3485,6 +3858,8 @@ onBeforeUnmount(() => {
.history-actions {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
margin-top: 0;
}
@@ -3504,6 +3879,41 @@ onBeforeUnmount(() => {
background: #3a3a3a;
border-color: #4a4a4a;
}
.download-btn {
width: 36px;
height: 36px;
background: #fff;
color: #333;
border: none;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.download-btn:hover {
background: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->
<style>
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(233%); }
100% { transform: translateX(-100%); }
}
@keyframes progress-gradient {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
</style>
@@ -3514,5 +3924,3 @@ onBeforeUnmount(() => {