fix: PayPal payment_method column length issue; add image model selection for storyboard; remove task restore popups; sync UserWork status on task failure
40
demo/frontend/build-for-baota.bat
Normal file
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo 前端打包脚本 - Windows
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
echo [1/3] 安装依赖...
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo 错误: 依赖安装失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/3] 构建生产版本...
|
||||
call npm run build
|
||||
if %errorlevel% neq 0 (
|
||||
echo 错误: 构建失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 打包完成!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 打包文件位置: frontend\dist\
|
||||
echo.
|
||||
echo 部署到宝塔步骤:
|
||||
echo 1. 直接上传 frontend\dist 目录下的所有文件到服务器
|
||||
echo 2. 上传到网站根目录(如:/www/wwwroot/your-domain)
|
||||
echo 3. 在宝塔面板配置 Nginx 为 Vue Router 模式
|
||||
echo.
|
||||
echo 提示:可以使用宝塔面板的文件管理器,选择 dist 目录下所有文件上传
|
||||
echo 或使用 FTP/SFTP 工具上传整个 dist 目录的内容
|
||||
echo.
|
||||
pause
|
||||
44
demo/frontend/build-for-baota.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# 前端打包脚本 - Linux
|
||||
|
||||
echo "========================================"
|
||||
echo " 前端打包脚本 - Linux"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "[1/3] 安装依赖..."
|
||||
npm install
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}错误: 依赖安装失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[2/3] 构建生产版本..."
|
||||
npm run build
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}错误: 构建失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 打包完成!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo -e "${GREEN}打包文件位置: frontend/dist/${NC}"
|
||||
echo ""
|
||||
echo "部署到宝塔步骤:"
|
||||
echo "1. 直接上传 frontend/dist 目录下的所有文件到服务器"
|
||||
echo "2. 上传到网站根目录(如:/www/wwwroot/your-domain)"
|
||||
echo "3. 在宝塔面板配置 Nginx 为 Vue Router 模式"
|
||||
echo ""
|
||||
echo "上传命令示例:"
|
||||
echo " scp -r dist/* root@your-server:/www/wwwroot/your-domain/"
|
||||
echo ""
|
||||
BIN
demo/frontend/frontend-dist.zip
Normal file
BIN
demo/frontend/public/fonts/TaipeiSansTC.ttf
Normal file
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
9
demo/frontend/public/images/backgrounds/welcome-bg.svg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 848 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 6.5 MiB After Width: | Height: | Size: 6.5 MiB |
@@ -95,6 +95,17 @@ export const imageToVideoApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除任务
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
deleteTask(taskId) {
|
||||
return request({
|
||||
url: `/image-to-video/tasks/${taskId}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
|
||||
@@ -47,6 +47,14 @@ export const handleAlipayCallback = (params) => {
|
||||
return api.post('/payments/alipay/callback', params)
|
||||
}
|
||||
|
||||
// PayPal支付API
|
||||
export const createPayPalPayment = (paymentData) => {
|
||||
return api.post('/payment/paypal/create', paymentData)
|
||||
}
|
||||
|
||||
export const getPayPalPaymentStatus = (paymentId) => {
|
||||
return api.get(`/payment/paypal/status/${paymentId}`)
|
||||
}
|
||||
|
||||
// 支付统计API
|
||||
export const getPaymentStats = () => {
|
||||
|
||||
@@ -28,4 +28,13 @@ export const getUserStoryboardTasks = async (page = 0, size = 10) => {
|
||||
*/
|
||||
export const startVideoGeneration = async (taskId, params = {}) => {
|
||||
return api.post(`/storyboard-video/task/${taskId}/start-video`, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接多张图片为六宫格(2×3)
|
||||
* @param {Array<string>} images - Base64图片数组
|
||||
* @param {number} cols - 列数,默认3(2×3布局)
|
||||
*/
|
||||
export const mergeImagesToGrid = async (images, cols = 3) => {
|
||||
return api.post('/image-grid/merge', { images, cols })
|
||||
}
|
||||
@@ -25,6 +25,16 @@
|
||||
</div>
|
||||
<span>Alipay扫码支付</span>
|
||||
</div>
|
||||
<div
|
||||
class="payment-method"
|
||||
:class="{ active: selectedMethod === 'paypal' }"
|
||||
@click="selectMethod('paypal')"
|
||||
>
|
||||
<div class="method-icon paypal-icon">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
</div>
|
||||
<span>PayPal支付</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 金额显示 -->
|
||||
@@ -33,8 +43,8 @@
|
||||
<div class="amount-value">${{ amount }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码区域 -->
|
||||
<div class="qr-section">
|
||||
<!-- 支付宝二维码区域 -->
|
||||
<div v-if="selectedMethod === 'alipay'" class="qr-section">
|
||||
<div class="qr-code">
|
||||
<img id="qr-code-img" style="display: none; width: 200px; height: 200px; margin: 0; padding: 0; border: none; object-fit: contain; background: #1a1a1a;" alt="支付二维码" />
|
||||
<div ref="qrPlaceholder" class="qr-placeholder">
|
||||
@@ -46,32 +56,33 @@
|
||||
<div class="qr-tip">支付前请阅读《Vionow支付服务条款》</div>
|
||||
</div>
|
||||
|
||||
<!-- PayPal支付按钮区域 -->
|
||||
<div v-if="selectedMethod === 'paypal'" class="paypal-section">
|
||||
<div class="paypal-info">
|
||||
<div class="paypal-logo">
|
||||
<svg width="100" height="32" viewBox="0 0 100 32" fill="none">
|
||||
<text x="0" y="24" font-family="Arial" font-size="20" font-weight="bold" fill="#0070BA">PayPal</text>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="paypal-desc">安全便捷的国际支付方式</p>
|
||||
<p class="paypal-desc-small">点击下方按钮跳转到PayPal完成支付</p>
|
||||
</div>
|
||||
<button
|
||||
class="paypal-pay-button"
|
||||
@click="handlePay"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span v-if="!loading">前往PayPal支付</span>
|
||||
<span v-else>正在跳转...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 支付提示 -->
|
||||
<div class="action-section">
|
||||
<div v-if="selectedMethod === 'alipay'" class="action-section">
|
||||
<div class="pay-tip">
|
||||
<p>请使用支付宝扫描上方二维码完成支付</p>
|
||||
<p class="tip-small">支付完成后页面将自动更新</p>
|
||||
</div>
|
||||
<!-- 模拟支付完成按钮(仅用于测试) -->
|
||||
<div class="test-payment-section" style="margin-top: 16px; text-align: center;">
|
||||
<button
|
||||
class="test-payment-btn"
|
||||
@click="handleTestPaymentComplete"
|
||||
:disabled="!currentPaymentId || loading"
|
||||
style="
|
||||
padding: 8px 16px;
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
"
|
||||
>
|
||||
🧪 模拟支付完成(测试用)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部链接 -->
|
||||
@@ -87,7 +98,7 @@ import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CreditCard } from '@element-plus/icons-vue'
|
||||
import { createPayment, createAlipayPayment, getPaymentById, testPaymentComplete } from '@/api/payments'
|
||||
import { createPayment, createAlipayPayment, createPayPalPayment, getPaymentById, getPayPalPaymentStatus } from '@/api/payments'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -144,18 +155,42 @@ const selectMethod = (method) => {
|
||||
const handlePay = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
ElMessage.info('正在创建支付订单...')
|
||||
|
||||
// 创建支付订单数据
|
||||
const paymentData = {
|
||||
orderId: props.orderId,
|
||||
amount: props.amount.toString(),
|
||||
method: 'ALIPAY',
|
||||
description: `${props.title} - 支付宝支付`
|
||||
// 根据选择的支付方式处理
|
||||
if (selectedMethod.value === 'paypal') {
|
||||
await handlePayPalPayment()
|
||||
} else {
|
||||
await handleAlipayPayment()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('=== 支付流程出错 ===')
|
||||
console.error('错误详情:', error)
|
||||
console.error('错误响应:', error.response)
|
||||
console.error('错误状态:', error.response?.status)
|
||||
console.error('错误数据:', error.response?.data)
|
||||
|
||||
ElMessage.error(`支付失败:${error.message || '请重试'}`)
|
||||
emit('pay-error', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== 开始支付流程 ===')
|
||||
console.log('支付数据:', paymentData)
|
||||
// 处理支付宝支付
|
||||
const handleAlipayPayment = async () => {
|
||||
ElMessage.info('正在创建支付订单...')
|
||||
|
||||
// 创建支付订单数据
|
||||
const paymentData = {
|
||||
orderId: props.orderId,
|
||||
amount: props.amount.toString(),
|
||||
method: 'ALIPAY',
|
||||
description: `${props.title} - 支付宝支付`
|
||||
}
|
||||
|
||||
console.log('=== 开始支付宝支付流程 ===')
|
||||
console.log('支付数据:', paymentData)
|
||||
|
||||
// 先创建支付记录
|
||||
console.log('1. 创建支付订单...')
|
||||
@@ -227,18 +262,52 @@ const handlePay = async () => {
|
||||
ElMessage.error(createResponse.data?.message || '创建支付订单失败')
|
||||
emit('pay-error', new Error(createResponse.data?.message || '创建支付订单失败'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理PayPal支付
|
||||
const handlePayPalPayment = async () => {
|
||||
ElMessage.info('正在创建PayPal支付...')
|
||||
|
||||
// 从sessionStorage获取用户信息
|
||||
const userStr = sessionStorage.getItem('user')
|
||||
let username = 'guest'
|
||||
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr)
|
||||
username = user.username || user.name || 'guest'
|
||||
} catch (e) {
|
||||
console.error('解析用户信息失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const paymentData = {
|
||||
username: username,
|
||||
orderId: props.orderId,
|
||||
amount: props.amount.toString(),
|
||||
method: 'PAYPAL'
|
||||
}
|
||||
|
||||
console.log('=== 开始PayPal支付流程 ===')
|
||||
console.log('支付数据:', paymentData)
|
||||
|
||||
const response = await createPayPalPayment(paymentData)
|
||||
console.log('PayPal支付响应:', response)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const paymentUrl = response.data.paymentUrl
|
||||
const paymentId = response.data.paymentId
|
||||
currentPaymentId.value = paymentId
|
||||
|
||||
} catch (error) {
|
||||
console.error('=== 支付流程出错 ===')
|
||||
console.error('错误详情:', error)
|
||||
console.error('错误响应:', error.response)
|
||||
console.error('错误状态:', error.response?.status)
|
||||
console.error('错误数据:', error.response?.data)
|
||||
console.log('PayPal支付URL:', paymentUrl)
|
||||
ElMessage.success('正在跳转到PayPal支付页面...')
|
||||
|
||||
ElMessage.error(`支付失败:${error.message || '请重试'}`)
|
||||
emit('pay-error', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
// 跳转到PayPal支付页面
|
||||
window.location.href = paymentUrl
|
||||
} else {
|
||||
console.error('PayPal支付创建失败:', response)
|
||||
ElMessage.error(response.data?.message || 'PayPal支付创建失败')
|
||||
emit('pay-error', new Error(response.data?.message || 'PayPal支付创建失败'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,44 +419,6 @@ const getStatusDescription = (status) => {
|
||||
return statusMap[status] || '未知状态'
|
||||
}
|
||||
|
||||
// 模拟支付完成(用于测试)
|
||||
const handleTestPaymentComplete = async () => {
|
||||
if (!currentPaymentId.value) {
|
||||
ElMessage.warning('支付订单尚未创建,请稍候...')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
ElMessage.info('正在模拟支付完成...')
|
||||
|
||||
console.log('模拟支付完成,支付ID:', currentPaymentId.value)
|
||||
const response = await testPaymentComplete(currentPaymentId.value)
|
||||
|
||||
console.log('✅ 模拟支付完成响应:', response)
|
||||
console.log('✅ 响应数据:', response.data)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
console.log('✅ 模拟支付完成成功,支付数据:', response.data.data)
|
||||
ElMessage.success('支付完成!')
|
||||
stopPaymentPolling()
|
||||
emit('pay-success', response.data.data)
|
||||
// 延迟关闭模态框
|
||||
setTimeout(() => {
|
||||
visible.value = false
|
||||
}, 2000)
|
||||
} else {
|
||||
console.error('❌ 模拟支付完成失败,响应:', response)
|
||||
ElMessage.error(response.data?.message || '模拟支付完成失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('模拟支付完成失败:', error)
|
||||
ElMessage.error(`模拟支付完成失败:${error.message || '请重试'}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示协议
|
||||
const showAgreement = () => {
|
||||
router.push('/terms-of-service')
|
||||
@@ -690,6 +721,10 @@ const showAgreement = () => {
|
||||
color: #1677FF;
|
||||
}
|
||||
|
||||
.paypal-icon {
|
||||
color: #0070BA;
|
||||
}
|
||||
|
||||
.payment-method.active .method-icon {
|
||||
color: white;
|
||||
}
|
||||
@@ -726,6 +761,74 @@ const showAgreement = () => {
|
||||
letter-spacing: 0.5px !important;
|
||||
}
|
||||
|
||||
/* PayPal支付区域 */
|
||||
.paypal-section {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 32px 24px;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.paypal-info {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.paypal-logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.paypal-desc {
|
||||
color: white !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 500 !important;
|
||||
margin: 8px 0 !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.paypal-desc-small {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 400 !important;
|
||||
margin: 4px 0 !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.paypal-pay-button {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #0070BA 0%, #005EA6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 112, 186, 0.3);
|
||||
}
|
||||
|
||||
.paypal-pay-button:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #005EA6 0%, #004A85 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 112, 186, 0.4);
|
||||
}
|
||||
|
||||
.paypal-pay-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.paypal-pay-button:disabled {
|
||||
background: #666;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 二维码区域 */
|
||||
.qr-section {
|
||||
text-align: center;
|
||||
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
myWorks: 'My Works',
|
||||
tools: 'Tools',
|
||||
noUsername: 'No username set',
|
||||
published: 'Published',
|
||||
published: 'Portfolio',
|
||||
userId: 'ID',
|
||||
noWorksYet: 'No works yet, start creating!',
|
||||
createSimilar: 'Create Similar',
|
||||
@@ -181,6 +181,8 @@ export default {
|
||||
generationFailed: 'Generation Failed',
|
||||
checkInputOrRetry: 'Please check input or retry',
|
||||
regenerate: 'Regenerate',
|
||||
clearAndCreateNew: 'Clear and Create New',
|
||||
failedTaskCleared: 'Failed task cleared',
|
||||
startCreating: 'Start creating your video!',
|
||||
noDescription: 'No Description',
|
||||
queuing: 'Queuing...',
|
||||
@@ -298,7 +300,8 @@ export default {
|
||||
deleteCancelled: 'Deletion cancelled',
|
||||
historyParamsFilled: 'History parameters filled, ready to generate',
|
||||
cancelFeatureTodo: 'Cancel feature coming soon',
|
||||
resumingTask: 'Unfinished task detected, resuming...'
|
||||
resumingTask: 'Unfinished task detected, resuming...',
|
||||
unfinishedTaskDetected: 'Unfinished task detected, restoring...'
|
||||
},
|
||||
|
||||
// Storyboard Video translations
|
||||
@@ -307,13 +310,13 @@ export default {
|
||||
generateStoryboard: 'Generate Storyboard',
|
||||
generateVideo: 'Generate Video',
|
||||
uploadStoryboard: 'Upload Storyboard (can generate video directly)',
|
||||
uploadHint: 'Upload 1-6 storyboard images, can generate video directly without text description',
|
||||
uploadHint: 'Upload 1-9 storyboard images, can generate video directly without text description',
|
||||
addMore: 'Add More',
|
||||
uploadedCount: 'Uploaded {count}/6',
|
||||
uploadedCount: 'Uploaded {count}/9',
|
||||
uploadLimit: 'Limit reached',
|
||||
uploadedImage: 'Uploaded image {index}',
|
||||
maxImages: 'Maximum 6 images allowed',
|
||||
maxImagesWarning: 'Maximum 6 images allowed, you have uploaded {current}, you can upload {remaining} more',
|
||||
maxImages: 'Maximum 9 images allowed',
|
||||
maxImagesWarning: 'Maximum 9 images allowed, you have uploaded {current}, you can upload {remaining} more',
|
||||
fileSizeLimit: 'Image file size cannot exceed 100MB',
|
||||
invalidFileType: 'Please select valid image files',
|
||||
uploadSuccess: 'Successfully uploaded {count} images',
|
||||
@@ -321,6 +324,11 @@ export default {
|
||||
promptPlaceholder: 'Example: a coffee advertisement\n\nTip: Simple description is enough, AI will automatically optimize it into professional storyboard\nSupports Chinese or English input, the system will automatically translate and optimize it into professional storyboard description',
|
||||
tip1: '💡 AI will automatically generate professional storyboards based on your description',
|
||||
tip2: '🎬 Supports various scene compositions and camera types',
|
||||
videoPromptLabel: 'Video Description',
|
||||
videoPromptPlaceholder: 'Describe actions and camera movements in the video, e.g.: camera slowly zooms in, person turns around and smiles',
|
||||
videoTip1: '💡 Describe actions and camera movements for more vivid video generation',
|
||||
videoTip2: '🎬 Supports camera movements, character actions, scene transitions, etc.',
|
||||
storyboardReadyHint: 'Storyboard is ready, enter video description and click generate',
|
||||
optimizing: 'Optimizing...',
|
||||
enterPrompt: 'Please enter prompt',
|
||||
promptTooLong: 'Prompt is too long, please keep it within 2000 characters',
|
||||
@@ -336,6 +344,7 @@ export default {
|
||||
noStoryboard: 'No storyboard yet',
|
||||
hdMode: 'HD Mode (1080P)',
|
||||
hdCost: 'Costs 20 points when enabled',
|
||||
imageModel: 'Image Generation Model',
|
||||
pleaseLogin: 'Please login first',
|
||||
loginRequired: 'Login required to submit task',
|
||||
loginNow: 'Login Now',
|
||||
@@ -357,6 +366,7 @@ export default {
|
||||
startGenerateStoryboard: 'Start Generate Storyboard',
|
||||
startGenerate: 'Start Generate',
|
||||
enterDescription: 'Please enter description',
|
||||
enterDescriptionForImage: 'Please enter description, AI will generate storyboard based on reference image and description',
|
||||
startingGenerate: 'Starting to generate storyboard...',
|
||||
taskCreated: 'Storyboard task created successfully!',
|
||||
createTaskFailed: 'Failed to create task',
|
||||
@@ -724,6 +734,12 @@ export default {
|
||||
failedTasksLogged: 'Failed tasks will be logged to cleanup logs',
|
||||
originalTasksDeleted: 'Original task records will be deleted',
|
||||
irreversibleWarning: 'This operation is irreversible, please proceed with caution!',
|
||||
confirmCleanup: 'Confirm Cleanup'
|
||||
confirmCleanup: 'Confirm Cleanup',
|
||||
aiModel: 'AI Model Settings',
|
||||
promptOptimization: 'Prompt Optimization Settings',
|
||||
promptOptimizationApiUrl: 'API Endpoint',
|
||||
promptOptimizationApiUrlTip: 'Enter the API endpoint for prompt optimization, e.g., https://api.openai.com',
|
||||
promptOptimizationModel: 'Model Name',
|
||||
promptOptimizationModelTip: 'Enter the model name for prompt optimization, e.g., gpt-4o, gemini-pro'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +97,13 @@ export default {
|
||||
myWorks: '我的作品',
|
||||
tools: '工具',
|
||||
noUsername: '未设置用户名',
|
||||
published: '已发布',
|
||||
published: '作品集',
|
||||
userId: 'ID',
|
||||
noWorksYet: '暂无作品,开始创作吧!',
|
||||
createSimilar: '做同款',
|
||||
workDetail: '作品详情',
|
||||
videoDetail: '视频详情',
|
||||
description: '描述',
|
||||
category: '分类',
|
||||
inputDetails: '输入详情',
|
||||
createTime: '创建时间',
|
||||
@@ -181,6 +183,8 @@ export default {
|
||||
generationFailed: '生成失败',
|
||||
checkInputOrRetry: '请检查输入或重新尝试',
|
||||
regenerate: '重新生成',
|
||||
clearAndCreateNew: '清除并创作新内容',
|
||||
failedTaskCleared: '失败任务已清除',
|
||||
startCreating: '开始创作你的视频吧!',
|
||||
noDescription: '无描述',
|
||||
queuing: '排队中...',
|
||||
@@ -299,7 +303,8 @@ export default {
|
||||
historyParamsFilled: '已填充历史记录参数,可以开始生成',
|
||||
cancelFeatureTodo: '取消功能待实现',
|
||||
resumingTask: '检测到未完成的任务,继续处理中...',
|
||||
resumingStoryboardTask: '检测到分镜图生成任务,继续处理中...'
|
||||
resumingStoryboardTask: '检测到分镜图生成任务,继续处理中...',
|
||||
unfinishedTaskDetected: '检测到未完成任务,正在恢复...'
|
||||
},
|
||||
|
||||
// 分镜视频专用翻译
|
||||
@@ -307,14 +312,14 @@ export default {
|
||||
userAvatar: '用户头像',
|
||||
generateStoryboard: '生成分镜图',
|
||||
generateVideo: '生成视频',
|
||||
uploadStoryboard: '上传分镜图 (可直接生成视频)',
|
||||
uploadHint: '支持上传 1-6 张分镜图,可直接生成视频,无需文字描述',
|
||||
addMore: '继续添加',
|
||||
uploadedCount: '已上传 {count}/6',
|
||||
uploadLimit: '已达上限',
|
||||
uploadedImage: '上传的图片 {index}',
|
||||
maxImages: '最多只能上传6张图片',
|
||||
maxImagesWarning: '最多只能上传6张图片,您已上传{current}张,还可以上传{remaining}张',
|
||||
uploadStoryboard: '上传参考图片',
|
||||
uploadHint: '上传一张参考图片,AI将根据图片生成6宫格分镜图',
|
||||
addMore: '重新上传',
|
||||
uploadedCount: '已上传图片',
|
||||
uploadLimit: '已上传',
|
||||
uploadedImage: '参考图片',
|
||||
maxImages: '只能上传1张参考图片',
|
||||
maxImagesWarning: '只能上传1张参考图片',
|
||||
fileSizeLimit: '图片文件大小不能超过100MB',
|
||||
invalidFileType: '请选择有效的图片文件',
|
||||
uploadSuccess: '成功上传 {count} 张图片',
|
||||
@@ -322,6 +327,11 @@ export default {
|
||||
promptPlaceholder: '例如:一个咖啡的广告\n\n提示:简单描述即可,AI会自动优化成专业的分镜图\n支持中文或英文输入,系统会自动翻译并优化为专业的分镜图描述',
|
||||
tip1: '💡 AI会根据您的描述自动生成专业分镜图',
|
||||
tip2: '🎬 支持多种画面构图和镜头类型描述',
|
||||
videoPromptLabel: '视频描述',
|
||||
videoPromptPlaceholder: '描述视频中的动作、镜头运动等,例如:镜头缓慢推进,人物转身微笑',
|
||||
videoTip1: '💡 描述画面中的动作和镜头运动,AI将生成更生动的视频',
|
||||
videoTip2: '🎬 支持描述镜头推拉、人物动作、场景变化等',
|
||||
storyboardReadyHint: '分镜图已准备就绪,输入视频描述后点击生成视频',
|
||||
optimizing: '优化中...',
|
||||
enterPrompt: '请输入提示词',
|
||||
promptTooLong: '提示词过长,请控制在2000字符以内',
|
||||
@@ -337,6 +347,7 @@ export default {
|
||||
noStoryboard: '暂无分镜图',
|
||||
hdMode: '高清模式 (1080P)',
|
||||
hdCost: '开启消耗20积分',
|
||||
imageModel: '图像生成模型',
|
||||
pleaseLogin: '请先登录',
|
||||
loginRequired: '需要登录后才能提交任务',
|
||||
loginNow: '立即登录',
|
||||
@@ -351,13 +362,15 @@ export default {
|
||||
queuing: '排队中',
|
||||
subscribeToSpeed: '订阅套餐以提升生成速度',
|
||||
noResult: '暂无结果',
|
||||
uploadOrGenerateFirst: '请先上传分镜图或生成分镜图',
|
||||
uploadOrInputPrompt: '请上传分镜图或输入提示词',
|
||||
uploadOrGenerateFirst: '请先上传参考图片或输入描述生成分镜图',
|
||||
uploadOrInputPrompt: '请上传参考图片或输入提示词',
|
||||
startGenerateVideo: '开始生成视频',
|
||||
generateVideoWithUpload: '使用上传图片生成视频',
|
||||
generateStoryboardWithImage: '使用图片生成分镜图',
|
||||
startGenerateStoryboard: '开始生成分镜图',
|
||||
startGenerate: '开始生成',
|
||||
enterDescription: '请输入描述文字',
|
||||
enterDescriptionForImage: '请输入描述文字,AI将根据参考图和描述生成分镜图',
|
||||
startingGenerate: '开始生成分镜图...',
|
||||
taskCreated: '分镜图任务创建成功!',
|
||||
createTaskFailed: '创建任务失败',
|
||||
@@ -381,13 +394,22 @@ export default {
|
||||
taskCompleted: '任务已完成!',
|
||||
resumingVideoTask: '检测到未完成的视频生成任务,继续处理中...',
|
||||
resumingStoryboardTask: '检测到未完成的分镜图生成任务,继续处理中...',
|
||||
resumingTask: '检测到未完成的任务,继续处理中...'
|
||||
resumingTask: '检测到未完成的任务,继续处理中...',
|
||||
storyboardReady: '分镜图已生成,点击下方按钮生成视频'
|
||||
}
|
||||
},
|
||||
|
||||
works: {
|
||||
title: '我的作品',
|
||||
all: '全部',
|
||||
videoDetail: '视频详情',
|
||||
description: '描述',
|
||||
createSimilar: '做同款',
|
||||
deleteFailedWork: '删除作品',
|
||||
retry: '重试',
|
||||
videoLoadFailed: '视频加载失败',
|
||||
videoFileNotExist: '视频文件不存在或已删除',
|
||||
referenceImagePrompt: '基于参考图片生成',
|
||||
textToVideo: '文生视频',
|
||||
imageToVideo: '图生视频',
|
||||
storyboardVideo: '分镜视频',
|
||||
@@ -725,6 +747,12 @@ export default {
|
||||
failedTasksLogged: '失败任务将记录到清理日志',
|
||||
originalTasksDeleted: '原始任务记录将被删除',
|
||||
irreversibleWarning: '此操作不可撤销,请谨慎操作!',
|
||||
confirmCleanup: '确认清理'
|
||||
confirmCleanup: '确认清理',
|
||||
aiModel: 'AI模型设置',
|
||||
promptOptimization: '提示词优化设置',
|
||||
promptOptimizationApiUrl: 'API端点',
|
||||
promptOptimizationApiUrlTip: '输入用于优化提示词的API端点地址,如 https://api.openai.com',
|
||||
promptOptimizationModel: '模型名称',
|
||||
promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const routes = [
|
||||
meta: { title: '任务状态', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/video/:id',
|
||||
path: '/video/:id',
|
||||
name: 'VideoDetail',
|
||||
component: VideoDetail,
|
||||
meta: { title: '视频详情', requiresAuth: true }
|
||||
|
||||
@@ -25,15 +25,17 @@
|
||||
<div class="main-content">
|
||||
<!-- 左侧设置面板 -->
|
||||
<div class="left-panel">
|
||||
<!-- 创作模式标签 -->
|
||||
<div class="creation-tabs">
|
||||
<div class="tab" @click="goToTextToVideo">{{ t('home.textToVideo') }}</div>
|
||||
<div class="tab active">{{ t('home.imageToVideo') }}</div>
|
||||
<div class="tab" @click="goToStoryboard">{{ t('home.storyboardVideo') }}</div>
|
||||
</div>
|
||||
<!-- 左侧可滚动内容区域 -->
|
||||
<div class="left-panel-content">
|
||||
<!-- 创作模式标签 -->
|
||||
<div class="creation-tabs">
|
||||
<div class="tab" @click="goToTextToVideo">{{ t('home.textToVideo') }}</div>
|
||||
<div class="tab active">{{ t('home.imageToVideo') }}</div>
|
||||
<div class="tab" @click="goToStoryboard">{{ t('home.storyboardVideo') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片输入区域 -->
|
||||
<div class="image-input-section">
|
||||
<!-- 图片输入区域 -->
|
||||
<div class="image-input-section">
|
||||
<div class="image-upload-area">
|
||||
<div class="upload-box" @click="uploadFirstFrame">
|
||||
<div v-if="!firstFrameImage" class="upload-placeholder">
|
||||
@@ -80,7 +82,6 @@
|
||||
<select v-model="duration" class="setting-select">
|
||||
<option value="10">10s</option>
|
||||
<option value="15">15s</option>
|
||||
<option value="25">25s</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -107,6 +108,7 @@
|
||||
<button class="login-link-btn" @click="goToLogin">{{ t('video.imageToVideo.loginNow') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧预览区域 -->
|
||||
@@ -159,20 +161,6 @@
|
||||
<div v-else class="no-video-placeholder">
|
||||
<div class="no-video-text">{{ t('video.imageToVideo.noVideoUrl') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 水印选择覆盖层 -->
|
||||
<div class="watermark-overlay">
|
||||
<div class="watermark-options">
|
||||
<div class="watermark-option">
|
||||
<input type="radio" id="withWatermark" name="watermark" value="with" v-model="watermarkOption">
|
||||
<label for="withWatermark">{{ t('video.imageToVideo.withWatermark') }}</label>
|
||||
</div>
|
||||
<div class="watermark-option">
|
||||
<input type="radio" id="withoutWatermark" name="watermark" value="without" v-model="watermarkOption">
|
||||
<label for="withoutWatermark">{{ t('video.imageToVideo.withoutWatermark') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,7 +305,7 @@
|
||||
import { ref, reactive, onUnmounted, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox, 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'
|
||||
@@ -351,7 +339,6 @@ const taskProgress = ref(0)
|
||||
const taskStatus = ref('')
|
||||
const stopPolling = ref(null)
|
||||
const showInProgress = ref(false)
|
||||
const watermarkOption = ref('without')
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
const historyTasks = ref([]) // 历史记录
|
||||
const playingVideos = ref({}) // 正在播放的视频
|
||||
@@ -399,6 +386,11 @@ const goToStoryboard = () => {
|
||||
|
||||
// 用户菜单相关方法
|
||||
const toggleUserMenu = () => {
|
||||
// 未登录时跳转到登录页面
|
||||
if (!isAuthenticated.value) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
showUserMenu.value = !showUserMenu.value
|
||||
}
|
||||
|
||||
@@ -655,11 +647,10 @@ const startPollingTask = () => {
|
||||
// 可以在这里跳转到结果页面或显示结果
|
||||
console.log('任务完成:', taskData)
|
||||
},
|
||||
// 错误回调
|
||||
// 错误回调 - 静默处理,只在页面上显示失败状态,不弹窗
|
||||
(error) => {
|
||||
inProgress.value = false
|
||||
taskStatus.value = 'FAILED'
|
||||
ElMessage.error(t('video.imageToVideo.videoGenerateFailed') + error.message)
|
||||
console.error('任务失败:', error)
|
||||
}
|
||||
)
|
||||
@@ -821,25 +812,47 @@ const retryTask = () => {
|
||||
// 投稿功能(已移除按钮,保留函数已删除)
|
||||
|
||||
// 删除作品
|
||||
const deleteWork = () => {
|
||||
const deleteWork = async () => {
|
||||
if (!currentTask.value) {
|
||||
ElMessage.error(t('video.imageToVideo.noWorkToDelete'))
|
||||
return
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
ElMessage.confirm(t('video.imageToVideo.confirmDeleteWork'), t('video.imageToVideo.confirmDelete'), {
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
// 这里可以调用删除API
|
||||
currentTask.value = null
|
||||
taskStatus.value = ''
|
||||
ElMessage.success(t('video.imageToVideo.workDeleted'))
|
||||
}).catch(() => {
|
||||
ElMessage.info(t('video.imageToVideo.deleteCancelled'))
|
||||
})
|
||||
try {
|
||||
// 确认删除
|
||||
await ElMessageBox.confirm(
|
||||
t('video.imageToVideo.confirmDeleteWork'),
|
||||
t('video.imageToVideo.confirmDelete'),
|
||||
{
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
// 调用删除 API
|
||||
const taskId = currentTask.value.taskId
|
||||
const response = await imageToVideoApi.deleteTask(taskId)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
// 重置状态
|
||||
currentTask.value = null
|
||||
taskStatus.value = ''
|
||||
inProgress.value = false
|
||||
taskProgress.value = 0
|
||||
ElMessage.success(t('video.imageToVideo.workDeleted'))
|
||||
|
||||
// 刷新历史记录
|
||||
loadHistory()
|
||||
} else {
|
||||
throw new Error(response.data?.message || t('video.imageToVideo.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
console.error('删除作品失败:', error)
|
||||
ElMessage.error(error.message || t('video.imageToVideo.deleteFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理历史记录URL
|
||||
@@ -998,19 +1011,19 @@ watch(() => userStore.isAuthenticated, (isAuth) => {
|
||||
// 恢复正在进行中的任务
|
||||
const restoreProcessingTask = async () => {
|
||||
if (!userStore.isAuthenticated) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果正在创建任务,跳过恢复逻辑
|
||||
if (isCreatingTask.value) {
|
||||
console.log('[Task Restore] 跳过恢复:正在创建新任务')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果已经恢复过任务且当前有任务在进行中,跳过
|
||||
if (hasRestoredTask.value && currentTask.value) {
|
||||
console.log('[Task Restore] 跳过恢复:已有任务在进行中')
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1044,30 +1057,77 @@ const restoreProcessingTask = async () => {
|
||||
if (work.duration) {
|
||||
duration.value = work.duration || '10'
|
||||
}
|
||||
|
||||
inProgress.value = true
|
||||
taskStatus.value = work.status || 'PROCESSING'
|
||||
taskProgress.value = 50 // 初始进度设为50%
|
||||
|
||||
console.log('恢复正在进行中的任务:', work.taskId, '状态:', work.status)
|
||||
ElMessage.info(t('video.imageToVideo.resumingTask'))
|
||||
|
||||
// 标记已恢复任务
|
||||
hasRestoredTask.value = true
|
||||
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
|
||||
if (work.thumbnailUrl) {
|
||||
firstFrameImage.value = processHistoryUrl(work.thumbnailUrl)
|
||||
console.log('[Task Restore] 已恢复首帧图片:', work.thumbnailUrl)
|
||||
}
|
||||
|
||||
// 开始轮询任务状态
|
||||
startPollingTask()
|
||||
// 只有真正在进行中的任务才恢复和显示消息
|
||||
const workStatus = work.status || 'PROCESSING'
|
||||
if (workStatus === 'PROCESSING' || workStatus === 'PENDING') {
|
||||
inProgress.value = true
|
||||
taskStatus.value = workStatus
|
||||
taskProgress.value = 50 // 初始进度设为50%
|
||||
|
||||
console.log('[Task Restored]', work.taskId, 'Status:', workStatus)
|
||||
// 静默恢复,不显示弹窗
|
||||
|
||||
// 标记已恢复任务
|
||||
hasRestoredTask.value = true
|
||||
|
||||
// 开始轮询任务状态
|
||||
startPollingTask()
|
||||
return true
|
||||
} else {
|
||||
// 如果任务已失败或取消,不显示恢复消息,让 checkLastTaskStatus 处理
|
||||
console.log('[Task Skip]', work.taskId, 'Status:', workStatus, '- 不是进行中状态')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复任务失败:', error)
|
||||
console.error('[Task Restore Error]', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查最近一条任务的状态(如果失败则显示失败状态,但不恢复输入参数)
|
||||
const checkLastTaskStatus = async () => {
|
||||
if (!userStore.isAuthenticated) return
|
||||
|
||||
try {
|
||||
const response = await imageToVideoApi.getTasks(0, 1)
|
||||
if (response.data && response.data.success && response.data.data && response.data.data.length > 0) {
|
||||
const lastTask = response.data.data[0]
|
||||
|
||||
// 只关注 FAILED 状态,显示失败UI但不恢复输入参数
|
||||
if (lastTask.status === 'FAILED') {
|
||||
console.log('[Last Task Failed]', lastTask)
|
||||
|
||||
currentTask.value = lastTask
|
||||
taskStatus.value = 'FAILED'
|
||||
// 不恢复输入参数,让用户可以自由创建新任务
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check last task status error', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载历史记录
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadHistory()
|
||||
restoreProcessingTask()
|
||||
// 延迟恢复任务,避免与创建任务冲突
|
||||
setTimeout(async () => {
|
||||
if (!isCreatingTask.value) {
|
||||
const restored = await restoreProcessingTask()
|
||||
// 如果没有恢复进行中的任务,则检查最近一条是否失败
|
||||
if (!restored) {
|
||||
checkLastTaskStatus()
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
@@ -1244,7 +1304,36 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧面板内容区域(可滚动) */
|
||||
.left-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* 优化滚动条样式 */
|
||||
.left-panel-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.left-panel-content::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.left-panel-content::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.left-panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
/* 创作模式标签 */
|
||||
@@ -1289,11 +1378,14 @@ onUnmounted(() => {
|
||||
.image-upload-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
flex: 1;
|
||||
flex: none;
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: #0a0a0a;
|
||||
border: 2px dashed #2a2a2a;
|
||||
@@ -1641,13 +1733,13 @@ onUnmounted(() => {
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
min-height: 200px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 40px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
@@ -1799,7 +1891,8 @@ onUnmounted(() => {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
min-height: 300px;
|
||||
min-height: 200px;
|
||||
max-height: 50vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1922,43 +2015,6 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 水印选择覆盖层 */
|
||||
.watermark-overlay {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.watermark-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watermark-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watermark-option input[type="radio"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
.watermark-option label {
|
||||
font-size: 13px;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 操作按钮区域 */
|
||||
.result-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<svg width="248" height="59" viewBox="0 0 248 59" fill="none" xmlns="http://www.w3.org/2000/svg" class="tabs-svg">
|
||||
<!-- 邮箱登录 -->
|
||||
<g class="tab-email" :class="{ active: loginType === 'email' }" @click="loginType = 'email'" style="cursor: pointer;">
|
||||
<!-- 透明点击区域 -->
|
||||
<rect x="0" y="10" width="120" height="40" fill="transparent"/>
|
||||
<path d="M13.598 21.112V40.638H11.076V39.13H4.316V40.638H1.768V21.112H6.344V17.55H8.996V21.112H13.598ZM4.316 36.712H6.344V31.122H4.316V36.712ZM8.996 36.712H11.076V31.122H8.996V36.712ZM4.316 28.73H6.344V23.556H4.316V28.73ZM8.996 23.556V28.73H11.076V23.556H8.996ZM15.34 18.772H24.232V20.748C23.452 23.4 22.62 25.818 21.736 28.054C23.556 30.654 24.466 32.76 24.492 34.398C24.492 35.958 24.154 37.024 23.504 37.596C22.802 38.22 21.398 38.532 19.318 38.532L18.512 35.802C19.474 35.906 20.28 35.984 20.956 35.984C21.372 35.958 21.658 35.828 21.814 35.62C21.918 35.464 21.97 35.048 21.996 34.398C21.97 32.786 20.982 30.68 19.032 28.054C19.864 26.156 20.67 23.868 21.45 21.164H17.914V41.73H15.34V18.772ZM27.716 27.326H31.616V24.83H34.346V27.326H37.57V29.926H34.346V30.446C35.542 31.538 36.79 32.734 38.038 34.06L36.53 36.348C35.698 35.048 34.97 33.93 34.346 33.046V41.756H31.616V33.202C30.654 35.23 29.432 37.102 27.924 38.818L26.754 35.776C28.756 34.06 30.238 32.11 31.226 29.926H27.716V27.326ZM49.4 25.48V41.73H46.774V40.82H41.106V41.73H38.48V25.48H49.4ZM41.106 38.428H46.774V36.244H41.106V38.428ZM41.106 33.956H46.774V32.058H41.106V33.956ZM41.106 29.77H46.774V27.872H41.106V29.77ZM31.538 21.762C30.966 22.75 30.316 23.634 29.614 24.466L27.248 22.958C28.782 21.294 29.874 19.5 30.498 17.576L33.124 18.148C32.968 18.564 32.838 18.98 32.682 19.37H38.974V21.762H35.568C36.088 22.49 36.504 23.192 36.842 23.842L34.346 24.778C33.878 23.738 33.306 22.724 32.682 21.762H31.538ZM42.64 21.762C42.12 22.828 41.574 23.816 40.95 24.726L38.636 23.244C39.962 21.424 40.898 19.474 41.444 17.446L44.018 18.018C43.862 18.486 43.732 18.928 43.602 19.37H50.83V21.762H46.67C47.19 22.49 47.632 23.192 47.97 23.842L45.578 24.752C45.11 23.712 44.538 22.724 43.862 21.762H42.64ZM57.538 28.522H72.566V35.282H57.538V28.522ZM69.836 32.89V30.888H60.268V32.89H69.836ZM60.45 35.438C61.282 36.296 62.036 37.31 62.712 38.454H67.626C68.354 37.466 68.978 36.426 69.524 35.36L72.046 36.27C71.578 37.05 71.084 37.778 70.564 38.454H76.232V41.028H53.716V38.454H59.826C59.28 37.726 58.63 37.05 57.902 36.4L60.45 35.438ZM56.368 21.112C57.564 21.996 58.63 22.854 59.514 23.686C60.424 22.802 61.152 21.866 61.724 20.878H55.822V18.382H64.792V20.41C64.194 21.918 63.388 23.27 62.374 24.466H68.822C67.262 22.75 66.014 20.904 65.104 18.876L67.366 17.628C67.782 18.59 68.276 19.5 68.848 20.358C69.758 19.63 70.538 18.85 71.188 18.018L73.086 19.708C72.306 20.644 71.37 21.528 70.304 22.308C70.72 22.828 71.188 23.296 71.708 23.764C72.8 22.932 73.71 21.996 74.49 20.982L76.388 22.646C75.608 23.634 74.672 24.544 73.632 25.35C74.776 26.182 76.024 26.962 77.428 27.664L75.634 29.744C73.606 28.6 71.838 27.352 70.33 25.974V26.936H60.788V26.104C59.124 27.56 57.07 28.782 54.626 29.796L52.962 27.664C54.782 26.962 56.316 26.156 57.616 25.246C56.758 24.466 55.744 23.66 54.548 22.828L56.368 21.112ZM82.368 18.408H97.864V26.806H102.258V29.276H98.228L100.282 30.966C98.93 32.63 97.396 33.956 95.68 34.892C97.604 36.296 99.84 37.518 102.388 38.61L101.01 41.002C97.37 39.286 94.432 37.232 92.196 34.814V38.974C92.196 40.794 91.39 41.73 89.804 41.73H86.762L86.164 39.182C87.1 39.286 88.01 39.364 88.894 39.364C89.284 39.364 89.492 39 89.492 38.324V34.918C86.944 37.154 83.928 39.156 80.47 40.95L79.378 38.428C83.278 36.66 86.632 34.58 89.492 32.136V29.276H79.768V26.806H95.108V25.012H83.252V22.672H95.108V20.852H82.368V18.408ZM83.018 29.666C84.526 30.706 85.8 31.746 86.84 32.786L85.072 34.554C84.162 33.566 82.888 32.526 81.224 31.382L83.018 29.666ZM98.176 29.276H92.196V31.824C92.69 32.37 93.236 32.89 93.834 33.41C95.498 32.422 96.954 31.044 98.176 29.276Z" />
|
||||
</g>
|
||||
|
||||
@@ -26,6 +28,8 @@
|
||||
|
||||
<!-- 密码登录 -->
|
||||
<g class="tab-password" :class="{ active: loginType === 'password' }" @click="loginType = 'password'" style="cursor: pointer;">
|
||||
<!-- 透明点击区域 -->
|
||||
<rect x="128" y="10" width="120" height="40" fill="transparent"/>
|
||||
<path d="M155.362 18.46V35.126H152.996V20.956H148.212V35.126H145.768V18.46H155.362ZM149.434 22.49H151.722V31.018C151.644 33.93 151.202 36.27 150.37 38.012C149.564 39.65 148.264 40.898 146.47 41.782L145.014 39.494C146.704 38.636 147.848 37.57 148.472 36.322C149.044 34.944 149.356 33.176 149.434 31.018V22.49ZM152.684 35.724C153.984 37.05 155.076 38.35 155.986 39.624L154.036 41.574C153.308 40.274 152.294 38.896 150.942 37.414L152.684 35.724ZM162.044 30.732H160.328V38.298C161.342 37.882 162.33 37.362 163.318 36.738L163.786 39.156C162.122 40.196 160.224 41.028 158.092 41.704L156.974 39.286C157.442 39.052 157.676 38.688 157.676 38.22V30.732H156.064V28.132H157.676V17.68H160.328V28.132H168.622V30.732H164.384C165.528 34.45 167.14 37.336 169.168 39.364L167.322 41.47C165.008 39.026 163.24 35.438 162.044 30.732ZM166.23 18.746L168.31 20.41C166.516 23.322 164.436 25.506 162.044 26.91L160.588 24.83C162.772 23.478 164.67 21.45 166.23 18.746ZM174.966 18.356H191.034V26.026H174.966V18.356ZM188.278 23.608V20.8H177.722V23.608H188.278ZM176.76 30.16H171.118V27.534H194.856V30.16H179.516L178.814 32.422H191.372C191.164 36.894 190.748 39.546 190.072 40.378C189.396 41.184 188.122 41.6 186.198 41.6C184.924 41.6 183.78 41.522 182.766 41.392L181.882 38.922C183.286 39.052 184.508 39.13 185.6 39.13C186.874 39.13 187.654 38.87 187.966 38.402C188.252 37.908 188.46 36.738 188.59 34.866H175.564L176.76 30.16ZM201.538 28.522H216.566V35.282H201.538V28.522ZM213.836 32.89V30.888H204.268V32.89H213.836ZM204.45 35.438C205.282 36.296 206.036 37.31 206.712 38.454H211.626C212.354 37.466 212.978 36.426 213.524 35.36L216.046 36.27C215.578 37.05 215.084 37.778 214.564 38.454H220.232V41.028H197.716V38.454H203.826C203.28 37.726 202.63 37.05 201.902 36.4L204.45 35.438ZM200.368 21.112C201.564 21.996 202.63 22.854 203.514 23.686C204.424 22.802 205.152 21.866 205.724 20.878H199.822V18.382H208.792V20.41C208.194 21.918 207.388 23.27 206.374 24.466H212.822C211.262 22.75 210.014 20.904 209.104 18.876L211.366 17.628C211.782 18.59 212.276 19.5 212.848 20.358C213.758 19.63 214.538 18.85 215.188 18.018L217.086 19.708C216.306 20.644 215.37 21.528 214.304 22.308C214.72 22.828 215.188 23.296 215.708 23.764C216.8 22.932 217.71 21.996 218.49 20.982L220.388 22.646C219.608 23.634 218.672 24.544 217.632 25.35C218.776 26.182 220.024 26.962 221.428 27.664L219.634 29.744C217.606 28.6 215.838 27.352 214.33 25.974V26.936H204.788V26.104C203.124 27.56 201.07 28.782 198.626 29.796L196.962 27.664C198.782 26.962 200.316 26.156 201.616 25.246C200.758 24.466 199.744 23.66 198.548 22.828L200.368 21.112ZM226.368 18.408H241.864V26.806H246.258V29.276H242.228L244.282 30.966C242.93 32.63 241.396 33.956 239.68 34.892C241.604 36.296 243.84 37.518 246.388 38.61L245.01 41.002C241.37 39.286 238.432 37.232 236.196 34.814V38.974C236.196 40.794 235.39 41.73 233.804 41.73H230.762L230.164 39.182C231.1 39.286 232.01 39.364 232.894 39.364C233.284 39.364 233.492 39 233.492 38.324V34.918C230.944 37.154 227.928 39.156 224.47 40.95L223.378 38.428C227.278 36.66 230.632 34.58 233.492 32.136V29.276H223.768V26.806H239.108V25.012H227.252V22.672H239.108V20.852H226.368V18.408ZM227.018 29.666C228.526 30.706 229.8 31.746 230.84 32.786L229.072 34.554C228.162 33.566 226.888 32.526 225.224 31.382L227.018 29.666ZM242.176 29.276H236.196V31.824C236.69 32.37 237.236 32.89 237.834 33.41C239.498 32.422 240.954 31.044 242.176 29.276Z" />
|
||||
</g>
|
||||
</svg>
|
||||
@@ -56,6 +60,7 @@
|
||||
placeholder="请输入验证码"
|
||||
class="code-input"
|
||||
@keyup.enter="handleLogin"
|
||||
@input="filterCodeSpaces"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
@@ -84,7 +89,7 @@
|
||||
:loading="userStore.loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ userStore.loading ? '登录中...' : (loginType === 'password' ? '登录' : '登陆/注册') }}
|
||||
{{ userStore.loading ? '登录中...' : '登录/注册' }}
|
||||
</el-button>
|
||||
|
||||
<!-- 协议文字 -->
|
||||
@@ -159,6 +164,10 @@ const clearForm = async () => {
|
||||
emailInput.value && emailInput.value.focus && emailInput.value.focus()
|
||||
}
|
||||
|
||||
// 过滤验证码中的空格
|
||||
const filterCodeSpaces = () => {
|
||||
loginForm.code = loginForm.code.replace(/\s/g, '')
|
||||
}
|
||||
|
||||
// 组件挂载时从URL参数读取邮箱
|
||||
onMounted(() => {
|
||||
@@ -284,32 +293,22 @@ const handleLogin = async () => {
|
||||
// 保存用户信息和token
|
||||
const loginUser = response.data.data.user
|
||||
const loginToken = response.data.data.token
|
||||
const needsPasswordChange = response.data.data.needsPasswordChange // 后端直接返回是否需要修改密码
|
||||
|
||||
sessionStorage.setItem('token', loginToken)
|
||||
sessionStorage.setItem('user', JSON.stringify(loginUser))
|
||||
userStore.user = loginUser
|
||||
userStore.token = loginToken
|
||||
|
||||
// 默认不要求设置密码
|
||||
sessionStorage.removeItem('needSetPassword')
|
||||
|
||||
// 额外调用 /auth/me 获取真实的密码状态(避免后端出于安全清空 passwordHash)
|
||||
try {
|
||||
const meResp = await getCurrentUser()
|
||||
const meData = meResp.data
|
||||
if (meData && meData.success && meData.data) {
|
||||
const userFromMe = meData.data
|
||||
const pwd = userFromMe.passwordHash
|
||||
if (!pwd || String(pwd).trim() === '') {
|
||||
// 当前用户还没有设置密码,标记为需要设置密码
|
||||
sessionStorage.setItem('needSetPassword', '1')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 获取用户信息失败时忽略,正常继续登录流程
|
||||
console.warn('获取当前用户信息用于检测密码状态失败:', e)
|
||||
// 根据后端返回的标记设置是否需要修改密码
|
||||
if (needsPasswordChange) {
|
||||
sessionStorage.setItem('needSetPassword', '1')
|
||||
console.log('新用户首次登录,需要设置密码')
|
||||
} else {
|
||||
sessionStorage.removeItem('needSetPassword')
|
||||
}
|
||||
|
||||
console.log('登录成功,用户信息:', userStore.user)
|
||||
console.log('登录成功,用户信息:', userStore.user, '需要设置密码:', needsPasswordChange)
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 等待一下确保状态更新
|
||||
@@ -341,12 +340,14 @@ const handleLogin = async () => {
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: url('/images/backgrounds/login-bg.svg') center/cover no-repeat;
|
||||
background: #0a0e1a url('/images/backgrounds/login-bg.svg') center/cover no-repeat;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
|
||||
@@ -190,12 +190,7 @@
|
||||
<el-button circle size="small" text><el-icon><MoreFilled /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="download_with_watermark">{{ t('works.downloadWithWatermark') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="download_without_watermark">
|
||||
{{ t('works.downloadWithoutWatermark') }}
|
||||
<el-tag type="primary" size="small" style="margin-left: 8px;">{{ t('works.memberOnly') }}</el-tag>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="rename" divided>{{ t('works.rename') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="rename">{{ t('works.rename') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="delete">{{ t('common.delete') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -232,16 +227,23 @@
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
:title="selectedItem?.title"
|
||||
width="60%"
|
||||
width="70vw"
|
||||
:before-close="handleClose"
|
||||
class="detail-dialog"
|
||||
class="work-detail-dialog"
|
||||
:modal="true"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
align-center
|
||||
>
|
||||
<div class="detail-content" v-if="selectedItem">
|
||||
<div class="detail-left">
|
||||
<div class="video-container">
|
||||
<!-- 自定义关闭按钮 -->
|
||||
<div class="dialog-close-btn" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
</div>
|
||||
|
||||
<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 class="error-content">
|
||||
@@ -280,7 +282,15 @@
|
||||
:alt="selectedItem.title"
|
||||
/>
|
||||
|
||||
<!-- 视频文字叠加 已移除(用户要求) -->
|
||||
<!-- 悬浮操作按钮 -->
|
||||
<div class="overlay-actions">
|
||||
<button class="icon-btn" @click="downloadWork" :title="t('common.download')">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button class="icon-btn delete" @click="deleteFailedWork" :title="t('common.delete')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,42 +305,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<div class="tabs">
|
||||
<div class="tab" :class="{ active: activeDetailTab === 'detail' }" @click="activeDetailTab = 'detail'">{{ t('profile.workDetail') }}</div>
|
||||
<div class="tab" :class="{ active: activeDetailTab === 'category' }" @click="activeDetailTab = 'category'">{{ selectedItem.category }}</div>
|
||||
<!-- 详情标题行 -->
|
||||
<div class="detail-title-row">
|
||||
<h3>{{ t('works.videoDetail') }}</h3>
|
||||
<span class="category-badge">{{ selectedItem.category }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 提示词区域 -->
|
||||
<div class="description-section" v-if="activeDetailTab === 'detail'">
|
||||
<h3 class="section-title">{{ t('video.prompt') }}</h3>
|
||||
<p class="description-text">{{ getDescription(selectedItem) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 参考图特殊内容 -->
|
||||
<div class="reference-content" v-if="activeDetailTab === 'category' && selectedItem.category === '参考图'">
|
||||
<div class="input-details-section">
|
||||
<h3 class="section-title">{{ t('profile.inputDetails') }}</h3>
|
||||
<div class="input-images">
|
||||
<div class="input-image-item">
|
||||
<img :src="selectedItem.cover" :alt="selectedItem.title" class="input-thumbnail" />
|
||||
</div>
|
||||
<div class="input-image-item">
|
||||
<img :src="selectedItem.cover" :alt="selectedItem.title" class="input-thumbnail" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 描述区域 -->
|
||||
<div class="description-section">
|
||||
<div class="section-header">
|
||||
<span class="section-label">{{ t('works.description') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="description-section">
|
||||
<h3 class="section-title">{{ t('video.prompt') }}</h3>
|
||||
<p class="description-text">{{ t('works.referenceImagePrompt') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他分类的内容 -->
|
||||
<div class="description-section" v-if="activeDetailTab === 'category' && selectedItem.category !== '参考图'">
|
||||
<h3 class="section-title">{{ t('video.prompt') }}</h3>
|
||||
<p class="description-text">{{ getDescription(selectedItem) }}</p>
|
||||
<p class="description-text">
|
||||
{{ getDescription(selectedItem) }}
|
||||
<el-icon class="copy-icon" @click="copyPrompt" :title="t('common.copy')"><CopyDocument /></el-icon>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 元数据区域 -->
|
||||
@@ -341,34 +330,26 @@
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">{{ t('profile.workId') }}</span>
|
||||
<span class="value">{{ selectedItem.id }}</span>
|
||||
<span class="value">{{ selectedItem.taskId }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">{{ t('profile.date') }}</span>
|
||||
<span class="value">{{ selectedItem.date }}</span>
|
||||
</div>
|
||||
<div class="metadata-item" v-if="selectedItem.type === 'video'">
|
||||
<span class="label">{{ t('profile.duration') }}</span>
|
||||
<span class="value">{{ formatDuration(selectedItem.duration) || t('profile.unknown') }}</span>
|
||||
</div>
|
||||
<div class="metadata-item" v-if="selectedItem.type === 'video'">
|
||||
<span class="label">{{ t('profile.quality') }}</span>
|
||||
<span class="value">{{ selectedItem.quality || t('profile.unknown') }}</span>
|
||||
<span class="value">{{ formatDuration(selectedItem.duration) || '5s' }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">{{ t('profile.category') }}</span>
|
||||
<span class="value">{{ selectedItem.category }}</span>
|
||||
<span class="label">{{ t('profile.quality') }}</span>
|
||||
<span class="value">{{ selectedItem.quality || '1080p' }}</span>
|
||||
</div>
|
||||
<div class="metadata-item" v-if="selectedItem.type === 'video'">
|
||||
<div class="metadata-item">
|
||||
<span class="label">{{ t('profile.aspectRatio') }}</span>
|
||||
<span class="value">{{ selectedItem.aspectRatio || t('profile.unknown') }}</span>
|
||||
<span class="value">{{ selectedItem.aspectRatio || '16:9' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="action-section">
|
||||
<button class="create-similar-btn" @click="createSimilar">
|
||||
{{ t('profile.createSimilar') }}
|
||||
<button class="create-similar-btn full-width" @click="createSimilar(selectedItem)">
|
||||
{{ t('works.createSimilar') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,7 +409,7 @@
|
||||
import { ref, onMounted, onActivated, computed, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Star, User, Compass, Document, VideoPlay, Picture, Film, Search, MoreFilled, Loading, ArrowUp, VideoCamera, Refresh, Delete } from '@element-plus/icons-vue'
|
||||
import { Star, User, Compass, Document, VideoPlay, Picture, Film, Search, MoreFilled, Loading, ArrowUp, VideoCamera, Refresh, Delete, CopyDocument, Download, Close } from '@element-plus/icons-vue'
|
||||
import { getMyWorks, getWorkDetail, deleteWork, recordDownload, getWorkFileUrl } from '@/api/userWorks'
|
||||
import { getCurrentUser } from '@/api/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -485,6 +466,13 @@ const activeDetailTab = ref('detail')
|
||||
const detailVideoError = ref(false)
|
||||
const detailVideoRef = ref(null)
|
||||
|
||||
// 判断是否是竖版视频(9:16)
|
||||
const isVerticalVideo = computed(() => {
|
||||
if (!selectedItem.value) return false
|
||||
const ratio = selectedItem.value.aspectRatio
|
||||
return ratio === '9:16' || ratio === '9/16' || ratio === '3:4' || ratio === '4:5'
|
||||
})
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
@@ -496,6 +484,10 @@ const failedUrls = ref(new Set()) // 记录加载失败的URL
|
||||
// 处理URL,确保相对路径正确
|
||||
const processUrl = (url) => {
|
||||
if (!url) return null
|
||||
// data: 协议(Base64 图片等)直接返回,避免被当成相对路径错误加前缀
|
||||
if (url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
// 如果已经是完整URL(http/https),直接返回
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url
|
||||
@@ -525,6 +517,7 @@ const transformWorkData = (work) => {
|
||||
|
||||
return {
|
||||
id: work.id?.toString() || work.taskId || '',
|
||||
taskId: work.taskId || work.id?.toString() || '',
|
||||
title: work.title || work.prompt || '未命名作品',
|
||||
cover: cover,
|
||||
resultUrl: resultUrl || '',
|
||||
@@ -1061,10 +1054,7 @@ const download = async (item) => {
|
||||
}
|
||||
|
||||
const moreCommand = async (cmd, item) => {
|
||||
if (cmd === 'download_with_watermark' || cmd === 'download_without_watermark') {
|
||||
// 两种下载模式都调用同一个下载函数(暂时不区分水印)
|
||||
await download(item)
|
||||
} else if (cmd === 'rename') {
|
||||
if (cmd === 'rename') {
|
||||
ElMessage.info(t('works.renameDevMsg'))
|
||||
} else if (cmd === 'delete') {
|
||||
try {
|
||||
@@ -1191,6 +1181,27 @@ const onVideoLoaded = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制提示词
|
||||
const copyPrompt = async () => {
|
||||
const prompt = getDescription(selectedItem.value)
|
||||
if (!prompt || prompt === t('profile.noPrompt')) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt)
|
||||
ElMessage.success(t('common.copySuccess'))
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
ElMessage.error(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 下载当前作品
|
||||
const downloadWork = () => {
|
||||
if (selectedItem.value) {
|
||||
download(selectedItem.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 视频加载失败处理
|
||||
const onVideoError = (event) => {
|
||||
const video = event.target
|
||||
@@ -1918,153 +1929,149 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
:deep(.detail-dialog .el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
|
||||
:deep(.work-detail-dialog) {
|
||||
--el-dialog-margin-top: 5vh;
|
||||
--el-dialog-bg-color: transparent;
|
||||
--el-dialog-border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__header) {
|
||||
background: #0a0a0a !important;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 16px 20px;
|
||||
:deep(.work-detail-dialog .el-dialog) {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
margin: 0 auto;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__title) {
|
||||
color: #fff !important;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
:deep(.work-detail-dialog .el-dialog__header) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__headerbtn) {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__body) {
|
||||
background: #0a0a0a !important;
|
||||
:deep(.work-detail-dialog .el-dialog__body) {
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
/* 遮罩层样式 */
|
||||
:deep(.el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.85) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 强制覆盖Element Plus默认样式 */
|
||||
:deep(.el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
/* 自定义关闭按钮 */
|
||||
.dialog-close-btn {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
.dialog-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
height: 50vh;
|
||||
background: #0a0a0a;
|
||||
height: 80vh;
|
||||
max-height: 800px;
|
||||
min-height: 500px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8);
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
flex: 2;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.video-error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #f56c6c;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-content h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error-content p {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-video, .detail-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* overlay 样式已移除(不再使用) */
|
||||
|
||||
.detail-right {
|
||||
flex: 1;
|
||||
background: #0a0a0a;
|
||||
padding: 16px;
|
||||
width: 280px;
|
||||
flex: none;
|
||||
background: #1a1a1a;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid #333;
|
||||
}
|
||||
|
||||
/* 竖版视频(9:16)特殊布局 */
|
||||
.vertical-content {
|
||||
height: 85vh;
|
||||
max-height: 900px;
|
||||
}
|
||||
|
||||
.vertical-left {
|
||||
flex: none;
|
||||
width: 45%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.vertical-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vertical-content .detail-right {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
@@ -2074,113 +2081,88 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
.detail-title-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #409eff;
|
||||
.detail-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
.category-badge {
|
||||
padding: 3px 8px;
|
||||
background: rgba(64, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #d1d5db;
|
||||
color: #ccc;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 参考图特殊内容样式 */
|
||||
.reference-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.copy-icon {
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.input-details-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-images {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-image-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-thumbnail {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
.copy-icon:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
.metadata-item .label {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
.metadata-item .value {
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
@@ -2189,46 +2171,50 @@ onActivated(() => {
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.create-similar-btn:hover {
|
||||
background: #337ecc;
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
/* 悬浮操作按钮 */
|
||||
.overlay-actions {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.create-similar-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 更强制性的样式覆盖 */
|
||||
:deep(.detail-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-overlay-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 全局模态框样式覆盖 */
|
||||
:deep(.el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
/* 回到顶部按钮样式 */
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
|
||||
@@ -85,9 +85,19 @@
|
||||
<div class="video-item" v-for="(video, index) in videos" :key="video.id || index" v-loading="loading">
|
||||
<div class="video-thumbnail" @click="openDetail(video)">
|
||||
<div class="thumbnail-image">
|
||||
<!-- 失败状态显示 -->
|
||||
<div v-if="video.status === 'FAILED'" class="failed-overlay">
|
||||
<div class="failed-icon">❌</div>
|
||||
<div class="failed-text">生成失败</div>
|
||||
</div>
|
||||
<!-- 处理中状态显示 -->
|
||||
<div v-else-if="video.status === 'PROCESSING' || video.status === 'PENDING'" class="processing-overlay">
|
||||
<div class="processing-icon">⏳</div>
|
||||
<div class="processing-text">{{ video.status === 'PENDING' ? '排队中' : '生成中' }}</div>
|
||||
</div>
|
||||
<!-- 如果是视频类型且有视频URL,使用video元素显示首帧 -->
|
||||
<video
|
||||
v-if="video.type === 'video' && video.resultUrl"
|
||||
v-else-if="video.type === 'video' && video.resultUrl"
|
||||
:src="video.resultUrl"
|
||||
class="video-cover-img"
|
||||
muted
|
||||
@@ -105,8 +115,10 @@
|
||||
<div v-else class="figure"></div>
|
||||
</div>
|
||||
<div class="video-action">
|
||||
<el-button v-if="index === 0" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
|
||||
<span v-else class="director-text">DIRECTED BY VANNOCENT</span>
|
||||
<el-button v-if="index === 0 && video.status === 'COMPLETED'" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
|
||||
<span v-else-if="video.status === 'COMPLETED'" class="director-text">DIRECTED BY VANNOCENT</span>
|
||||
<span v-else-if="video.status === 'FAILED'" class="status-text failed">生成失败</span>
|
||||
<span v-else class="status-text processing">{{ video.status === 'PENDING' ? '排队中...' : '生成中...' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,37 +173,11 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab" :class="{ active: activeDetailTab === 'detail' }" @click="activeDetailTab = 'detail'">{{ t('profile.workDetail') }}</div>
|
||||
<div class="tab" :class="{ active: activeDetailTab === 'category' }" @click="activeDetailTab = 'category'">{{ selectedItem.category }}</div>
|
||||
<div class="tab active">{{ t('profile.workDetail') }}</div>
|
||||
<span class="category-label">{{ selectedItem.category }}</span>
|
||||
</div>
|
||||
|
||||
<div class="description-section" v-if="activeDetailTab === 'detail'">
|
||||
<h3 class="section-title">{{ t('video.prompt') }}</h3>
|
||||
<p class="description-text">{{ getDescription(selectedItem) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 参考图特殊内容 -->
|
||||
<div class="reference-content" v-if="activeDetailTab === 'category' && selectedItem.category === '参考图'">
|
||||
<div class="input-details-section">
|
||||
<h3 class="section-title">{{ t('profile.inputDetails') }}</h3>
|
||||
<div class="input-images">
|
||||
<div class="input-image-item">
|
||||
<img :src="selectedItem.cover" :alt="selectedItem.title" class="input-thumbnail" />
|
||||
</div>
|
||||
<div class="input-image-item">
|
||||
<img :src="selectedItem.cover" :alt="selectedItem.title" class="input-thumbnail" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description-section">
|
||||
<h3 class="section-title">{{ t('video.prompt') }}</h3>
|
||||
<p class="description-text">图1在图2中奔跑视频</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他分类的内容 -->
|
||||
<div class="description-section" v-if="activeDetailTab === 'category' && selectedItem.category !== '参考图'">
|
||||
<div class="description-section">
|
||||
<h3 class="section-title">{{ t('video.prompt') }}</h3>
|
||||
<p class="description-text">{{ getDescription(selectedItem) }}</p>
|
||||
</div>
|
||||
@@ -572,6 +558,10 @@ const createSimilar = (item) => {
|
||||
// 处理URL,确保相对路径正确
|
||||
const processUrl = (url) => {
|
||||
if (!url) return null
|
||||
// data: 协议(Base64 图片等)直接返回
|
||||
if (url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
// 如果已经是完整URL(http/https),直接返回
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url
|
||||
@@ -1165,6 +1155,78 @@ onUnmounted(() => {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 失败状态覆盖层 */
|
||||
.failed-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #3d1a1a 0%, #5a2020 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.failed-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.failed-text {
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 处理中状态覆盖层 */
|
||||
.processing-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #1a2d3d 0%, #203a5a 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.processing-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.processing-text {
|
||||
color: #4dabf7;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 状态文字 */
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-text.failed {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.status-text.processing {
|
||||
color: #4dabf7;
|
||||
}
|
||||
|
||||
.empty-works {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
@@ -1372,6 +1434,15 @@ onUnmounted(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>{{ $t('systemSettings.cleanup') }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'aiModel' }"
|
||||
@click="activeTab = 'aiModel'"
|
||||
>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('systemSettings.aiModel') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会员收费标准选项卡 -->
|
||||
@@ -228,6 +236,35 @@
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI模型设置选项卡 -->
|
||||
<div v-if="activeTab === 'aiModel'" class="tab-content">
|
||||
<h2 class="page-title">{{ $t('systemSettings.aiModel') }}</h2>
|
||||
<el-card class="ai-model-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>{{ $t('systemSettings.promptOptimization') }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ai-model-content">
|
||||
<el-form label-width="180px">
|
||||
<el-form-item :label="$t('systemSettings.promptOptimizationApiUrl')">
|
||||
<el-input v-model="promptOptimizationApiUrl" style="width: 400px;" placeholder="https://ai.comfly.chat"></el-input>
|
||||
<div class="model-tip">{{ $t('systemSettings.promptOptimizationApiUrlTip') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('systemSettings.promptOptimizationModel')">
|
||||
<el-input v-model="promptOptimizationModel" style="width: 400px;" placeholder="gpt-5.1-thinking"></el-input>
|
||||
<div class="model-tip">{{ $t('systemSettings.promptOptimizationModelTip') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveAiModelSettings" :loading="savingAiModel">
|
||||
{{ $t('common.save') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -458,6 +495,11 @@ const cleanupConfig = reactive({
|
||||
archiveRetentionDays: 365
|
||||
})
|
||||
|
||||
// AI模型设置相关
|
||||
const promptOptimizationModel = ref('gpt-5.1-thinking')
|
||||
const promptOptimizationApiUrl = ref('https://ai.comfly.chat')
|
||||
const savingAiModel = ref(false)
|
||||
|
||||
const goToDashboard = () => {
|
||||
router.push('/admin/dashboard')
|
||||
}
|
||||
@@ -719,11 +761,57 @@ const saveCleanupConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载AI模型设置
|
||||
const loadAiModelSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.promptOptimizationModel) {
|
||||
promptOptimizationModel.value = data.promptOptimizationModel
|
||||
}
|
||||
if (data.promptOptimizationApiUrl) {
|
||||
promptOptimizationApiUrl.value = data.promptOptimizationApiUrl
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载AI模型设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存AI模型设置
|
||||
const saveAiModelSettings = async () => {
|
||||
savingAiModel.value = true
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
promptOptimizationModel: promptOptimizationModel.value,
|
||||
promptOptimizationApiUrl: promptOptimizationApiUrl.value
|
||||
})
|
||||
})
|
||||
if (response.ok) {
|
||||
ElMessage.success('AI模型设置保存成功')
|
||||
} else {
|
||||
throw new Error('保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存AI模型设置失败:', error)
|
||||
ElMessage.error('保存AI模型设置失败')
|
||||
} finally {
|
||||
savingAiModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取统计信息和会员等级配置
|
||||
onMounted(() => {
|
||||
refreshStats()
|
||||
loadMembershipLevels()
|
||||
fetchSystemStats()
|
||||
loadAiModelSettings()
|
||||
})
|
||||
|
||||
// 获取系统统计数据
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
<select v-model="duration" class="setting-select">
|
||||
<option value="10">10s</option>
|
||||
<option value="15">15s</option>
|
||||
<option value="25">25s</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -141,20 +140,6 @@
|
||||
<div v-else class="no-video-placeholder">
|
||||
<div class="no-video-text">{{ t('video.textToVideo.noVideoUrl') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 水印选择覆盖层 -->
|
||||
<div class="watermark-overlay">
|
||||
<div class="watermark-options">
|
||||
<div class="watermark-option">
|
||||
<input type="radio" id="withWatermark" name="watermark" value="with" v-model="watermarkOption">
|
||||
<label for="withWatermark">{{ t('video.textToVideo.withWatermark') }}</label>
|
||||
</div>
|
||||
<div class="watermark-option">
|
||||
<input type="radio" id="withoutWatermark" name="watermark" value="without" v-model="watermarkOption">
|
||||
<label for="withoutWatermark">{{ t('video.textToVideo.withoutWatermark') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +307,6 @@ const taskProgress = ref(0)
|
||||
const taskStatus = ref('')
|
||||
const stopPolling = ref(null)
|
||||
const showInProgress = ref(false)
|
||||
const watermarkOption = ref('without')
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
const historyTasks = ref([]) // 历史记录
|
||||
const playingVideos = ref({}) // 正在播放的视频
|
||||
@@ -370,6 +354,11 @@ const goToStoryboardVideo = () => {
|
||||
|
||||
// 用户菜单相关方法
|
||||
const toggleUserMenu = () => {
|
||||
// 未登录时跳转到登录页面
|
||||
if (!isAuthenticated.value) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
showUserMenu.value = !showUserMenu.value
|
||||
}
|
||||
|
||||
@@ -540,11 +529,10 @@ const startPollingTask = () => {
|
||||
// 可以在这里跳转到结果页面或显示结果
|
||||
console.log('[Task Completed]', taskData)
|
||||
},
|
||||
// 错误回调
|
||||
// 错误回调 - 静默处理,只在页面上显示失败状态,不弹窗
|
||||
(error) => {
|
||||
inProgress.value = false
|
||||
taskStatus.value = 'FAILED'
|
||||
ElMessage.error(t('video.textToVideo.videoFailed') + error.message)
|
||||
console.error('[Task Failed]', error)
|
||||
}
|
||||
)
|
||||
@@ -866,19 +854,19 @@ watch(() => userStore.isAuthenticated, (isAuth) => {
|
||||
// 恢复正在进行中的任务
|
||||
const restoreProcessingTask = async () => {
|
||||
if (!userStore.isAuthenticated) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果正在创建任务,跳过恢复逻辑
|
||||
if (isCreatingTask.value) {
|
||||
console.log('[Task Restore] 跳过恢复:正在创建新任务')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果已经恢复过任务且当前有任务在进行中,跳过
|
||||
if (hasRestoredTask.value && currentTask.value) {
|
||||
console.log('[Task Restore] 跳过恢复:已有任务在进行中')
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -913,28 +901,70 @@ const restoreProcessingTask = async () => {
|
||||
duration.value = parseInt(work.duration) || 10
|
||||
}
|
||||
|
||||
inProgress.value = true
|
||||
taskStatus.value = work.status || 'PROCESSING'
|
||||
taskProgress.value = 50 // 初始进度设为50%
|
||||
// 只有真正在进行中的任务才恢复和显示消息
|
||||
const workStatus = work.status || 'PROCESSING'
|
||||
if (workStatus === 'PROCESSING' || workStatus === 'PENDING') {
|
||||
inProgress.value = true
|
||||
taskStatus.value = workStatus
|
||||
taskProgress.value = 50 // 初始进度设为50%
|
||||
|
||||
console.log('[Task Restored]', work.taskId, 'Status:', work.status)
|
||||
ElMessage.info(t('video.textToVideo.unfinishedTaskDetected'))
|
||||
|
||||
// 标记已恢复任务
|
||||
hasRestoredTask.value = true
|
||||
console.log('[Task Restored]', work.taskId, 'Status:', workStatus)
|
||||
// 静默恢复,不显示弹窗
|
||||
|
||||
// 标记已恢复任务
|
||||
hasRestoredTask.value = true
|
||||
|
||||
// 开始轮询任务状态
|
||||
startPollingTask()
|
||||
// 开始轮询任务状态
|
||||
startPollingTask()
|
||||
return true
|
||||
} else {
|
||||
// 如果任务已失败或取消,不显示恢复消息,让 checkLastTaskStatus 处理
|
||||
console.log('[Task Skip]', work.taskId, 'Status:', workStatus, '- 不是进行中状态')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Task Restore Error]', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 检查最近一条任务的状态(如果失败则显示失败状态,但不恢复输入参数)
|
||||
const checkLastTaskStatus = async () => {
|
||||
if (!userStore.isAuthenticated) return
|
||||
|
||||
try {
|
||||
const response = await textToVideoApi.getTasks(0, 1)
|
||||
if (response.data && response.data.success && response.data.data && response.data.data.length > 0) {
|
||||
const lastTask = response.data.data[0]
|
||||
|
||||
// 只关注 FAILED 状态,显示失败UI但不恢复输入参数
|
||||
if (lastTask.status === 'FAILED') {
|
||||
console.log('[Last Task Failed]', lastTask)
|
||||
|
||||
currentTask.value = lastTask
|
||||
taskStatus.value = 'FAILED'
|
||||
// 不恢复输入参数,让用户可以自由创建新任务
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check last task status error', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadHistory()
|
||||
restoreProcessingTask()
|
||||
// 延迟恢复任务,避免与创建任务冲突
|
||||
setTimeout(async () => {
|
||||
if (!isCreatingTask.value) {
|
||||
const restored = await restoreProcessingTask()
|
||||
// 如果没有恢复进行中的任务,则检查最近一条是否失败
|
||||
if (!restored) {
|
||||
checkLastTaskStatus()
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
@@ -1403,13 +1433,13 @@ onUnmounted(() => {
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
min-height: 200px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 40px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
@@ -1625,8 +1655,8 @@ onUnmounted(() => {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
min-height: 300px;
|
||||
max-height: 70vh;
|
||||
min-height: 200px;
|
||||
max-height: 50vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1765,43 +1795,6 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 水印选择覆盖层 */
|
||||
.watermark-overlay {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.watermark-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watermark-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watermark-option input[type="radio"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
.watermark-option label {
|
||||
font-size: 13px;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 操作按钮区域 */
|
||||
.result-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -214,6 +214,10 @@ const videoData = ref({
|
||||
// 处理URL,确保相对路径正确
|
||||
const processUrl = (url) => {
|
||||
if (!url) return null
|
||||
// data: 协议(Base64 图片/视频等)直接返回
|
||||
if (url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<main class="content">
|
||||
<h1 class="title">
|
||||
<span class="title-line">
|
||||
<span class="bright-text">智创</span><span class="gradient-text">无限,</span>
|
||||
<span class="bright-text">智创</span><span class="gradient-text">无限,</span>
|
||||
</span>
|
||||
<span class="title-line">
|
||||
<span class="bright-text">灵感</span><span class="gradient-text">变现。</span>
|
||||
@@ -61,23 +61,31 @@ const goToStoryboardVideo = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义字体 */
|
||||
@font-face {
|
||||
font-family: 'Taipei Sans TC';
|
||||
src: url('/fonts/TaipeiSansTC.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.welcome-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'Taipei Sans TC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.welcome-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 2156px;
|
||||
height: 1394px;
|
||||
background: url('/images/backgrounds/27.jpg') center/cover no-repeat;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: url('/images/backgrounds/welcome-bg.svg') center/cover no-repeat;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@@ -175,12 +183,13 @@ const goToStoryboardVideo = () => {
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Taipei Sans TC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 6.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 60px;
|
||||
letter-spacing: -0.03em;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
@@ -289,4 +298,15 @@ const goToStoryboardVideo = () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 全局样式 - 隐藏滚动条 */
|
||||
html:has(.welcome-page),
|
||||
body:has(.welcome-page) {
|
||||
overflow: hidden !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||