feat: 线程池扩容、会员注册和过期逻辑优化、API管理页面显示当前配置

- 线程池扩容:TaskQueueService 10->20, AsyncConfig 核心5->10/最大20->40/队列50->100
- 新用户注册自动创建免费会员记录(永久有效到2099年)
- 付费会员过期自动降级为免费会员并清零积分
- API管理页面显示当前API密钥(脱敏)和端点
- 修复StoryboardVideoCreate.vue语法错误
This commit is contained in:
AIGC Developer
2026-01-11 21:32:33 +08:00
parent a66bd806b2
commit 8404cb2b7a
50 changed files with 2635 additions and 971 deletions

View File

@@ -47,6 +47,11 @@ export const handleAlipayCallback = (params) => {
return api.post('/payments/alipay/callback', params)
}
// 噜噜支付彩虹易支付API
export const createLuluPayment = (paymentData) => {
return api.post('/payments/lulupay/create', paymentData)
}
// PayPal支付API
export const createPayPalPayment = (paymentData) => {
return api.post('/payment/paypal/create', paymentData)

View File

@@ -7,6 +7,14 @@ export const createStoryboardTask = async (data) => {
return api.post('/storyboard-video/create', data)
}
/**
* 直接使用上传的分镜图创建视频任务(跳过分镜图生成)
* @param {object} data - 包含 storyboardImage, prompt, aspectRatio, hdMode, duration, referenceImages
*/
export const createVideoDirectTask = async (data) => {
return api.post('/storyboard-video/create-video-direct', data)
}
/**
* 获取任务详情
*/

View File

@@ -6,7 +6,20 @@ export const getMyWorks = (params = {}) => {
params: {
page: params.page || 0,
size: params.size || 10,
includeProcessing: params.includeProcessing !== false // 默认包含正在处理中的作品
includeProcessing: params.includeProcessing !== false, // 默认包含正在处理中的作品
workType: params.workType || null // 按作品类型筛选
}
})
}
// 按类型获取我的作品(用于历史记录)
export const getMyWorksByType = (workType, params = {}) => {
return api.get('/works/my-works', {
params: {
page: params.page || 0,
size: params.size || 1000,
includeProcessing: true,
workType: workType // TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO, STORYBOARD_IMAGE
}
})
}

View File

@@ -40,7 +40,7 @@
<!-- 金额显示 -->
<div class="amount-section">
<div class="amount-label">金额</div>
<div class="amount-value">${{ amount }}</div>
<div class="amount-value">${{ formatAmount(amount) }}</div>
</div>
<!-- 支付宝支付区域 -->
@@ -128,6 +128,12 @@ const loading = ref(false)
const currentPaymentId = ref(null)
let paymentPollingTimer = null
let isPaymentStarted = false // 防止重复调用
// 格式化金额,保留两位小数
const formatAmount = (amount) => {
if (amount === null || amount === undefined) return '0.00'
return Number(amount).toFixed(2)
}
let lastPlanType = '' // 记录上一次的套餐类型
// 从orderId中提取套餐类型如 SUB_standard_xxx -> standard

View File

@@ -348,6 +348,7 @@ export default {
serverError: 'Server error, please try again later',
networkError: 'Network error, please check your connection',
storyboardImage: 'Storyboard',
referenceImage: 'Reference Image',
noStoryboard: 'No storyboard yet',
hdMode: 'HD Mode (1080P)',
hdCost: 'Costs 20 points when enabled',
@@ -361,11 +362,13 @@ export default {
progress: 'Progress: {progress}%',
generatingStoryboardText: 'Generating storyboard, please wait...',
generatingVideoText: 'Generating video, please wait...',
generatingText: 'Generating',
startCreating: 'Start creating your first work!',
noDescription: 'No description',
queuing: 'Queuing',
subscribeToSpeed: 'Subscribe to improve generation speed',
noResult: 'No result yet',
noStoryboardImage: 'Please wait for storyboard generation to complete, or upload a storyboard image',
uploadOrGenerateFirst: 'Please upload or generate storyboard first',
uploadOrInputPrompt: 'Please upload storyboard or enter prompt',
startGenerateVideo: 'Start Generate Video',
@@ -476,6 +479,8 @@ export default {
bulkDeleteSuccess: 'Selected items deleted',
filtersReset: 'Filters reset',
processing: 'Processing...',
queuing: 'Queuing...',
pleaseWait: 'Please wait, video is being generated',
noPreview: 'No Preview',
videoLoadFailed: 'Video Load Failed',
videoFileNotExist: 'Video file may not exist or has been deleted',

View File

@@ -351,6 +351,7 @@ export default {
serverError: '服务器错误,请稍后重试',
networkError: '网络错误,请检查网络连接',
storyboardImage: '分镜图',
referenceImage: '参考图',
noStoryboard: '暂无分镜图',
hdMode: '高清模式 (1080P)',
hdCost: '开启消耗20积分',
@@ -364,11 +365,13 @@ export default {
progress: '进度: {progress}%',
generatingStoryboardText: '正在生成分镜图,请稍候...',
generatingVideoText: '正在生成视频,请稍候...',
generatingText: '生成中',
startCreating: '开始创作您的第一个作品吧!',
noDescription: '无描述',
queuing: '排队中',
subscribeToSpeed: '订阅套餐以提升生成速度',
noResult: '暂无结果',
noStoryboardImage: '请等待分镜图生成完成,或上传一张分镜图',
uploadOrGenerateFirst: '请先上传参考图片或输入描述生成分镜图',
uploadOrInputPrompt: '请上传参考图片或输入提示词',
startGenerateVideo: '开始生成视频',
@@ -490,6 +493,7 @@ export default {
filtersReset: '筛选器已重置',
processing: '生成中...',
queuing: '排队中...',
pleaseWait: '请耐心等待,视频正在生成中',
noPreview: '无预览',
videoLoadFailed: '视频加载失败',
videoFileNotExist: '视频文件可能不存在或已被删除',
@@ -589,7 +593,10 @@ export default {
qrCodeGenerationError: '二维码生成失败:{message}',
pleaseTryAgain: '请重试',
refreshPage: '请刷新页面重试',
paymentSuccess: '支付成功!正在更新信息...',
paymentSuccess: '支付成功!积分已到账',
paymentPending: '支付处理中,请稍候...',
paymentCancelled: '支付已取消',
paymentError: '支付处理异常,如有疑问请联系客服',
infoUpdated: '信息已更新!',
paymentProcessingFailed: '支付成功但处理订单失败,请联系客服',
paymentFailed: '支付失败,请重试',

View File

@@ -76,7 +76,25 @@
<h2>{{ $t('apiManagement.title') }}</h2>
</div>
<!-- 当前配置展示 -->
<div class="current-config">
<h3>当前配置</h3>
<div class="config-item">
<span class="config-label">API密钥</span>
<span class="config-value">{{ currentMaskedKey || '未配置' }}</span>
</div>
<div class="config-item">
<span class="config-label">API端点</span>
<span class="config-value">{{ currentApiBaseUrl || '未配置' }}</span>
</div>
<div class="config-item">
<span class="config-label">Token过期时间</span>
<span class="config-value">{{ apiForm.tokenExpireHours ? apiForm.tokenExpireHours + ' 小时' : '未配置' }}</span>
</div>
</div>
<div class="api-form-container">
<h3 style="margin-bottom: 16px; color: #374151;">修改配置</h3>
<el-form :model="apiForm" label-width="120px" class="api-form">
<el-form-item :label="$t('apiManagement.apiKey')">
<el-input
@@ -157,6 +175,7 @@ const apiForm = reactive({
tokenExpireHours: null // 从数据库加载
})
const currentApiBaseUrl = ref('')
const currentMaskedKey = ref('')
// 导航功能
const goToDashboard = () => {
@@ -214,6 +233,7 @@ const loadApiKey = async () => {
try {
const response = await api.get('/api-key')
if (response.data?.maskedKey) {
currentMaskedKey.value = response.data.maskedKey
console.log('当前API密钥已配置')
}
// 加载当前API基础URL
@@ -498,6 +518,49 @@ const fetchSystemStats = async () => {
max-width: 800px;
}
.current-config {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border: 1px solid #bae6fd;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
}
.current-config h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #0369a1;
}
.config-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(186, 230, 253, 0.5);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-size: 14px;
color: #64748b;
width: 120px;
flex-shrink: 0;
}
.config-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: rgba(255, 255, 255, 0.6);
padding: 4px 12px;
border-radius: 6px;
}
.api-form {
background: #f9fafb;
padding: 32px;

View File

@@ -148,9 +148,10 @@
<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: taskProgress + '%' }"></div>
<!-- 生成中动态进度条进度为0时也显示动画 -->
<div v-else class="progress-bar-large" :class="{ 'indeterminate': taskProgress === 0 }">
<div v-if="taskProgress === 0" class="progress-fill-indeterminate"></div>
<div v-else class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ taskProgress }}%</div>
</div>
@@ -262,11 +263,11 @@
<div v-if="task.status === 'PENDING'" class="history-progress-bar indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中确定进度条 -->
<div v-else class="history-progress-bar">
<div class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
<!-- 生成中动态进度条进度为0时也显示动画 -->
<div v-else class="history-progress-bar" :class="{ 'indeterminate': (task.progress || 0) === 0 }">
<div v-if="(task.progress || 0) === 0" class="progress-fill-indeterminate"></div>
<div v-else class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
</div>
<div class="queue-link">{{ t('video.imageToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('video.imageToVideo.cancel') }}</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail">
@@ -366,7 +367,7 @@ import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
import { imageToVideoApi } from '@/api/imageToVideo'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
import { getProcessingWorks, getMyWorksByType } from '@/api/userWorks'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
@@ -720,7 +721,7 @@ const startGenerate = async () => {
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
taskStatus.value = 'PROCESSING' // 直接显示生成中
ElMessage.success(t('video.imageToVideo.taskCreatedSuccess'))
setTimeout(() => userStore.fetchCurrentUser(), 0)
startPollingTask()
@@ -776,7 +777,7 @@ const startGenerate = async () => {
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
taskStatus.value = 'PROCESSING' // 直接显示生成中
ElMessage.success(t('video.imageToVideo.taskCreatedSuccess'))
@@ -1116,7 +1117,7 @@ const retryTask = async () => {
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
taskStatus.value = 'PROCESSING' // 直接显示生成中
ElMessage.success('重试任务已提交')
@@ -1183,6 +1184,10 @@ const deleteWork = async () => {
// 处理历史记录URL
const processHistoryUrl = (url) => {
if (!url) return ''
// data: 协议Base64 图片等)直接返回,避免被当成相对路径错误加前缀
if (url.startsWith('data:')) {
return url
}
// 如果是相对路径,确保格式正确
if (url.startsWith('/') || !url.startsWith('http')) {
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
@@ -1192,7 +1197,7 @@ const processHistoryUrl = (url) => {
return url
}
// 加载历史记录
// 加载历史记录从user_works表获取IMAGE_TO_VIDEO类型的作品
const loadHistory = async () => {
// 只有登录用户才能查看历史记录
if (!userStore.isAuthenticated) {
@@ -1201,20 +1206,24 @@ const loadHistory = async () => {
}
try {
// 请求全部已完成的任务,不限制数量
const response = await imageToVideoApi.getTasks(0, 1000)
// 从user_works表获取IMAGE_TO_VIDEO类型的作品
const response = await getMyWorksByType('IMAGE_TO_VIDEO', { page: 0, size: 1000 })
console.log('历史记录API响应:', response.data)
if (response.data && response.data.success) {
// 只显示已完成的任务,不显示失败的任务
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
)
// 处理URL确保相对路径正确
historyTasks.value = tasks.map(task => ({
...task,
resultUrl: task.resultUrl ? processHistoryUrl(task.resultUrl) : null,
firstFrameUrl: task.firstFrameUrl ? processHistoryUrl(task.firstFrameUrl) : null
// 转换数据格式,适配历史记录展示
const works = response.data.data || []
historyTasks.value = works.map(work => ({
taskId: work.taskId,
prompt: work.prompt,
aspectRatio: work.aspectRatio,
duration: work.duration ? parseInt(work.duration) : 5,
hdMode: work.quality === 'HD',
status: work.status,
resultUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
firstFrameUrl: work.thumbnailUrl ? processHistoryUrl(work.thumbnailUrl) : null,
createdAt: work.createdAt,
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
}))
console.log('历史记录加载成功:', historyTasks.value.length, '条')
@@ -1375,47 +1384,47 @@ const restoreProcessingTask = async () => {
// 取最新的一个任务
const work = imageToVideoWorks[0]
// 恢复任务状态
currentTask.value = {
taskId: work.taskId,
prompt: work.prompt,
aspectRatio: work.aspectRatio,
duration: work.duration,
resultUrl: work.resultUrl,
createdAt: work.createdAt
}
// 恢复输入参数
if (work.prompt) {
inputText.value = work.prompt
}
if (work.aspectRatio) {
aspectRatio.value = work.aspectRatio
}
if (work.duration) {
duration.value = String(work.duration)
}
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
if (work.thumbnailUrl) {
const imageUrl = processHistoryUrl(work.thumbnailUrl)
firstFrameImage.value = imageUrl
console.log('[Task Restore] 已恢复首帧图片:', work.thumbnailUrl)
// 尝试从URL加载图片并转换为File对象以便重新生成时可以提交
try {
const response = await fetch(imageUrl)
const blob = await response.blob()
const fileName = `first_frame_${work.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
console.log('[Task Restore] 首帧图片已转换为文件对象:', fileName)
} catch (error) {
console.warn('[Task Restore] 无法从URL加载首帧图片:', error)
}
}
// 只有真正在进行中的任务才恢复和显示消息
// 只有真正在进行中的任务才恢复
const workStatus = work.status || 'PROCESSING'
if (workStatus === 'PROCESSING' || workStatus === 'PENDING') {
// 恢复任务状态
currentTask.value = {
taskId: work.taskId,
prompt: work.prompt,
aspectRatio: work.aspectRatio,
duration: work.duration,
resultUrl: work.resultUrl,
createdAt: work.createdAt
}
// 恢复输入参数
if (work.prompt) {
inputText.value = work.prompt
}
if (work.aspectRatio) {
aspectRatio.value = work.aspectRatio
}
if (work.duration) {
duration.value = String(work.duration)
}
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
if (work.thumbnailUrl) {
const imageUrl = processHistoryUrl(work.thumbnailUrl)
firstFrameImage.value = imageUrl
console.log('[Task Restore] 已恢复首帧图片:', work.thumbnailUrl)
// 尝试从URL加载图片并转换为File对象以便重新生成时可以提交
try {
const imgResponse = await fetch(imageUrl)
const blob = await imgResponse.blob()
const fileName = `first_frame_${work.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
console.log('[Task Restore] 首帧图片已转换为文件对象:', fileName)
} catch (error) {
console.warn('[Task Restore] 无法从URL加载首帧图片:', error)
}
}
inProgress.value = true
taskStatus.value = workStatus
taskProgress.value = 50 // 初始进度设为50%
@@ -1430,7 +1439,7 @@ const restoreProcessingTask = async () => {
startPollingTask()
return true
} else {
// 如果任务已失败或取消,不显示恢复消息,让 checkLastTaskStatus 处理
// 如果任务已完成、失败或取消,不恢复任何参数
console.log('[Task Skip]', work.taskId, 'Status:', workStatus, '- 不是进行中状态')
return false
}
@@ -1492,13 +1501,19 @@ const checkLastTaskStatus = async () => {
}
}
// 标记是否已完成首次加载(用于 watch 判断)
const isInitialized = ref(false)
onMounted(async () => {
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
// 标记是否从"做同款"或"生视频"进入(有路由参数)
const isFromExternalEntry = !!(route.query.prompt || route.query.referenceImage)
// 处理"做同款"传递的路由参数
if (route.query.prompt || route.query.referenceImage) {
console.log('[做同款] 接收参数:', route.query)
if (isFromExternalEntry) {
console.log('[做同款/生视频] 接收参数:', route.query)
if (route.query.prompt) {
inputText.value = route.query.prompt
@@ -1513,7 +1528,7 @@ onMounted(async () => {
// 处理参考图
if (route.query.referenceImage) {
firstFrameImage.value = route.query.referenceImage
console.log('[做同款] 设置参考图:', route.query.referenceImage)
console.log('[做同款/生视频] 设置参考图:', route.query.referenceImage)
// 注意firstFrameFile 为 null用户需要重新上传或点击生成时会提示
}
@@ -1524,17 +1539,62 @@ onMounted(async () => {
loadHistory()
// 延迟恢复任务,避免与创建任务冲突
// 注意:如果是从"做同款"或"生视频"进入,不恢复正在进行的任务,让用户开始新任务
setTimeout(async () => {
if (!isCreatingTask.value) {
if (!isCreatingTask.value && !isFromExternalEntry) {
const restored = await restoreProcessingTask()
// 如果没有恢复进行中的任务,则检查最近一条是否失败
if (!restored) {
checkLastTaskStatus()
}
}
// 标记初始化完成
isInitialized.value = true
}, 500)
})
// 监听路由参数变化(同一页面内跳转时触发)
watch(() => route.query._t, (newT, oldT) => {
// 只在初始化完成后且有新的时间戳时触发
if (!isInitialized.value || !newT || newT === oldT) {
return
}
console.log('[路由参数变化] 检测到新参数:', route.query)
// 停止当前轮询(如果有)
if (stopPolling.value) {
stopPolling.value()
stopPolling.value = null
}
// 重置状态
inProgress.value = false
taskId.value = ''
taskStatus.value = ''
resultUrl.value = ''
// 应用新参数
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
}
if (route.query.referenceImage) {
firstFrameImage.value = route.query.referenceImage
console.log('[路由参数变化] 设置参考图')
}
// 清除URL中的query参数
router.replace({ path: route.path })
ElMessage.success(t('works.readyToGenerateVideo'))
})
// 组件卸载时清理资源
onUnmounted(() => {
// 停止轮询

View File

@@ -264,8 +264,19 @@
<div class="detail-content" :class="{ 'vertical-content': isVerticalVideo }" v-if="selectedItem">
<div class="detail-left" :class="{ 'vertical-left': isVerticalVideo }">
<div class="video-container" :class="{ 'vertical-container': isVerticalVideo }">
<!-- 视频加载失败提示 -->
<div v-if="detailVideoError" class="video-error-overlay">
<!-- 生成中状态提示 -->
<div v-if="selectedItem.status === 'PROCESSING' || selectedItem.status === 'PENDING'" class="video-processing-overlay">
<div class="processing-content">
<el-icon class="processing-icon is-loading" :size="48"><Loading /></el-icon>
<h3>{{ selectedItem.status === 'PROCESSING' ? t('works.processing') : t('works.queuing') }}</h3>
<p>{{ t('works.pleaseWait') }}</p>
<div class="progress-bar-container">
<div class="progress-bar-animated"></div>
</div>
</div>
</div>
<!-- 视频加载失败提示仅对已完成的作品显示 -->
<div v-else-if="detailVideoError && selectedItem.status === 'COMPLETED'" class="video-error-overlay">
<div class="error-content">
<el-icon class="error-icon" :size="48"><VideoCamera /></el-icon>
<h3>{{ t('works.videoLoadFailed') }}</h3>
@@ -284,7 +295,7 @@
</div>
<video
v-if="selectedItem.type === 'video'"
v-if="selectedItem.type === 'video' && selectedItem.status === 'COMPLETED'"
ref="detailVideoRef"
class="detail-video"
:src="selectedItem.resultUrl || selectedItem.cover"
@@ -295,15 +306,22 @@
>
{{ t('profile.browserNotSupport') }}
</video>
<!-- 生成中状态显示封面图 -->
<img
v-else
v-else-if="selectedItem.status === 'PROCESSING' || selectedItem.status === 'PENDING'"
class="detail-image processing-preview"
:src="selectedItem.cover || '/images/backgrounds/video-placeholder.jpg'"
:alt="selectedItem.title"
/>
<img
v-else-if="selectedItem.type !== 'video'"
class="detail-image"
:src="selectedItem.cover"
:alt="selectedItem.title"
/>
<!-- 悬浮操作按钮 -->
<div class="overlay-actions">
<!-- 悬浮操作按钮仅对已完成的作品显示 -->
<div class="overlay-actions" v-if="selectedItem.status === 'COMPLETED'">
<button class="icon-btn" @click="downloadWork" :title="t('common.download')">
<el-icon><Download /></el-icon>
</button>
@@ -1102,8 +1120,8 @@ const createSimilar = (item) => {
}
// 根据作品类别跳转到对应的创建页面,并携带参数
// 注意:不传递 taskId因为"做同款"是创建新任务,不是继续原任务
const query = {
taskId: item.taskId,
prompt: item.prompt || '',
aspectRatio: item.aspectRatio || '',
duration: item.duration || '',
@@ -1130,6 +1148,9 @@ const createSimilar = (item) => {
// 传递分镜图阶段的参考图
if (item.uploadedImages) {
query.uploadedImages = item.uploadedImages
console.log('[做同款-分镜图] 传递参考图:', item.uploadedImages)
} else {
console.log('[做同款-分镜图] 无参考图数据, item:', item)
}
router.push({ path: '/storyboard-video/create', query })
} else if (item.category === '分镜视频') {
@@ -1139,6 +1160,14 @@ const createSimilar = (item) => {
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.storyboardImage = item.cover
}
// 传递视频提示词videoPrompt
if (item.videoPrompt) {
query.prompt = item.videoPrompt
} else if (item.imagePrompt) {
query.prompt = item.imagePrompt
} else if (item.prompt) {
query.prompt = item.prompt
}
// 传递视频阶段的参考图videoReferenceImages不是分镜图阶段的参考图
if (item.videoReferenceImages) {
query.videoReferenceImages = item.videoReferenceImages
@@ -1160,7 +1189,8 @@ const goToGenerateVideo = (item) => {
const query = {
aspectRatio: item.aspectRatio || '',
duration: item.duration || '',
hdMode: item.quality === 'HD' ? 'true' : 'false'
hdMode: item.quality === 'HD' ? 'true' : 'false',
_t: Date.now() // 添加时间戳确保路由参数变化被检测到
}
// 使用分镜图作为参考图
@@ -2638,6 +2668,114 @@ onActivated(() => {
z-index: 20;
}
/* 详情弹窗中的生成中状态 */
.video-processing-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 15;
border-radius: 8px;
}
.video-processing-overlay .processing-content {
text-align: center;
color: #fff;
}
.video-processing-overlay .processing-icon {
color: #409eff;
margin-bottom: 16px;
}
.video-processing-overlay h3 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
}
.video-processing-overlay p {
margin: 0 0 20px 0;
font-size: 14px;
color: #999;
}
.video-processing-overlay .progress-bar-container {
width: 200px;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin: 0 auto;
}
.video-processing-overlay .progress-bar-animated {
height: 100%;
width: 30%;
background: linear-gradient(90deg, #409eff, #66b1ff);
border-radius: 2px;
animation: progressSlide 1.5s ease-in-out infinite;
}
@keyframes progressSlide {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
/* 视频加载失败样式 */
.video-error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 15;
border-radius: 8px;
}
.video-error-overlay .error-content {
text-align: center;
color: #fff;
}
.video-error-overlay .error-icon {
color: #f56c6c;
margin-bottom: 16px;
}
.video-error-overlay h3 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
}
.video-error-overlay p {
margin: 0 0 20px 0;
font-size: 14px;
color: #999;
}
.video-error-overlay .error-actions {
display: flex;
gap: 12px;
justify-content: center;
}
/* 生成中预览图样式 */
.processing-preview {
opacity: 0.5;
filter: blur(2px);
}
.icon-btn {
width: 40px;
height: 40px;

View File

@@ -567,6 +567,14 @@ const createSimilar = (item) => {
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.storyboardImage = item.cover
}
// 传递视频提示词videoPrompt
if (item.videoPrompt) {
query.prompt = item.videoPrompt
} else if (item.imagePrompt) {
query.prompt = item.imagePrompt
} else if (item.prompt) {
query.prompt = item.prompt
}
// 传递视频阶段的参考图videoReferenceImages不是分镜图阶段的参考图
if (item.videoReferenceImages) {
query.videoReferenceImages = item.videoReferenceImages
@@ -590,7 +598,8 @@ const goToGenerateVideo = (item) => {
const query = {
aspectRatio: item.aspectRatio || '',
duration: item.duration || '',
hdMode: item.quality === 'HD' ? 'true' : 'false'
hdMode: item.quality === 'HD' ? 'true' : 'false',
_t: Date.now() // 添加时间戳确保路由参数变化被检测到
}
// 使用分镜图作为参考图

View File

@@ -85,27 +85,18 @@
<!-- 分镜步骤标签 - SVG版本 -->
<div class="storyboard-steps-svg">
<!-- 左侧点击容器 - 生成分镜图 -->
<div class="step-click-container step-1" @click="switchToGenerateStep">
</div>
<!-- 右侧点击容器 - 生成视频 -->
<div class="step-click-container step-2" @click="switchToVideoStep">
</div>
<svg width="244" height="43" viewBox="0 0 244 43" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 左侧底部指示条 - 生成分镜图激活时显示 -->
<rect v-if="currentStep === 'generate'" x="80" y="41" width="2" height="80" rx="1" transform="rotate(90 80 41)" fill="white"/>
<!-- 右侧底部指示条 - 生成分镜视频激活时显示 -->
<rect v-if="currentStep === 'video'" x="244" y="41" width="2" height="80" rx="1" transform="rotate(90 244 41)" fill="white"/>
<!-- 左侧点击区域 - 生成分镜图 -->
<rect
x="0" y="0" width="122" height="43"
fill="transparent"
style="cursor: pointer;"
@click="switchToGenerateStep"
/>
<!-- 右侧点击区域 - 生成分镜视频 -->
<rect
x="122" y="0" width="122" height="43"
fill="transparent"
style="cursor: pointer;"
@click="switchToVideoStep"
/>
<!-- STEP1 生成分镜图 文字 -->
<path :opacity="currentStep === 'generate' ? '1' : '0.4'" d="M4.048 22.232C3.424 23.64 2.624 24.84 1.648 25.8L0.544 24.408C1.984 22.904 2.944 20.888 3.44 18.344L5.152 18.664C5.008 19.352 4.848 20.008 4.656 20.616H7.44V17.992H9.12V20.616H14.192V22.232H9.12V25.368H13.888V26.984H9.12V30.472H15.232V32.136H0.992V30.472H7.44V26.984H2.928V25.368H7.44V22.232H4.048ZM28.144 17.72C28.864 18.264 29.472 18.824 29.984 19.384L29.248 20.136H31.152V21.72H26.448C26.576 23.576 26.768 25.016 27.024 26.008C27.072 26.232 27.136 26.44 27.2 26.664C27.84 25.544 28.368 24.248 28.784 22.808L30.304 23.464C29.68 25.496 28.88 27.224 27.904 28.648C28.048 28.968 28.208 29.256 28.368 29.496C28.816 30.184 29.168 30.536 29.408 30.536C29.568 30.536 29.76 29.768 29.968 28.232L31.472 29.048C31.056 31.304 30.464 32.44 29.664 32.44C28.96 32.44 28.224 31.96 27.44 31.016C27.216 30.728 26.992 30.408 26.8 30.056C25.744 31.208 24.528 32.088 23.152 32.696L22.192 31.288C23.696 30.616 24.976 29.64 26.032 28.36C25.808 27.8 25.616 27.192 25.44 26.536C25.088 25.256 24.864 23.64 24.736 21.72H19.744V23.944H23.664C23.616 26.92 23.456 28.776 23.184 29.512C22.928 30.184 22.368 30.52 21.504 30.536C21.12 30.536 20.688 30.504 20.208 30.456L19.696 28.936C20.336 28.984 20.864 29.016 21.312 29.016C21.568 29 21.744 28.84 21.824 28.52C21.92 28.152 21.968 27.144 22 25.512H19.744C19.664 28.488 19.008 30.872 17.776 32.68L16.512 31.544C17.504 30.088 18.016 28.056 18.064 25.464V20.136H24.656C24.624 19.4 24.624 18.616 24.624 17.816H26.32C26.32 18.632 26.336 19.4 26.368 20.136H28.624C28.224 19.688 27.712 19.208 27.088 18.728L28.144 17.72ZM37.52 18.136L38.96 19.016C38.224 20.328 37.472 21.416 36.72 22.248C35.984 23.08 34.928 23.992 33.52 25L32.608 23.512C33.6 22.968 34.56 22.2 35.456 21.192C36.176 20.36 36.864 19.352 37.52 18.136ZM37.952 25.656H35.072V24.04H44.8C44.736 26.12 44.64 27.784 44.48 29.064C44.32 30.344 44.064 31.224 43.68 31.736C43.312 32.216 42.72 32.488 41.904 32.536H38.832L38.384 30.888C39.328 30.92 40.224 30.952 41.056 30.952C41.84 30.952 42.352 30.616 42.608 29.976C42.864 29.304 43.04 27.864 43.104 25.656H39.648C39.28 27.288 38.784 28.584 38.144 29.56C37.312 30.728 36.08 31.784 34.448 32.712L33.36 31.32C34.8 30.552 35.888 29.704 36.624 28.776C37.184 27.976 37.632 26.936 37.952 25.656ZM42.528 17.944C43.392 20.328 45.008 22.216 47.376 23.592L46.24 24.92C43.776 23.336 42.048 21.288 41.072 18.76L42.528 17.944ZM54.896 18.936H57.776C57.632 18.6 57.488 18.28 57.344 17.992L59.024 17.704C59.152 18.072 59.264 18.488 59.392 18.936H62.608V20.312H61.424C61.312 20.76 61.184 21.176 61.024 21.592H63.12V22.984H54.416V21.592H56.544C56.384 21.128 56.24 20.696 56.096 20.312H54.896V18.936ZM59.44 21.592C59.616 21.176 59.76 20.76 59.888 20.312H57.68C57.792 20.712 57.904 21.144 58.032 21.592H59.44ZM55.216 23.592H62.224V28.968H60.624V30.552C60.624 30.936 60.8 31.128 61.168 31.128H61.52C61.712 31.128 61.872 31.048 61.968 30.92C62.08 30.76 62.16 30.264 62.192 29.464L63.536 29.88C63.408 31.16 63.232 31.912 62.976 32.152C62.704 32.376 62.304 32.488 61.776 32.488H60.736C59.616 32.488 59.056 31.944 59.056 30.888V28.968H58.112C57.92 29.848 57.584 30.584 57.12 31.16C56.576 31.832 55.6 32.376 54.208 32.776L53.632 31.384C54.656 31.112 55.408 30.728 55.872 30.248C56.176 29.896 56.4 29.48 56.56 28.968H55.216V23.592ZM60.72 27.656V26.872H56.736V27.656H60.72ZM56.736 25.672H60.72V24.888H56.736V25.672ZM50.352 17.832L51.952 18.184C51.84 18.632 51.728 19.08 51.616 19.496H54.032V21.08H51.12C50.912 21.672 50.688 22.232 50.448 22.744H53.904V24.232H52.416V25.736H54.272V27.272H52.416V30.312C52.848 30.088 53.28 29.816 53.712 29.496L54 30.92C53.104 31.48 52.08 31.944 50.928 32.296L50.288 30.936C50.624 30.808 50.8 30.584 50.8 30.296V27.272H48.976V25.736H50.8V24.232H50.144V23.368C50.032 23.56 49.936 23.752 49.84 23.928L48.464 23.064C49.36 21.432 49.984 19.688 50.352 17.832ZM70.208 20.04L71.696 20.232C71.6 20.456 71.504 20.68 71.392 20.888H75.472V21.992C75.088 22.744 74.48 23.416 73.68 24.008C74.656 24.424 75.744 24.76 76.96 25.032L76.096 26.28C74.64 25.896 73.376 25.416 72.304 24.84C71.104 25.448 69.632 25.944 67.872 26.328L67.152 25C68.64 24.76 69.904 24.44 70.96 24.024C70.512 23.704 70.096 23.368 69.744 23.016C69.312 23.4 68.832 23.752 68.32 24.072L67.408 22.856C68.784 22.088 69.728 21.144 70.208 20.04ZM72.336 23.352C72.944 22.968 73.44 22.536 73.808 22.072H70.624C70.608 22.088 70.592 22.104 70.592 22.12C71.104 22.568 71.68 22.984 72.336 23.352ZM69.28 27.672C71.376 28.04 73.392 28.552 75.328 29.224L74.784 30.584C72.8 29.864 70.752 29.304 68.624 28.904L69.28 27.672ZM70.88 25.528C72.176 25.896 73.36 26.328 74.432 26.84L73.664 28.024C72.512 27.416 71.344 26.952 70.144 26.616L70.88 25.528ZM78.752 18.408V32.664H77.12V32.136H66.88V32.664H65.248V18.408H78.752ZM66.88 30.664H77.12V19.88H66.88V30.664Z" fill="white"/>
@@ -361,12 +352,9 @@
<!-- 分镜图生成中只有在分镜图还没生成完成时才显示 -->
<div v-if="inProgress && !generatedImageUrl" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingStoryboardText') }}</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: '50%' }"></div>
<div class="generating-text">{{ t('video.storyboard.generatingStoryboardText') }}</div>
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
</div>
@@ -438,12 +426,9 @@
<!-- 视频生成中显示简洁的进度条与文生视频一致 -->
<div v-else-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : 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: '50%' }"></div>
<div class="generating-text">{{ t('video.storyboard.generatingVideoText') }}</div>
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
</div>
@@ -488,12 +473,9 @@
<!-- 分镜图生成中 -->
<div v-if="inProgress && !generatedImageUrl" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingStoryboardText') }}</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: '50%' }"></div>
<div class="generating-text">{{ t('video.storyboard.generatingStoryboardText') }}</div>
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
</div>
@@ -513,12 +495,9 @@
<!-- 视频生成中 -->
<div v-else-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : 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: '50%' }"></div>
<div class="generating-text">{{ t('video.storyboard.generatingVideoText') }}</div>
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
</div>
@@ -548,7 +527,7 @@
<!-- 头部信息 -->
<div class="history-item-header">
<span class="history-type">{{ t('home.storyboardVideo') }}</span>
<span class="history-type">{{ currentStep === 'generate' ? t('video.storyboard.storyboardImage') : t('home.storyboardVideo') }}</span>
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
</div>
@@ -558,8 +537,7 @@
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">{{ t('video.storyboard.queuing') }}</div>
<div class="queue-link">{{ t('video.storyboard.subscribeToSpeed') }}</div>
<div class="queue-text">{{ t('video.storyboard.generatingText') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('common.cancel') }}</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl && !isImageUrl(task.resultUrl)" class="history-video-thumbnail">
@@ -633,7 +611,7 @@ import { useUserStore } from '@/stores/user'
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks, retryStoryboardTask } from '@/api/storyboardVideo'
import { imageToVideoApi } from '@/api/imageToVideo'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks, getMyWorks } from '@/api/userWorks'
import { getProcessingWorks, getMyWorksByType } from '@/api/userWorks'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useI18n } from 'vue-i18n'
@@ -1760,12 +1738,24 @@ const pollTaskStatus = async (taskId) => {
generatedImageUrl.value = taskResultUrl
isAIGeneratedImage.value = true // AI生成的分镜图
mainReferenceImage.value = taskResultUrl // 同时填入左侧分镜图框
// 分镜图生成完成后,填充 videoPrompt 到视频提示词框
if (task.videoPrompt && task.videoPrompt.trim()) {
videoPrompt.value = task.videoPrompt
console.log('[轮询] 分镜图生成完成,已填充 videoPrompt:', task.videoPrompt.substring(0, 100))
}
// 只有在分镜图生成阶段才设置 inProgress = false
// 如果是视频生成阶段currentStep === 'video'),保持 inProgress = true
// 因为用户可能正在等待视频生成
if (currentStep.value !== 'video') {
inProgress.value = false
console.log('[轮询] 分镜图生成完成,设置 inProgress = false')
// 分镜图生成完成后,清空左侧的输入参数,让用户从空白状态开始新任务
inputText.value = ''
uploadedImages.value = []
console.log('[轮询] 分镜图生成完成,已清空左侧输入参数')
} else {
console.log('[轮询] 当前在视频步骤,保持 inProgress =', inProgress.value, ', progress:', taskProgress)
}
@@ -1773,19 +1763,15 @@ const pollTaskStatus = async (taskId) => {
// 不再将生成的分镜图添加到参考图数组中,只显示在右侧预览区域
}
// 每次轮询都尝试填充优化后的提示词
// 填充 imagePrompt 到生图提示词框(替换用户原始输入)
if (task.imagePrompt && task.imagePrompt.trim()) {
if (inputText.value !== task.imagePrompt) {
inputText.value = task.imagePrompt
console.log('[DEBUG] 已自动填充 imagePrompt:', task.imagePrompt.substring(0, 100))
}
}
// 填充 videoPrompt 到视频提示词框
if (task.videoPrompt && task.videoPrompt.trim()) {
if (videoPrompt.value !== task.videoPrompt) {
videoPrompt.value = task.videoPrompt
console.log('[DEBUG] 已自动填充 videoPrompt:', task.videoPrompt.substring(0, 100))
// 分镜图生成完成后,不再自动填充提示词到左侧输入框
// 只在视频步骤时填充 videoPrompt
if (currentStep.value === 'video') {
// 填充 videoPrompt 到视频提示词框
if (task.videoPrompt && task.videoPrompt.trim()) {
if (videoPrompt.value !== task.videoPrompt) {
videoPrompt.value = task.videoPrompt
console.log('[DEBUG] 已自动填充 videoPrompt:', task.videoPrompt.substring(0, 100))
}
}
}
@@ -1990,12 +1976,23 @@ const startVideoGenerate = async () => {
console.log('[生成视频] 第一张参考图前50字符:', referenceImages[0].substring(0, 50))
}
// 确定要使用的分镜图优先使用AI生成的分镜图其次使用用户上传的参考图
const effectiveStoryboardImage = generatedImageUrl.value || mainReferenceImage.value
if (!effectiveStoryboardImage) {
ElMessage.warning(t('video.storyboard.noStoryboardImage'))
inProgress.value = false
return
}
console.log('[生成视频] 使用的分镜图:', effectiveStoryboardImage.substring(0, 80) + '...')
const response = await startVideoGeneration(taskId.value, {
duration: parseInt(duration.value),
aspectRatio: aspectRatio.value,
hdMode: hdMode.value,
referenceImages: referenceImages, // 只传递视频阶段的参考图
storyboardImage: generatedImageUrl.value // 传递分镜图URL,用于恢复被覆盖的分镜图
storyboardImage: effectiveStoryboardImage // 传递分镜图URLAI生成或用户上传
})
if (response.data && response.data.success) {
@@ -2098,15 +2095,22 @@ const startVideoGenerate = async () => {
ElMessage.info(t('video.storyboard.startingVideoGenerate'))
const { createStoryboardTask, startVideoGeneration } = await import('@/api/storyboardVideo')
// 使用新的 API 直接创建视频任务(跳过分镜图生成)
const { createVideoDirectTask } = await import('@/api/storyboardVideo')
// 第一步:创建任务(传入上传的分镜图)
const response = await createStoryboardTask({
prompt: prompt || '根据图片生成分镜',
// 收集视频参考图
let referenceImages = videoReferenceImages.value
.filter(img => img && img.url)
.map(img => img.url)
// 直接创建视频任务
const response = await createVideoDirectTask({
storyboardImage: imageUrl, // 用户上传的分镜图
prompt: prompt || '根据分镜图生成视频',
aspectRatio: aspectRatio.value,
duration: parseInt(duration.value),
hdMode: hdMode.value,
imageUrl: imageUrl // 传入上传的分镜图
referenceImages: referenceImages
})
if (response.data && response.data.success) {
@@ -2117,23 +2121,9 @@ const startVideoGenerate = async () => {
// 设置分镜图URL用于显示
generatedImageUrl.value = imageUrl
// 第二步:立即开始视频生成
const videoResponse = await startVideoGeneration(newTaskId, {
duration: parseInt(duration.value),
aspectRatio: aspectRatio.value,
hdMode: hdMode.value,
storyboardImage: imageUrl
})
if (videoResponse.data && videoResponse.data.success) {
ElMessage.success(t('video.storyboard.videoTaskStarted'))
refreshUserPoints()
pollTaskStatus(newTaskId)
} else {
const errorMsg = videoResponse.data?.message || t('video.storyboard.videoStartFailed')
handleInsufficientPointsError(errorMsg, errorMsg)
inProgress.value = false
}
ElMessage.success(t('video.storyboard.videoTaskStarted'))
refreshUserPoints()
pollTaskStatus(newTaskId)
} else {
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
handleInsufficientPointsError(errorMsg, errorMsg)
@@ -2222,6 +2212,10 @@ const pollVideoTaskStatus = async (taskId) => {
// 处理历史记录URL
const processHistoryUrl = (url) => {
if (!url) return ''
// data: 协议Base64 图片等)直接返回,避免被当成相对路径错误加前缀
if (url.startsWith('data:')) {
return url
}
// 如果是相对路径,确保格式正确
if (url.startsWith('/') || !url.startsWith('http')) {
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
@@ -2231,7 +2225,7 @@ const processHistoryUrl = (url) => {
return url
}
// 加载历史记录(同时加载分镜图和视频两种历史记录
// 加载历史记录(从user_works表获取分镜图和分镜视频
const loadHistory = async () => {
// 只有登录用户才能查看历史记录
if (!userStore.isAuthenticated) {
@@ -2244,17 +2238,29 @@ const loadHistory = async () => {
// 同时加载分镜图和视频历史记录
// 1. 加载视频历史记录:从 user_works 获取 STORYBOARD_VIDEO 类型的作品
const videoResponse = await getMyWorks({ page: 0, size: 1000 })
const videoResponse = await getMyWorksByType('STORYBOARD_VIDEO', { page: 0, size: 1000 })
if (videoResponse.data && videoResponse.data.success) {
const works = (videoResponse.data.data || []).filter(work =>
work.workType === 'STORYBOARD_VIDEO' && work.status === 'COMPLETED'
)
const works = videoResponse.data.data || []
console.log(`[历史记录-视频] 结果数量: ${works.length}`)
// 过滤掉 resultUrl 是图片的记录(这些是分镜图完成但视频还没生成的)
const videoWorks = works.filter(work => {
// 如果是 PROCESSING/PENDING 状态,保留
if (work.status === 'PROCESSING' || work.status === 'PENDING') {
return true
}
// 如果是 COMPLETED 状态,只保留 resultUrl 是视频的
if (work.status === 'COMPLETED' && work.resultUrl) {
const isImage = /\.(png|jpg|jpeg|gif|webp|bmp)(\?|$)/i.test(work.resultUrl)
return !isImage // 只保留视频
}
return false
})
console.log(`[历史记录-视频] 原始数量: ${works.length}, 过滤后: ${videoWorks.length}`)
// 转换为任务格式 - 分镜视频用 resultUrl 显示视频
videoHistoryTasks.value = works.map(work => ({
videoHistoryTasks.value = videoWorks.map(work => ({
taskId: work.taskId || work.id?.toString(),
prompt: work.prompt,
resultUrl: work.resultUrl, // 视频URL用于播放
@@ -2263,6 +2269,7 @@ const loadHistory = async () => {
videoResultUrl: work.resultUrl, // 视频URL用于下载
imageUrl: work.imageUrl,
uploadedImages: work.uploadedImages, // 用户上传的参考图,用于做同款
videoReferenceImages: work.videoReferenceImages, // 视频阶段用户上传的参考图
imagePrompt: work.imagePrompt,
videoPrompt: work.videoPrompt,
aspectRatio: work.aspectRatio,
@@ -2271,37 +2278,36 @@ const loadHistory = async () => {
quality: work.quality,
status: work.status,
workType: work.workType,
createdAt: work.createdAt
createdAt: work.createdAt,
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
}))
} else {
videoHistoryTasks.value = []
}
// 2. 加载分镜图历史记录:从 storyboard_video_tasks 获取分镜图任务
const storyboardResponse = await getUserStoryboardTasks(0, 1000)
// 2. 加载分镜图历史记录:从 user_works 获取 STORYBOARD_IMAGE 类型的作品
const storyboardResponse = await getMyWorksByType('STORYBOARD_IMAGE', { page: 0, size: 1000 })
if (storyboardResponse.data && storyboardResponse.data.success) {
// 显示有分镜图的任务
const tasks = (storyboardResponse.data.data || []).filter(task => {
if (task.status !== 'COMPLETED') return false
// 检查 resultUrl 是否存在且是图片
const resultUrl = task.resultUrl || ''
if (!resultUrl) return false
const isVideo = /\.(mp4|webm|mov|avi)(\?|$)/i.test(resultUrl)
return !isVideo // 只要不是视频就显示
})
const works = storyboardResponse.data.data || []
console.log(`[历史记录-分镜图] 结果数量: ${tasks.length}`)
console.log(`[历史记录-分镜图] 结果数量: ${works.length}`)
// 处理URL
storyboardHistoryTasks.value = tasks.map(task => ({
...task,
resultUrl: task.resultUrl && !task.resultUrl.startsWith('data:') && !task.resultUrl.startsWith('http')
? processHistoryUrl(task.resultUrl)
: task.resultUrl,
imageUrl: task.imageUrl && !task.imageUrl.startsWith('data:') && !task.imageUrl.startsWith('http')
? processHistoryUrl(task.imageUrl)
: task.imageUrl
// 转换为任务格式
storyboardHistoryTasks.value = works.map(work => ({
taskId: work.taskId?.replace('_image', '') || work.id?.toString(), // 移除_image后缀
prompt: work.prompt,
resultUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
imageUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
uploadedImages: work.uploadedImages,
imagePrompt: work.imagePrompt,
aspectRatio: work.aspectRatio,
hdMode: work.quality === 'HD',
quality: work.quality,
status: work.status,
workType: work.workType,
createdAt: work.createdAt,
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
}))
} else {
storyboardHistoryTasks.value = []
@@ -2360,39 +2366,31 @@ const createSimilarFromHistory = (task) => {
// 清空 Step 1 的 inputText避免混淆
inputText.value = ''
// 恢复用户上传的参考图片显示在下方3个上传框中
// 恢复视频阶段用户上传的参考图片显示在下方3个上传框中
// 注意:使用 videoReferenceImages视频阶段参考图不是 uploadedImages分镜图阶段参考图
videoReferenceImages.value = [null, null, null] // 先清空
// 优先从 uploadedImages 恢复JSON数组
if (task.uploadedImages) {
// 优先从 videoReferenceImages 恢复JSON数组- 这是视频阶段的参考图
if (task.videoReferenceImages) {
try {
const parsedImages = typeof task.uploadedImages === 'string'
? JSON.parse(task.uploadedImages)
: task.uploadedImages
const parsedImages = typeof task.videoReferenceImages === 'string'
? JSON.parse(task.videoReferenceImages)
: task.videoReferenceImages
if (Array.isArray(parsedImages)) {
parsedImages.filter(img => img && img !== 'null').forEach((img, idx) => {
if (idx < 3) {
videoReferenceImages.value[idx] = {
url: img,
file: null,
name: `参考图${idx + 1}`
name: `视频参考图${idx + 1}`
}
}
})
console.log('[做同款-视频] 恢复用户上传图片(uploadedImages):', parsedImages.length, '张')
console.log('[做同款-视频] 恢复视频阶段参考图(videoReferenceImages):', parsedImages.length, '张')
}
} catch (e) {
console.warn('[做同款-视频] 解析 uploadedImages 失败:', e)
console.warn('[做同款-视频] 解析 videoReferenceImages 失败:', e)
}
}
// 兼容旧字段 imageUrl
if (!videoReferenceImages.value[0] && task.imageUrl) {
videoReferenceImages.value[0] = {
url: task.imageUrl,
file: null,
name: '参考图片'
}
console.log('[做同款-视频] 恢复用户上传图片(imageUrl):', task.imageUrl)
}
// 清空 Step 1 的参考图
uploadedImages.value = []
@@ -2473,6 +2471,12 @@ const goToVideoStepWithStoryboard = (task) => {
// 切换到视频生成步骤
currentStep.value = 'video'
// 更新 taskId 为选中任务的 ID重要确保后续操作针对正确的任务
if (task.taskId) {
taskId.value = task.taskId
console.log('[生成视频] 切换到任务:', task.taskId)
}
// 设置分镜图
if (task.resultUrl && isImageUrl(task.resultUrl)) {
generatedImageUrl.value = task.resultUrl
@@ -2490,27 +2494,28 @@ const goToVideoStepWithStoryboard = (task) => {
videoPrompt.value = task.prompt
}
// 恢复用户上传的参考图片到视频参考图框
// 恢复视频阶段用户上传的参考图片到视频参考图框
// 注意:使用 videoReferenceImages视频阶段参考图不是 uploadedImages分镜图阶段参考图
videoReferenceImages.value = [null, null, null]
if (task.uploadedImages) {
if (task.videoReferenceImages) {
try {
const parsedImages = typeof task.uploadedImages === 'string'
? JSON.parse(task.uploadedImages)
: task.uploadedImages
const parsedImages = typeof task.videoReferenceImages === 'string'
? JSON.parse(task.videoReferenceImages)
: task.videoReferenceImages
if (Array.isArray(parsedImages)) {
parsedImages.filter(img => img && img !== 'null').forEach((img, idx) => {
if (idx < 3) {
videoReferenceImages.value[idx] = {
url: img,
file: null,
name: `参考图${idx + 1}`
name: `视频参考图${idx + 1}`
}
}
})
console.log('[生成视频] 恢复用户上传图片:', parsedImages.length, '张')
console.log('[生成视频] 恢复视频阶段参考图:', parsedImages.length, '张')
}
} catch (e) {
console.warn('[生成视频] 解析 uploadedImages 失败:', e)
console.warn('[生成视频] 解析 videoReferenceImages 失败:', e)
}
}
@@ -2626,7 +2631,7 @@ const formatDate = (dateString) => {
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'PENDING': t('video.storyboard.statusPending'),
'PENDING': t('video.storyboard.statusProcessing'), // 统一显示为"生成中"
'PROCESSING': t('video.storyboard.statusProcessing'),
'COMPLETED': t('video.completed'),
'FAILED': t('video.failed'),
@@ -2640,7 +2645,7 @@ const getDisplayStatusText = () => {
// 分镜图创作页面
if (currentStep.value === 'generate') {
if (inProgress.value) {
return taskStatus.value === 'PENDING' ? t('video.storyboard.statusPending') : t('video.storyboard.statusProcessing')
return t('video.storyboard.statusProcessing') // 统一显示为"生成中"
}
if (generatedImageUrl.value && isAIGeneratedImage.value) {
return t('video.storyboard.statusCompleted') // AI生成的分镜图已完成
@@ -2787,10 +2792,17 @@ const restoreProcessingTask = async () => {
if (detailResponse.data && detailResponse.data.success) {
const taskDetail = detailResponse.data.data
// 判断任务状态,如果已完成则不恢复任何参数
const detailStatus = taskDetail.status || 'PROCESSING'
if (detailStatus === 'COMPLETED') {
console.log('[恢复任务] 任务已完成,不恢复参数')
return false // 不需要恢复
}
currentTask.value = taskDetail
taskId.value = taskDetail.taskId
// 恢复输入参数
// 只有 PROCESSING/PENDING 状态才恢复输入参数
if (taskDetail.prompt) {
inputText.value = taskDetail.prompt
}
@@ -2810,43 +2822,19 @@ const restoreProcessingTask = async () => {
// 判断任务进度,决定恢复到哪个步骤
const taskProgress = Number(taskDetail.progress) || 0
const taskResultUrl = taskDetail.resultUrl || ''
const detailStatus = taskDetail.status || 'PROCESSING'
// 1. 如果分镜图生成任务已完成有resultUrl且状态是COMPLETED不应该恢复
if (taskResultUrl && isImageUrl(taskResultUrl) && detailStatus === 'COMPLETED') {
// 设置分镜图URL但不启动任务
generatedImageUrl.value = taskResultUrl
isAIGeneratedImage.value = true // AI生成的分镜图
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
// 恢复 videoPrompt
if (taskDetail.videoPrompt) {
videoPrompt.value = taskDetail.videoPrompt
} else if (taskDetail.prompt) {
videoPrompt.value = taskDetail.prompt
}
// 将分镜图添加到上传列表
const alreadyInList = uploadedImages.value.some(img => img.url === taskResultUrl)
if (!alreadyInList) {
uploadedImages.value.unshift({
url: taskResultUrl,
file: null,
name: '生成的分镜图'
})
}
currentStep.value = 'video' // 切换到视频生成步骤
inProgress.value = false // 不显示"生成中"
console.log('[恢复任务] 分镜图已完成填充分镜图框和videoPrompt')
return false // 不需要恢复轮询
}
console.log('[恢复任务] taskResultUrl:', taskResultUrl ? taskResultUrl.substring(0, 100) : '空')
console.log('[恢复任务] isImageUrl:', isImageUrl(taskResultUrl))
console.log('[恢复任务] detailStatus:', detailStatus)
console.log('[恢复任务] taskProgress:', taskProgress)
// 2. 如果分镜图已生成有resultUrl状态是PROCESSING
// 1. 如果分镜图已生成有resultUrl状态是PROCESSING
// 说明视频正在生成中分镜图完成后状态仍为PROCESSING表示视频生成中
if (taskResultUrl && isImageUrl(taskResultUrl) && detailStatus === 'PROCESSING') {
generatedImageUrl.value = taskResultUrl
isAIGeneratedImage.value = true // AI生成的分镜图
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
console.log('[恢复任务] 已设置 mainReferenceImage:', taskResultUrl.substring(0, 50))
// 恢复 videoPrompt
if (taskDetail.videoPrompt) {
@@ -2885,15 +2873,37 @@ const restoreProcessingTask = async () => {
pollTaskStatus(taskDetail.taskId)
return true
}
// 3. 如果分镜图还在生成中没有resultUrl状态是PROCESSING
// 2. 如果分镜图还在生成中没有resultUrl状态是PROCESSING
else if (!taskResultUrl && detailStatus === 'PROCESSING') {
// 恢复用户上传的参考图片
if (taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
// 恢复用户上传的参考图片(分镜图阶段)
// 优先从 uploadedImages 恢复JSON数组支持多张图片
if (taskDetail.uploadedImages) {
try {
const parsedImages = typeof taskDetail.uploadedImages === 'string'
? JSON.parse(taskDetail.uploadedImages)
: taskDetail.uploadedImages
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
uploadedImages.value = parsedImages
.filter(img => img && img !== 'null')
.map((url, idx) => ({
url: url,
file: null,
name: `参考图片${idx + 1}`
}))
console.log('[恢复任务-分镜图生成中] 恢复参考图:', uploadedImages.value.length, '张')
}
} catch (e) {
console.warn('[恢复任务] 解析 uploadedImages 失败:', e)
}
}
// 兼容旧字段 imageUrl单张图片
if (uploadedImages.value.length === 0 && taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
uploadedImages.value = [{
url: taskDetail.imageUrl,
file: null,
name: '参考图片'
}]
console.log('[恢复任务-分镜图生成中] 恢复参考图(imageUrl):', taskDetail.imageUrl)
}
currentStep.value = 'generate'
@@ -2904,8 +2914,29 @@ const restoreProcessingTask = async () => {
}
// 4. 其他情况PENDING等也恢复
else if (detailStatus === 'PENDING') {
// 恢复用户上传的参考图片
if (taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
// 恢复用户上传的参考图片(分镜图阶段)
// 优先从 uploadedImages 恢复JSON数组支持多张图片
if (taskDetail.uploadedImages) {
try {
const parsedImages = typeof taskDetail.uploadedImages === 'string'
? JSON.parse(taskDetail.uploadedImages)
: taskDetail.uploadedImages
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
uploadedImages.value = parsedImages
.filter(img => img && img !== 'null')
.map((url, idx) => ({
url: url,
file: null,
name: `参考图片${idx + 1}`
}))
console.log('[恢复任务-PENDING] 恢复参考图:', uploadedImages.value.length, '张')
}
} catch (e) {
console.warn('[恢复任务] 解析 uploadedImages 失败:', e)
}
}
// 兼容旧字段 imageUrl单张图片
if (uploadedImages.value.length === 0 && taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
uploadedImages.value = [{
url: taskDetail.imageUrl,
file: null,
@@ -2946,13 +2977,25 @@ const restoreProcessingTask = async () => {
taskId.value = work.taskId
inputText.value = work.prompt || ''
// 恢复参考图片(从 thumbnailUrl 获取
if (work.thumbnailUrl && isImageUrl(work.thumbnailUrl)) {
uploadedImages.value = [{
url: work.thumbnailUrl,
file: null,
name: '参考图片'
}]
// 恢复参考图片(优先从 uploadedImages 恢复
if (work.uploadedImages) {
try {
const parsedImages = typeof work.uploadedImages === 'string'
? JSON.parse(work.uploadedImages)
: work.uploadedImages
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
uploadedImages.value = parsedImages
.filter(img => img && img !== 'null')
.map((url, idx) => ({
url: url,
file: null,
name: `参考图片${idx + 1}`
}))
console.log('[恢复任务-兜底] 恢复参考图:', uploadedImages.value.length, '张')
}
} catch (e) {
console.warn('[恢复任务-兜底] 解析 uploadedImages 失败:', e)
}
}
inProgress.value = true
@@ -2980,43 +3023,10 @@ const checkLastTaskStatus = async () => {
if (response.data && response.data.success && response.data.data && response.data.data.length > 0) {
const lastTask = response.data.data[0]
// 检查是否是"分镜图已完成但视频未生成"的任务
// 条件:状态是 COMPLETED有分镜图结果resultUrl但没有视频结果videoUrls
const hasStoryboard = lastTask.resultUrl && isImageUrl(lastTask.resultUrl)
const hasVideo = lastTask.videoUrls || lastTask.videoUrl
if (lastTask.status === 'COMPLETED' && hasStoryboard && !hasVideo) {
// 这是分镜图已完成的任务,恢复到 Step 2
const taskResultUrl = processHistoryUrl(lastTask.resultUrl)
generatedImageUrl.value = taskResultUrl
isAIGeneratedImage.value = true // AI生成的分镜图
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
// 恢复 taskId关键用于生成视频时识别任务
taskId.value = lastTask.taskId || ''
// 恢复 videoPrompt
if (lastTask.videoPrompt) {
videoPrompt.value = lastTask.videoPrompt
} else if (lastTask.prompt) {
videoPrompt.value = lastTask.prompt
}
// 恢复提示词
if (lastTask.prompt) {
inputText.value = lastTask.prompt
}
// 恢复其他参数
if (lastTask.aspectRatio) {
aspectRatio.value = lastTask.aspectRatio
}
if (lastTask.hdMode !== undefined) {
hdMode.value = lastTask.hdMode
}
// 切换到视频生成步骤
currentStep.value = 'video'
console.log('[恢复任务] 分镜图已完成从历史记录taskId:', lastTask.taskId, '切换到视频生成步骤')
// 如果最近的任务是完全完成的COMPLETED 状态),不恢复任何参数
// 让用户从空白状态开始新任务
if (lastTask.status === 'COMPLETED') {
console.log('[checkLastTaskStatus] 最近任务已完成,不恢复参数')
return
}
@@ -3536,6 +3546,7 @@ onBeforeUnmount(() => {
padding: 0;
margin: 8px 0;
margin-left: 30px;
position: relative;
}
.storyboard-steps-svg svg {
@@ -3551,6 +3562,25 @@ onBeforeUnmount(() => {
opacity: 0.8;
}
/* 步骤点击容器 */
.step-click-container {
position: absolute;
top: 0;
height: 100%;
cursor: pointer;
z-index: 10;
}
.step-click-container.step-1 {
left: 0;
width: 122px; /* SVG宽度的一半 */
}
.step-click-container.step-2 {
left: 122px;
width: 122px; /* SVG宽度的一半 */
}
/* 生成的图片预览 */
.generated-image-preview {
width: 100%;
@@ -4784,6 +4814,15 @@ onBeforeUnmount(() => {
max-width: 220px;
}
.step-click-container.step-1,
.step-click-container.step-2 {
width: 110px; /* 220px的一半 */
}
.step-click-container.step-2 {
left: 110px;
}
.step {
text-align: left;
}
@@ -5522,18 +5561,63 @@ onBeforeUnmount(() => {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* 视频完成后的操作按钮区域 */
.result-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 0;
}
.result-actions .action-btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.result-actions .action-btn.primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
.result-actions .action-btn.primary:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.result-actions .action-icons {
display: flex;
gap: 12px;
}
.result-actions .icon-btn {
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.result-actions .icon-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.result-actions .icon-btn svg {
width: 20px;
height: 20px;
}
</style>
C:\Users\UI\Desktop\AIGC\demo>java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
Error: Unable to access jarfile target/demo-0.0.1-SNAPSHOT.jar
C:\Users\UI\Desktop\AIGC\demo>

View File

@@ -127,7 +127,7 @@
<div class="package-header">
<h4 class="package-title">{{ $t('subscription.free') }}</h4>
</div>
<div class="package-price">¥{{ membershipPrices.free }}/{{ $t('subscription.perMonth') }}</div>
<div class="package-price">¥{{ formatPrice(membershipPrices.free) }}/{{ $t('subscription.perMonth') }}</div>
<div class="points-box points-box-placeholder">&nbsp;</div>
<button class="package-button current">{{ $t('subscription.currentPackage') }}</button>
<div class="package-features">
@@ -160,9 +160,8 @@
<h4 class="package-title">{{ $t('subscription.standard') }}</h4>
<div class="discount-tag">{{ $t('subscription.firstPurchaseDiscount') }}</div>
</div>
<div class="package-price">¥{{ membershipPrices.standard }}/{{ Math.floor((membershipPoints.standard || 0) / 30) }}{{ $t('subscription.items') }}</div>
<div class="points-box" v-if="membershipPoints.standard !== null">{{ membershipPoints.standard }}{{ $t('subscription.points') }}</div>
<div class="points-box" v-else>{{ $t('subscription.loading') }}</div>
<div class="package-price">¥{{ formatPrice(membershipPrices.standard) }}/{{ membershipPoints.standard || 0 }}{{ $t('subscription.points') }}</div>
<div class="points-box points-box-placeholder">&nbsp;</div>
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
<div class="feature-item">
@@ -202,9 +201,8 @@
<h4 class="package-title">{{ $t('subscription.professional') }}</h4>
<div class="value-tag">{{ $t('subscription.bestValue') }}</div>
</div>
<div class="package-price">¥{{ membershipPrices.premium }}/{{ Math.floor((membershipPoints.premium || 0) / 30) }}{{ $t('subscription.items') }}</div>
<div class="points-box" v-if="membershipPoints.premium !== null">{{ membershipPoints.premium }}{{ $t('subscription.points') }}</div>
<div class="points-box" v-else>{{ $t('subscription.loading') }}</div>
<div class="package-price">¥{{ formatPrice(membershipPrices.premium) }}/{{ membershipPoints.premium || 0 }}{{ $t('subscription.points') }}</div>
<div class="points-box points-box-placeholder">&nbsp;</div>
<button class="package-button premium" @click.stop="handleSubscribe('premium')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
<div class="feature-item">
@@ -414,7 +412,7 @@ const subscriptionInfo = ref({
// 套餐名称映射函数将后端返回的中文套餐名映射到国际化key
const mapPlanNameToI18nKey = (planName) => {
if (!planName) return t('subscription.free')
if (!planName || planName.trim() === '') return t('subscription.free')
// 移除"会员"、"套餐"等后缀,并转为小写
const planLower = planName.replace(/会员|套餐|版本/g, '').toLowerCase()
@@ -636,6 +634,12 @@ const loadMembershipPrices = async () => {
}
}
// 格式化价格,保留两位小数
const formatPrice = (price) => {
if (price === null || price === undefined) return '--'
return Number(price).toFixed(2)
}
// 组件挂载时加载数据
onMounted(async () => {
// 先加载会员等级价格配置(不需要登录)
@@ -662,6 +666,29 @@ onMounted(async () => {
// 然后从API获取完整的订阅信息
await loadUserSubscriptionInfo()
// 检查URL参数处理支付回调
const urlParams = new URLSearchParams(window.location.search)
const paymentStatus = urlParams.get('paymentStatus')
const orderId = urlParams.get('orderId')
if (paymentStatus) {
// 清除URL参数避免刷新时重复显示
window.history.replaceState({}, document.title, window.location.pathname)
if (paymentStatus === 'success') {
ElMessage.success(t('subscription.paymentSuccess'))
// 刷新用户积分信息
await userStore.fetchCurrentUser()
await loadUserSubscriptionInfo()
} else if (paymentStatus === 'pending') {
ElMessage.info(t('subscription.paymentPending'))
} else if (paymentStatus === 'cancelled') {
ElMessage.warning(t('subscription.paymentCancelled'))
} else if (paymentStatus === 'error') {
ElMessage.error(t('subscription.paymentError'))
}
}
} else {
// 路由守卫应该已经处理了跳转,但这里作为双重保险
console.warn('用户未登录,路由守卫应该已处理跳转')
@@ -1323,10 +1350,10 @@ const createSubscriptionOrder = async (planType, planInfo) => {
/* 套餐选择 */
.subscription-packages {
padding: 0 30px 30px; /* 与顶部盒子保持一致的左右留白 */
margin: 0 auto; /* 居中显示,与上方盒子对齐 */
max-width: calc(100% - 80px); /* 限制最大宽度,与上方盒子一致 */
margin-left: 15px; /* 增加左边距,与上方盒子更精确对齐 */
padding: 0 30px 30px;
margin: 0 auto;
max-width: calc(100% - 80px);
margin-left: 15px;
}
.subscription-packages .section-title {
@@ -1354,9 +1381,9 @@ const createSubscriptionOrder = async (planType, planInfo) => {
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 1440px;
margin: 0 30px 0 0; /* 右侧留白与左侧 padding(30px) 保持一致 */
margin: 0 30px 0 0;
width: 100%;
align-items: stretch; /* 卡片等高 */
min-height: calc(100vh - 450px); /* 调整高度 */
}
.subscription-packages .package-card {
@@ -1366,7 +1393,6 @@ const createSubscriptionOrder = async (planType, planInfo) => {
border: 1px solid #333;
position: relative;
transition: all 0.3s ease;
min-height: 700px; /* 进一步拉长 */
display: flex;
flex-direction: column;
}

View File

@@ -110,7 +110,7 @@
<h3>{{ level.name }}</h3>
</div>
<div class="card-body">
<p class="price">¥{{ level.price || 0 }}/{{ Math.floor((level.resourcePoints || level.pointsBonus || 0) / 30) }}{{ $t('subscription.items') }}</p>
<p class="price">¥{{ formatPrice(level.price) }}/{{ Math.floor((level.resourcePoints || level.pointsBonus || 0) / 30) }}{{ $t('subscription.items') }}</p>
<p class="description">{{ level.resourcePoints || level.pointsBonus || 0 }}{{ $t('subscription.points') }}</p>
</div>
<div class="card-footer">
@@ -554,8 +554,14 @@ const handleCloseEditDialog = () => {
}
}
// 格式化价格,保留两位小数
const formatPrice = (price) => {
if (price === null || price === undefined) return '0.00'
return Number(price).toFixed(2)
}
const handlePriceInput = (value) => {
// 确保只输入数字
// 确保只输入数字和小数点
editForm.price = value.replace(/[^\d.]/g, '')
}
@@ -564,8 +570,8 @@ const saveEdit = async () => {
if (!valid) return
try {
const priceInt = parseInt(editForm.price)
if (Number.isNaN(priceInt) || priceInt < 0) {
const priceValue = parseFloat(editForm.price)
if (Number.isNaN(priceValue) || priceValue < 0) {
ElMessage.error(t('systemSettings.enterValidNumber'))
return
}
@@ -576,8 +582,8 @@ const saveEdit = async () => {
return
}
// 直接更新membership_levels表
const updateData = { price: priceInt, pointsBonus: pointsInt }
// 直接更新membership_levels表(价格支持两位小数)
const updateData = { price: priceValue, pointsBonus: pointsInt }
console.log('准备更新会员等级:', editForm.id, updateData)
const response = await api.put(`/members/levels/${editForm.id}`, updateData)
console.log('会员等级更新响应:', response.data)

View File

@@ -118,9 +118,10 @@
<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: taskProgress + '%' }"></div>
<!-- 生成中动态进度条进度为0时也显示动画 -->
<div v-else class="progress-bar-large" :class="{ 'indeterminate': taskProgress === 0 }">
<div v-if="taskProgress === 0" class="progress-fill-indeterminate"></div>
<div v-else class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ taskProgress }}%</div>
</div>
@@ -232,11 +233,11 @@
<div v-if="task.status === 'PENDING'" class="history-progress-bar indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中动态进度条 -->
<div v-else class="history-progress-bar">
<div class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
<!-- 生成中动态进度条进度为0时也显示动画 -->
<div v-else class="history-progress-bar" :class="{ 'indeterminate': (task.progress || 0) === 0 }">
<div v-if="(task.progress || 0) === 0" class="progress-fill-indeterminate"></div>
<div v-else class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
</div>
<div class="queue-link">{{ t('video.textToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('common.cancel') }}</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail">
@@ -333,7 +334,7 @@ import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
import { getProcessingWorks, getMyWorksByType } from '@/api/userWorks'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
@@ -517,7 +518,7 @@ const startGenerate = async () => {
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
taskStatus.value = 'PROCESSING' // 直接显示生成中
ElMessage.success(t('video.textToVideo.taskCreated'))
@@ -837,7 +838,7 @@ const retryTask = async () => {
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
taskStatus.value = 'PROCESSING' // 直接显示生成中
ElMessage.success('重试任务已提交')
@@ -882,6 +883,10 @@ const deleteWork = () => {
// 处理历史记录URL
const processHistoryUrl = (url) => {
if (!url) return ''
// data: 协议Base64 图片等)直接返回,避免被当成相对路径错误加前缀
if (url.startsWith('data:')) {
return url
}
// 如果是相对路径,确保格式正确
if (url.startsWith('/') || !url.startsWith('http')) {
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
@@ -891,7 +896,7 @@ const processHistoryUrl = (url) => {
return url
}
// 加载历史记录
// 加载历史记录从user_works表获取TEXT_TO_VIDEO类型的作品
const loadHistory = async () => {
// 只有登录用户才能查看历史记录
if (!userStore.isAuthenticated) {
@@ -900,18 +905,22 @@ const loadHistory = async () => {
}
try {
// 请求全部已完成的任务,不限制数量
const response = await textToVideoApi.getTasks(0, 1000)
// 从user_works表获取TEXT_TO_VIDEO类型的作品
const response = await getMyWorksByType('TEXT_TO_VIDEO', { page: 0, size: 1000 })
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
)
// 转换数据格式,适配历史记录展
const works = response.data.data || []
// 处理URL确保相对路径正确
historyTasks.value = tasks.map(task => ({
...task,
resultUrl: task.resultUrl ? processHistoryUrl(task.resultUrl) : null
historyTasks.value = works.map(work => ({
taskId: work.taskId,
prompt: work.prompt,
aspectRatio: work.aspectRatio,
duration: work.duration ? parseInt(work.duration) : 5,
hdMode: work.quality === 'HD',
status: work.status,
resultUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
createdAt: work.createdAt,
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
}))
console.log('[History Load]', t('video.textToVideo.historyLoadSuccess', { count: historyTasks.value.length }))
@@ -1060,30 +1069,30 @@ const restoreProcessingTask = async () => {
// 取最新的一个任务
const work = textToVideoWorks[0]
// 恢复任务状态
currentTask.value = {
taskId: work.taskId,
prompt: work.prompt,
aspectRatio: work.aspectRatio,
duration: work.duration,
resultUrl: work.resultUrl,
createdAt: work.createdAt
}
// 恢复输入参数
if (work.prompt) {
inputText.value = work.prompt
}
if (work.aspectRatio) {
aspectRatio.value = work.aspectRatio
}
if (work.duration) {
duration.value = parseInt(work.duration) || 10
}
// 只有真正在进行中的任务才恢复和显示消息
// 只有真正在进行中的任务才恢复
const workStatus = work.status || 'PROCESSING'
if (workStatus === 'PROCESSING' || workStatus === 'PENDING') {
// 恢复任务状态
currentTask.value = {
taskId: work.taskId,
prompt: work.prompt,
aspectRatio: work.aspectRatio,
duration: work.duration,
resultUrl: work.resultUrl,
createdAt: work.createdAt
}
// 恢复输入参数
if (work.prompt) {
inputText.value = work.prompt
}
if (work.aspectRatio) {
aspectRatio.value = work.aspectRatio
}
if (work.duration) {
duration.value = parseInt(work.duration) || 10
}
inProgress.value = true
taskStatus.value = workStatus
taskProgress.value = 50 // 初始进度设为50%
@@ -1098,7 +1107,7 @@ const restoreProcessingTask = async () => {
startPollingTask()
return true
} else {
// 如果任务已失败或取消,不显示恢复消息,让 checkLastTaskStatus 处理
// 如果任务已完成、失败或取消,不恢复任何参数
console.log('[Task Skip]', work.taskId, 'Status:', workStatus, '- 不是进行中状态')
return false
}