frontend: Profile detail-dialog background and modal adjustments (disable modal overlay); align detail layout; fix muted playback for history videos in 3 create pages

This commit is contained in:
AIGC Developer
2025-11-11 19:34:19 +08:00
parent ef379bcca6
commit 83bf064bb2
4 changed files with 2345 additions and 132 deletions

View File

@@ -37,25 +37,14 @@
<div class="image-input-section">
<div class="image-upload-area">
<div class="upload-box" @click="uploadFirstFrame">
<div v-if="!firstFrameImage" class="upload-placeholder">
<div class="upload-icon">+</div>
<div class="upload-text">首帧</div>
</div>
<div class="arrow-icon"></div>
<div class="upload-box optional" @click="uploadLastFrame">
<div class="upload-icon">+</div>
<div class="upload-text">尾帧 (可选)</div>
</div>
</div>
<!-- 已上传的图片预览 -->
<div class="image-preview" v-if="firstFrameImage || lastFrameImage">
<div class="preview-item" v-if="firstFrameImage">
<div v-else class="upload-preview">
<img :src="firstFrameImage" alt="首帧" />
<button class="remove-btn" @click="removeFirstFrame">×</button>
<button class="remove-btn" @click.stop="removeFirstFrame">×</button>
</div>
<div class="preview-item" v-if="lastFrameImage">
<img :src="lastFrameImage" alt="尾帧" />
<button class="remove-btn" @click="removeLastFrame">×</button>
</div>
</div>
</div>
@@ -92,6 +81,8 @@
<label>时长</label>
<select v-model="duration" class="setting-select">
<option value="10">10s</option>
<option value="15">15s</option>
<option value="25">25s</option>
</select>
</div>
@@ -106,9 +97,17 @@
<!-- 生成按钮 -->
<div class="generate-section">
<button class="generate-btn" @click="startGenerate">
开始生成
<button
class="generate-btn"
@click="startGenerate"
:disabled="!isAuthenticated"
>
{{ isAuthenticated ? '开始生成' : '请先登录' }}
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>需要登录后才能提交任务</p>
<button class="login-link-btn" @click="goToLogin">立即登录</button>
</div>
</div>
</div>
@@ -182,7 +181,6 @@
<!-- 操作按钮区域 -->
<div class="result-actions">
<button class="action-btn primary" @click="createSimilar">做同款</button>
<button class="action-btn primary" @click="submitWork">投稿</button>
<div class="action-icons">
<button class="icon-btn" @click="downloadVideo" title="下载视频">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -230,6 +228,66 @@
</div>
</div>
</div>
<!-- 历史记录区域 -->
<div class="history-section" v-if="historyTasks.length > 0">
<div class="history-list">
<div
v-for="task in historyTasks"
:key="task.taskId"
class="history-item"
>
<!-- 顶部状态复选框 -->
<div class="history-status-checkbox" v-if="task.status === 'PENDING' || task.status === 'PROCESSING'">
<input type="checkbox" :checked="true" disabled>
<label>进行中</label>
</div>
<!-- 头部信息 -->
<div class="history-item-header">
<span class="history-type">图生视频</span>
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
</div>
<!-- 描述文字 -->
<div class="history-prompt">{{ task.prompt || '无描述' }}</div>
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">排队中</div>
<div class="queue-link">订阅套餐以提升生成速度</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">取消</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail" @click="toggleHistoryVideo(task)">
<video
:ref="el => setVideoRef(task.taskId, el)"
:src="processHistoryUrl(task.resultUrl)"
muted
preload="metadata"
@loadedmetadata="handleVideoLoaded"
@error="handleVideoError"
@click.stop="toggleHistoryVideo(task)"
></video>
<div class="play-overlay" v-if="!playingVideos[task.taskId]">
<div class="play-icon"></div>
</div>
</div>
<div v-else-if="task.firstFrameUrl" class="history-image-thumbnail">
<img :src="task.firstFrameUrl" alt="首帧图片" />
</div>
<div v-else class="history-placeholder">
<div class="no-result-text">暂无结果</div>
</div>
</div>
<!-- 做同款按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">做同款</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -264,17 +322,21 @@
</template>
<script setup>
import { ref, reactive, onUnmounted, computed } from 'vue'
import { ref, reactive, onUnmounted, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
import { imageToVideoApi } from '@/api/imageToVideo'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
const router = useRouter()
const userStore = useUserStore()
// 计算是否已登录
const isAuthenticated = computed(() => userStore.isAuthenticated)
// 表单数据
const inputText = ref('')
const aspectRatio = ref('16:9')
@@ -296,6 +358,9 @@ const stopPolling = ref(null)
const showInProgress = ref(false)
const watermarkOption = ref('without')
const optimizingPrompt = ref(false) // 优化提示词状态
const historyTasks = ref([]) // 历史记录
const playingVideos = ref({}) // 正在播放的视频
const videoRefs = ref({}) // 视频元素引用
// 用户菜单相关
const showUserMenu = ref(false)
@@ -319,6 +384,14 @@ const goBack = () => {
router.push('/')
}
// 跳转到登录页面
const goToLogin = () => {
router.push({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
const goToTextToVideo = () => {
router.push('/text-to-video/create')
}
@@ -433,6 +506,13 @@ const removeLastFrame = () => {
// 开始生成视频
const startGenerate = async () => {
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning('请先登录后再提交任务')
goToLogin()
return
}
// 检查是否已有任务在进行中
if (inProgress.value) {
ElMessage.warning('已有任务在进行中,请等待完成或取消当前任务')
@@ -450,11 +530,6 @@ const startGenerate = async () => {
return
}
// 验证描述文字长度
if (inputText.value.trim().length > 500) {
ElMessage.error('描述文字不能超过500个字符')
return
}
// 显示加载状态
const loading = ElLoading.service({
@@ -467,7 +542,6 @@ const startGenerate = async () => {
// 准备请求参数
const params = {
firstFrame: firstFrameFile.value,
lastFrame: lastFrameFile.value,
prompt: inputText.value.trim(),
aspectRatio: aspectRatio.value,
duration: parseInt(duration.value),
@@ -660,6 +734,8 @@ const optimizePromptHandler = async () => {
}
} else if (error.request) {
errorMessage = '网络错误,请检查网络连接'
} else if (error.code === 'ERR_NETWORK') {
errorMessage = '网络连接错误,请检查您的网络'
} else {
errorMessage = error.message || '优化失败'
}
@@ -703,17 +779,7 @@ const retryTask = () => {
startGenerate()
}
// 投稿功能
const submitWork = () => {
if (!currentTask.value) {
ElMessage.error('没有可投稿的作品')
return
}
// 这里可以调用投稿API
ElMessage.success('投稿成功!')
console.log('投稿作品:', currentTask.value)
}
// 投稿功能(已移除按钮,保留函数已删除)
// 删除作品
const deleteWork = () => {
@@ -737,6 +803,213 @@ const deleteWork = () => {
})
}
// 处理历史记录URL
const processHistoryUrl = (url) => {
if (!url) return ''
// 如果是相对路径,确保格式正确
if (url.startsWith('/') || !url.startsWith('http')) {
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
return url.startsWith('/') ? url : `/${url}`
}
}
return url
}
// 加载历史记录
const loadHistory = async () => {
// 只有登录用户才能查看历史记录
if (!userStore.isAuthenticated) {
historyTasks.value = []
return
}
try {
const response = await imageToVideoApi.getTasks(0, 5)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 5)
// 处理URL确保相对路径正确
historyTasks.value = tasks.map(task => ({
...task,
resultUrl: task.resultUrl ? processHistoryUrl(task.resultUrl) : null,
firstFrameUrl: task.firstFrameUrl ? processHistoryUrl(task.firstFrameUrl) : null
}))
console.log('历史记录加载成功:', historyTasks.value.length, '条')
}
} catch (error) {
console.error('加载历史记录失败:', error)
historyTasks.value = []
}
}
// 从历史记录创建同款
const createSimilarFromHistory = (task) => {
if (task.prompt) {
inputText.value = task.prompt
}
if (task.aspectRatio) {
aspectRatio.value = task.aspectRatio
}
if (task.duration) {
duration.value = task.duration
}
if (task.hdMode !== undefined) {
hdMode.value = task.hdMode
}
// 如果有首帧图片URL尝试加载
if (task.firstFrameUrl) {
firstFrameImage.value = task.firstFrameUrl
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
ElMessage.success('已填充历史记录参数,可以开始生成')
}
// 取消任务
const cancelTask = async (taskId) => {
try {
ElMessage.info('取消功能待实现')
// TODO: 实现取消任务API
} catch (error) {
console.error('取消任务失败:', error)
}
}
// 处理视频加载
const handleVideoLoaded = (event) => {
// 视频元数据加载完成,可以显示缩略图
const video = event.target
video.currentTime = 1 // 跳转到第1秒作为缩略图
// 监听播放状态
video.addEventListener('play', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = true
}
})
video.addEventListener('pause', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = false
}
})
video.addEventListener('ended', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = false
video.currentTime = 1 // 播放结束后回到第1秒作为缩略图
}
})
}
// 处理视频加载错误
const handleVideoError = (event) => {
console.error('历史记录视频加载失败:', event.target.src)
// 可以在这里添加错误处理逻辑,比如显示占位图
}
// 设置视频引用
const setVideoRef = (taskId, el) => {
if (el) {
videoRefs.value[taskId] = el
}
}
// 切换历史记录视频播放
const toggleHistoryVideo = (task) => {
const video = videoRefs.value[task.taskId]
if (!video) return
if (playingVideos.value[task.taskId]) {
video.pause()
playingVideos.value[task.taskId] = false
} else {
// 解除静音并设置音量,确保有声音
try {
video.muted = false
video.volume = 1
video.play()
} catch (_) {
video.play()
}
playingVideos.value[task.taskId] = true
}
}
// 监听登录状态变化,登录后自动加载历史记录
watch(() => userStore.isAuthenticated, (isAuth) => {
if (isAuth) {
loadHistory()
restoreProcessingTask()
} else {
historyTasks.value = []
}
})
// 恢复正在进行中的任务
const restoreProcessingTask = async () => {
if (!userStore.isAuthenticated) {
return
}
try {
const response = await getProcessingWorks()
if (response.data && response.data.success && response.data.data) {
const works = response.data.data
// 只恢复图生视频类型的任务(包括 PROCESSING 和 PENDING 状态)
const imageToVideoWorks = works.filter(work => work.workType === 'IMAGE_TO_VIDEO')
if (imageToVideoWorks.length > 0) {
// 取最新的一个任务
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 = work.duration || '10'
}
inProgress.value = true
taskStatus.value = work.status || 'PROCESSING'
taskProgress.value = 50 // 初始进度设为50%
console.log('恢复正在进行中的任务:', work.taskId, '状态:', work.status)
ElMessage.info('检测到未完成的任务,继续处理中...')
// 开始轮询任务状态
startPollingTask()
}
}
} catch (error) {
console.error('恢复任务失败:', error)
}
}
// 组件挂载时加载历史记录
onMounted(() => {
loadHistory()
restoreProcessingTask()
})
// 组件卸载时清理资源
onUnmounted(() => {
// 停止轮询
@@ -990,6 +1263,8 @@ onUnmounted(() => {
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.upload-box:hover {
@@ -1001,6 +1276,15 @@ onUnmounted(() => {
opacity: 0.7;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.upload-icon {
font-size: 32px;
color: #6b7280;
@@ -1014,46 +1298,49 @@ onUnmounted(() => {
font-weight: 500;
}
.upload-preview {
width: 100%;
height: 100%;
position: relative;
}
.upload-preview img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.arrow-icon {
font-size: 20px;
color: #6b7280;
font-weight: bold;
}
.image-preview {
display: flex;
gap: 16px;
}
.preview-item {
flex: 1;
position: relative;
aspect-ratio: 1;
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.remove-btn {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.8);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-size: 18px;
font-weight: bold;
z-index: 10;
transition: all 0.2s ease;
}
.remove-btn:hover {
background: rgba(239, 68, 68, 0.9);
transform: scale(1.1);
}
/* 文本输入区域 */
@@ -1208,12 +1495,65 @@ onUnmounted(() => {
box-shadow: none;
}
.login-tip {
margin-top: 12px;
text-align: center;
padding: 12px;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.login-tip p {
color: #9ca3af;
font-size: 13px;
margin: 0 0 8px 0;
}
.login-link-btn {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.login-link-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
/* 右侧面板 */
.right-panel {
background: #0a0a0a;
padding: 32px;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
/* 自定义右侧面板滚动条样式 */
.right-panel::-webkit-scrollbar {
width: 8px;
}
.right-panel::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 4px;
}
.right-panel::-webkit-scrollbar-thumb {
background: #3b82f6;
border-radius: 4px;
}
.right-panel::-webkit-scrollbar-thumb:hover {
background: #2563eb;
}
.preview-area {
@@ -1221,6 +1561,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 20px;
min-height: min-content;
}
.status-checkbox {
@@ -1253,6 +1594,11 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
align-self: flex-start;
}
.preview-content:hover {
@@ -1260,15 +1606,22 @@ onUnmounted(() => {
}
.preview-placeholder {
text-align: center;
padding: 40px;
width: 100%;
min-height: 400px;
background: #1a1a1a;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 40px;
}
.placeholder-text {
font-size: 18px;
color: #6b7280;
font-size: 20px;
color: #9ca3af;
font-weight: 500;
line-height: 1.5;
line-height: 1.6;
margin-bottom: 20px;
}
@@ -1430,6 +1783,10 @@ onUnmounted(() => {
justify-content: center;
margin: 15px 0;
overflow: hidden;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
}
/* 生成中状态 */
@@ -1696,6 +2053,223 @@ onUnmounted(() => {
text-align: center;
}
/* 历史记录区域 */
.history-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #2a2a2a;
}
.history-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.history-item {
background: transparent;
border: none;
padding: 0;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
}
.history-status-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.history-status-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: default;
accent-color: #3b82f6;
}
.history-status-checkbox label {
color: #e5e7eb;
font-size: 14px;
font-weight: 500;
cursor: default;
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.history-type {
color: #3b82f6;
font-size: 14px;
font-weight: 600;
}
.history-date {
color: #9ca3af;
font-size: 13px;
}
.history-prompt {
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
}
.history-preview {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
position: relative;
}
.history-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
gap: 12px;
}
.queue-text {
font-size: 18px;
font-weight: 600;
color: #fff;
}
.queue-link {
font-size: 14px;
color: #3b82f6;
cursor: pointer;
text-decoration: none;
}
.queue-link:hover {
color: #60a5fa;
text-decoration: underline;
}
.cancel-btn {
padding: 8px 24px;
background: transparent;
color: #fff;
border: 1px solid #fff;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
margin-top: 8px;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.no-result-text {
font-size: 14px;
color: #6b7280;
}
.history-video-thumbnail {
width: 100%;
height: 100%;
position: relative;
cursor: pointer;
overflow: hidden;
}
.history-video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.history-image-thumbnail {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.history-image-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.history-video-thumbnail:hover .play-overlay {
opacity: 1;
}
.play-icon {
width: 48px;
height: 48px;
background: rgba(59, 130, 246, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
padding-left: 4px;
}
.history-actions {
display: flex;
justify-content: flex-start;
margin-top: 0;
}
.similar-btn {
padding: 10px 20px;
background: #2a2a2a;
color: #e5e7eb;
border: 1px solid #3a3a3a;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.similar-btn:hover {
background: #3a3a3a;
border-color: #4a4a4a;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.main-content {

View File

@@ -3,7 +3,9 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<!-- Logo -->
<div class="logo">logo</div>
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
</div>
<!-- 导航菜单 -->
<nav class="nav-menu">
@@ -67,8 +69,7 @@
<section class="profile-section">
<div class="profile-info">
<div class="avatar">
<img v-if="userInfo.avatar" :src="userInfo.avatar" alt="avatar" class="avatar-image" />
<div v-else class="avatar-icon"></div>
<img src="/images/backgrounds/avatar-default.svg" alt="avatar" class="avatar-image" />
</div>
<div class="user-details">
<h2 class="username">{{ userInfo.nickname || userInfo.username || '未设置用户名' }}</h2>
@@ -83,7 +84,7 @@
<h3 class="section-title">已发布</h3>
<div class="video-grid">
<div class="video-item" v-for="(video, index) in videos" :key="video.id || index" v-loading="loading">
<div class="video-thumbnail" @click="goToCreate(video)">
<div class="video-thumbnail" @click="openDetail(video)">
<div class="thumbnail-image">
<!-- 如果是视频类型且有视频URL使用video元素显示首帧 -->
<video
@@ -103,10 +104,9 @@
/>
<!-- 否则使用占位符 -->
<div v-else class="figure"></div>
<div class="text-overlay" v-if="video.text">{{ video.text }}</div>
</div>
<div class="video-action">
<el-button v-if="index === 0" type="primary" size="small" @click.stop="goToCreate(video)">做同款</el-button>
<el-button v-if="index === 0" type="primary" size="small" @click.stop="createSimilar(video)">做同款</el-button>
<span v-else class="director-text">DIRECTED BY VANNOCENT</span>
</div>
</div>
@@ -119,6 +119,124 @@
</main>
</div>
<!-- 作品详情弹窗我的作品一致风格的精简版 -->
<el-dialog
v-model="detailDialogVisible"
:title="selectedItem?.title"
width="60%"
:before-close="handleClose"
class="detail-dialog"
:modal="false"
:close-on-click-modal="true"
:close-on-press-escape="true"
>
<div class="detail-content" v-if="selectedItem">
<div class="detail-left">
<div class="video-container">
<video
v-if="selectedItem.type === 'video'"
class="detail-video"
:src="selectedItem.resultUrl || selectedItem.cover"
:poster="selectedItem.cover"
controls
>
您的浏览器不支持视频播放
</video>
<img
v-else
class="detail-image"
:src="selectedItem.cover"
:alt="selectedItem.title"
/>
</div>
</div>
<div class="detail-right">
<div class="detail-header">
<div class="user-info">
<div class="avatar">
<el-icon><User /></el-icon>
</div>
<div class="username">{{ (selectedItem && selectedItem.username) || '匿名用户' }}</div>
</div>
</div>
<div class="tabs">
<div class="tab" :class="{ active: activeDetailTab === 'detail' }" @click="activeDetailTab = 'detail'">作品详情</div>
<div class="tab" :class="{ active: activeDetailTab === 'category' }" @click="activeDetailTab = 'category'">{{ selectedItem.category }}</div>
</div>
<div class="description-section" v-if="activeDetailTab === 'detail'">
<h3 class="section-title">提示词</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">输入详情</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">提示词</h3>
<p class="description-text">图1在图2中奔跑视频</p>
</div>
</div>
<!-- 其他分类的内容 -->
<div class="description-section" v-if="activeDetailTab === 'category' && selectedItem.category !== '参考图'">
<h3 class="section-title">提示词</h3>
<p class="description-text">{{ getDescription(selectedItem) }}</p>
</div>
<div class="metadata-section">
<div class="metadata-item">
<span class="label">创建时间</span>
<span class="value">{{ selectedItem.createTime }}</span>
</div>
<div class="metadata-item">
<span class="label">作品 ID</span>
<span class="value">{{ selectedItem.id }}</span>
</div>
<div class="metadata-item">
<span class="label">日期</span>
<span class="value">{{ selectedItem.date }}</span>
</div>
<div class="metadata-item">
<span class="label">分类</span>
<span class="value">{{ selectedItem.category }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type === 'video'">
<span class="label">时长</span>
<span class="value">{{ formatDuration(selectedItem.duration) || '未知' }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type === 'video'">
<span class="label">清晰度</span>
<span class="value">{{ selectedItem.quality || '未知' }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type === 'video'">
<span class="label">宽高比</span>
<span class="value">{{ selectedItem.aspectRatio || '未知' }}</span>
</div>
</div>
<div class="action-section">
<button class="create-similar-btn" @click="createSimilar(selectedItem)">
做同款
</button>
</div>
</div>
</div>
</el-dialog>
<!-- 用户菜单下拉 - 使用Teleport渲染到body -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
@@ -168,6 +286,7 @@ import {
} from '@element-plus/icons-vue'
import { getMyWorks } from '@/api/userWorks'
import { getCurrentUser } from '@/api/auth'
import { getWorkDetail } from '@/api/userWorks'
const router = useRouter()
const userStore = useUserStore()
@@ -192,6 +311,11 @@ const userLoading = ref(false)
const videos = ref([])
const loading = ref(false)
// 详情弹窗
const detailDialogVisible = ref(false)
const selectedItem = ref(null)
const activeDetailTab = ref('detail')
// 计算菜单位置
const menuStyle = computed(() => {
if (!userStatusRef.value || !showUserMenu.value) return {}
@@ -287,18 +411,105 @@ const logout = async () => {
}
}
// 打开作品详情
const openDetail = async (item) => {
selectedItem.value = item
activeDetailTab.value = 'detail'
detailDialogVisible.value = true
try {
const response = await getWorkDetail(item.id)
if (response && response.data && response.data.success && response.data.data) {
const work = response.data.data
selectedItem.value = transformWorkData(work)
} else {
console.error('获取作品详情失败:', response?.data?.message || '未知错误')
ElMessage.error('获取作品详情失败')
}
} catch (error) {
console.error('加载作品详情失败:', error)
ElMessage.error('加载作品详情失败: ' + (error.message || '未知错误'))
}
}
// 关闭详情
const handleClose = () => {
detailDialogVisible.value = false
selectedItem.value = null
activeDetailTab.value = 'detail'
}
// 获取作品提示词(优先使用 prompt其次使用后台 description最后回退默认文案
const getDescription = (item) => {
if (!item) return ''
const desc = (item.prompt && item.prompt.trim()) ? item.prompt : (item.description && item.description.trim() ? item.description : '')
if (desc) return desc
// 回退文案
if (item.type === 'video') {
return '暂无提示词'
}
return '暂无提示词'
}
// 格式化时长
const formatDuration = (dur) => {
if (dur === null || dur === undefined || dur === '') return ''
if (typeof dur === 'number') return `${dur}s`
if (typeof dur === 'string') {
const trimmed = dur.trim()
if (/^\d+$/.test(trimmed)) return `${trimmed}s`
return trimmed
}
return String(dur)
}
// 做同款
const createSimilar = (item) => {
if (!item) return
if (item.type === 'video') {
router.push('/text-to-video/create')
} else {
router.push('/image-to-video/create')
}
}
// 处理URL确保相对路径正确
const processUrl = (url) => {
if (!url) return null
// 如果已经是完整URLhttp/https直接返回
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
// 如果是相对路径(以/开头),确保以/开头
if (url.startsWith('/')) {
return url
}
// 否则添加/前缀
return '/' + url
}
// 将后端返回的UserWork数据转换为前端需要的格式
const transformWorkData = (work) => {
const resultUrl = processUrl(work.resultUrl)
const thumbnailUrl = processUrl(work.thumbnailUrl)
return {
id: work.id?.toString() || work.taskId || '',
title: work.title || work.prompt || '未命名作品',
cover: work.thumbnailUrl || work.resultUrl || '/images/backgrounds/welcome.jpg',
resultUrl: work.resultUrl || '',
type: work.workType === 'TEXT_TO_VIDEO' || work.workType === 'IMAGE_TO_VIDEO' ? 'video' : 'image',
text: work.prompt || '',
category: work.workType === 'TEXT_TO_VIDEO' ? '文生视频' : work.workType === 'IMAGE_TO_VIDEO' ? '图生视频' : '未知',
size: work.fileSize || '未知大小',
createTime: work.createdAt ? new Date(work.createdAt).toLocaleString('zh-CN') : ''
cover: thumbnailUrl || resultUrl || '/images/backgrounds/welcome.jpg',
resultUrl: resultUrl || '',
type: work.workType === 'TEXT_TO_VIDEO' || work.workType === 'IMAGE_TO_VIDEO' || work.workType === 'STORYBOARD_VIDEO' ? 'video' : 'image',
category: work.workType === 'TEXT_TO_VIDEO' ? '文生视频' : work.workType === 'IMAGE_TO_VIDEO' ? '图生视频' : work.workType === 'STORYBOARD_VIDEO' ? '分镜视频' : '未知',
sizeText: work.fileSize || '未知大小',
createTime: work.createdAt ? new Date(work.createdAt).toLocaleString('zh-CN') : '',
date: work.createdAt ? new Date(work.createdAt).toLocaleDateString('zh-CN') : '',
description: work.description || work.prompt || '',
prompt: work.prompt || '',
duration: work.duration || work.videoDuration || work.length || '',
aspectRatio: work.aspectRatio || work.ratio || work.aspect || '',
quality: work.quality || work.resolution || '',
username: work.username || work.user?.username || work.creator || work.author || work.owner || '未知用户',
status: work.status || 'COMPLETED',
}
}
@@ -374,16 +585,7 @@ const handleClickOutside = (event) => {
}
}
// 跳转到作品详情或创作页面
const goToCreate = (video) => {
if (video && video.category === '文生视频') {
router.push('/text-to-video/create')
} else if (video && video.category === '图生视频') {
router.push('/image-to-video/create')
} else {
router.push('/text-to-video/create')
}
}
// 个人主页“已发布”区不跳转,保持静态展示,与“我的作品”点击打开详情逻辑不同
// 视频加载元数据后,跳转到第一帧(但不播放)
const onVideoLoaded = (event) => {
@@ -453,7 +655,7 @@ onUnmounted(() => {
/* 左侧导航栏 */
.sidebar {
width: 280px !important;
background: #1a1a1a !important;
background: #000000 !important;
padding: 24px 0 !important;
border-right: 1px solid #1a1a1a !important;
flex-shrink: 0 !important;
@@ -464,9 +666,14 @@ onUnmounted(() => {
.logo {
padding: 0 24px 32px;
font-size: 20px;
font-weight: 500;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.logo img {
height: 40px;
width: auto;
}
.nav-menu, .tools-menu {
@@ -909,4 +1116,268 @@ onUnmounted(() => {
text-align: center;
}
}
/* 详情弹窗样式(与“我的作品”保持一致) */
:deep(.el-dialog.detail-dialog) {
background: #0a0a0a !important;
/* 强制覆盖 Element Plus 对话框背景变量,避免仍使用默认白色 */
--el-dialog-bg-color: #0a0a0a !important;
border-radius: 12px;
border: 1px solid #333;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
}
:deep(.el-dialog.detail-dialog .el-dialog__header) {
background: #0a0a0a !important;
border-bottom: 1px solid #333;
padding: 16px 20px;
}
:deep(.el-dialog.detail-dialog .el-dialog__title) {
color: #fff !important;
font-size: 18px;
font-weight: 600;
}
:deep(.el-dialog.detail-dialog .el-dialog__headerbtn) {
color: #fff !important;
}
:deep(.el-dialog.detail-dialog .el-dialog__body) {
background: #0a0a0a !important;
padding: 0 !important;
}
.detail-content {
display: flex;
height: 50vh;
background: #0a0a0a;
}
.detail-left {
flex: 2;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
}
.video-container {
position: relative;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
.detail-video, .detail-image {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.detail-right {
flex: 1;
background: #0a0a0a;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.detail-right .avatar {
width: 40px;
height: 40px;
background: #409eff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
}
.detail-right .username {
font-size: 16px;
font-weight: 500;
color: #fff;
}
.tabs {
display: flex;
gap: 0;
}
.tab {
padding: 8px 16px;
background: transparent;
color: #9ca3af;
cursor: pointer;
border-radius: 6px;
transition: all 0.3s;
font-size: 14px;
}
.tab.active {
background: #409eff;
color: #fff;
}
.tab:hover:not(.active) {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.description-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin: 0;
}
.description-text {
font-size: 14px;
line-height: 1.6;
color: #d1d5db;
margin: 0;
}
.reference-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.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;
}
.metadata-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.label {
font-size: 14px;
color: #9ca3af;
}
.value {
font-size: 14px;
color: #fff;
font-weight: 500;
}
.action-section {
margin-top: auto;
padding-top: 20px;
}
.create-similar-btn {
width: 100%;
background: #409eff;
color: #fff;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.create-similar-btn:hover {
background: #337ecc;
transform: translateY(-2px);
}
.create-similar-btn:active {
transform: translateY(0);
}
/* 覆盖全局对话框与遮罩背景,确保弹窗打开时为深色背景 */
: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;
}
/* 不全局影响其它对话框,仅本弹窗 */
/* 仅作用于本弹窗的遮罩类,避免全局影响 */
:deep(.dark-overlay) {
background-color: rgba(0, 0, 0, 0.6) !important;
}
/* 兜底:确保对话框本体也是深色(覆盖可能的白色默认) */
:deep(.dark-overlay .el-overlay-dialog),
:deep(.dark-overlay .el-dialog),
:deep(.el-overlay .el-dialog.detail-dialog),
:deep(.detail-dialog .el-dialog) {
background: #0a0a0a !important;
}
</style>
<style>
/* 全局兜底:仅作用于个人主页详情弹窗 */
.el-dialog.detail-dialog {
background: #0a0a0a !important;
--el-dialog-bg-color: #0a0a0a !important;
}
.el-dialog.detail-dialog .el-dialog__header {
background: #0a0a0a !important;
}
.el-dialog.detail-dialog .el-dialog__body {
background: #0a0a0a !important;
}
</style>

View File

@@ -117,8 +117,9 @@
<div class="setting-item">
<label>时长</label>
<select v-model="duration" class="setting-select">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="15">15s</option>
<option value="25">25s</option>
</select>
</div>
@@ -139,10 +140,14 @@
<button
class="generate-btn"
@click="handleGenerateClick"
:disabled="inProgress || (currentStep === 'generate' && !hasUploadedImages && !inputText.trim()) || (currentStep === 'video' && !generatedImageUrl && !hasUploadedImages)"
:disabled="!isAuthenticated || inProgress || (currentStep === 'generate' && !hasUploadedImages && !inputText.trim()) || (currentStep === 'video' && !generatedImageUrl && !hasUploadedImages)"
>
{{ getButtonText() }}
{{ isAuthenticated ? getButtonText() : '请先登录' }}
</button>
<div v-if="!isAuthenticated" class="login-tip-floating">
<p>需要登录后才能提交任务</p>
<button class="login-link-btn" @click="goToLogin">立即登录</button>
</div>
</div>
<!-- 右侧预览区域 -->
@@ -189,6 +194,66 @@
<div class="placeholder-text">开始创作您的第一个作品吧!</div>
</div>
</div>
<!-- 历史记录区域 -->
<div class="history-section" v-if="historyTasks.length > 0">
<div class="history-list">
<div
v-for="task in historyTasks"
:key="task.taskId"
class="history-item"
>
<!-- 顶部状态复选框 -->
<div class="history-status-checkbox" v-if="task.status === 'PENDING' || task.status === 'PROCESSING'">
<input type="checkbox" :checked="true" disabled>
<label>进行中</label>
</div>
<!-- 头部信息 -->
<div class="history-item-header">
<span class="history-type">分镜视频</span>
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
</div>
<!-- 描述文字 -->
<div class="history-prompt">{{ task.prompt || '无描述' }}</div>
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">排队中</div>
<div class="queue-link">订阅套餐以提升生成速度</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">取消</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl && !task.resultUrl.startsWith('data:image')" class="history-video-thumbnail" @click="toggleHistoryVideo(task)">
<video
:ref="el => setVideoRef(task.taskId, el)"
:src="processHistoryUrl(task.resultUrl)"
muted
preload="metadata"
@loadedmetadata="handleVideoLoaded"
@error="handleVideoError"
@click.stop="toggleHistoryVideo(task)"
></video>
<div class="play-overlay" v-if="!playingVideos[task.taskId]">
<div class="play-icon"></div>
</div>
</div>
<div v-else-if="task.resultUrl && task.resultUrl.startsWith('data:image')" class="history-image-thumbnail">
<img :src="task.resultUrl" alt="分镜图" />
</div>
<div v-else class="history-placeholder">
<div class="no-result-text">暂无结果</div>
</div>
</div>
<!-- 做同款按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">做同款</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -196,19 +261,25 @@
</template>
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue'
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createStoryboardTask, getStoryboardTask } from '@/api/storyboardVideo'
import { ElMessage, ElLoading } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks } from '@/api/storyboardVideo'
import { imageToVideoApi } from '@/api/imageToVideo'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
const router = useRouter()
const userStore = useUserStore()
// 计算是否已登录
const isAuthenticated = computed(() => userStore.isAuthenticated)
// 表单数据
const inputText = ref('')
const aspectRatio = ref('16:9')
const duration = ref('5')
const duration = ref('10')
const hdMode = ref(false)
const inProgress = ref(false)
const currentStep = ref('generate') // 'generate' 或 'video'
@@ -239,12 +310,23 @@ const videoPollIntervalId = ref(null) // 视频任务轮询定时器ID
const videoTaskStatus = ref('') // 视频任务状态PROCESSING, COMPLETED, FAILED
const videoResultUrl = ref('') // 视频结果URL
const videoProgress = ref(0) // 视频生成进度
const historyTasks = ref([]) // 历史记录
const playingVideos = ref({}) // 正在播放的视频
const videoRefs = ref({}) // 视频元素引用
// 导航函数
const goBack = () => {
router.back()
}
// 跳转到登录页面
const goToLogin = () => {
router.push({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
const goToTextToVideo = () => {
router.push('/text-to-video/create')
}
@@ -295,6 +377,13 @@ const getButtonText = () => {
const handleGenerateClick = () => {
console.log('handleGenerateClick 被调用,当前步骤:', currentStep.value)
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning('请先登录后再提交任务')
goToLogin()
return
}
// 如果已经切换到视频步骤,直接生成视频
if (currentStep.value === 'video') {
startVideoGenerate()
@@ -473,6 +562,8 @@ const optimizePromptHandler = async () => {
}
} else if (error.request) {
errorMessage = '网络错误,请检查网络连接'
} else if (error.code === 'ERR_NETWORK') {
errorMessage = '网络连接错误,请检查您的网络'
} else {
errorMessage = error.message || '优化失败'
}
@@ -487,6 +578,13 @@ const optimizePromptHandler = async () => {
const startGenerate = async () => {
console.log('startGenerate 被调用')
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning('请先登录后再提交任务')
goToLogin()
return
}
if (!inputText.value.trim()) {
ElMessage.warning('请输入描述文字')
return
@@ -746,6 +844,13 @@ const urlToFile = async (url, filename) => {
const startVideoGenerate = async () => {
console.log('startVideoGenerate 被调用')
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning('请先登录后再提交任务')
goToLogin()
return
}
// 如果有分镜图任务ID从分镜图生成使用新的API
if (taskId.value && generatedImageUrl.value) {
try {
@@ -945,6 +1050,237 @@ const pollVideoTaskStatus = async (taskId) => {
videoPollIntervalId.value = setTimeout(poll, pollInterval)
}
// 处理历史记录URL
const processHistoryUrl = (url) => {
if (!url) return ''
// 如果是相对路径,确保格式正确
if (url.startsWith('/') || !url.startsWith('http')) {
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
return url.startsWith('/') ? url : `/${url}`
}
}
return url
}
// 加载历史记录
const loadHistory = async () => {
// 只有登录用户才能查看历史记录
if (!userStore.isAuthenticated) {
historyTasks.value = []
return
}
try {
const response = await getUserStoryboardTasks(0, 10)
console.log('分镜视频历史记录API响应:', response)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 10)
console.log('获取到的任务列表:', tasks)
// 处理URL确保相对路径正确但保留Base64图片
historyTasks.value = tasks.map(task => ({
...task,
resultUrl: task.resultUrl && !task.resultUrl.startsWith('data:image')
? processHistoryUrl(task.resultUrl)
: task.resultUrl
}))
console.log('历史记录加载成功:', historyTasks.value.length, '条')
} else {
console.warn('分镜视频历史记录API返回失败:', response.data)
historyTasks.value = []
}
} catch (error) {
console.error('加载历史记录失败:', error)
console.error('错误详情:', error.response || error.message)
historyTasks.value = []
}
}
// 从历史记录创建同款
const createSimilarFromHistory = (task) => {
if (task.prompt) {
inputText.value = task.prompt
}
if (task.aspectRatio) {
aspectRatio.value = task.aspectRatio
}
if (task.hdMode !== undefined) {
hdMode.value = task.hdMode
}
// 如果有分镜图URL尝试加载
if (task.resultUrl && task.resultUrl.startsWith('data:image')) {
generatedImageUrl.value = task.resultUrl
uploadedImages.value = [{
url: task.resultUrl,
file: null,
name: '分镜图'
}]
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
ElMessage.success('已填充历史记录参数,可以开始生成')
}
// 取消任务
const cancelTask = async (taskId) => {
try {
ElMessage.info('取消功能待实现')
// TODO: 实现取消任务API
} catch (error) {
console.error('取消任务失败:', error)
}
}
// 处理视频加载
const handleVideoLoaded = (event) => {
// 视频元数据加载完成,可以显示缩略图
const video = event.target
video.currentTime = 1 // 跳转到第1秒作为缩略图
// 监听播放状态
video.addEventListener('play', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = true
}
})
video.addEventListener('pause', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = false
}
})
video.addEventListener('ended', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = false
video.currentTime = 1 // 播放结束后回到第1秒作为缩略图
}
})
}
// 处理视频加载错误
const handleVideoError = (event) => {
console.error('历史记录视频加载失败:', event.target.src)
// 可以在这里添加错误处理逻辑,比如显示占位图
}
// 设置视频引用
const setVideoRef = (taskId, el) => {
if (el) {
videoRefs.value[taskId] = el
}
}
// 切换历史记录视频播放
const toggleHistoryVideo = (task) => {
const video = videoRefs.value[task.taskId]
if (!video) return
if (playingVideos.value[task.taskId]) {
video.pause()
playingVideos.value[task.taskId] = false
} else {
// 解除静音并设置音量,确保有声音
try {
video.muted = false
video.volume = 1
video.play()
} catch (_) {
video.play()
}
playingVideos.value[task.taskId] = true
}
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}${month}${day}${hours}:${minutes}`
}
// 组件挂载时加载历史记录
// 监听登录状态变化,登录后自动加载历史记录
watch(() => userStore.isAuthenticated, (isAuth) => {
if (isAuth) {
loadHistory()
restoreProcessingTask()
} else {
historyTasks.value = []
}
})
// 恢复正在进行中的任务
const restoreProcessingTask = async () => {
if (!userStore.isAuthenticated) {
return
}
try {
const response = await getProcessingWorks()
if (response.data && response.data.success && response.data.data) {
const works = response.data.data
// 只恢复分镜视频类型的任务(包括 PROCESSING 和 PENDING 状态)
const storyboardWorks = works.filter(work => work.workType === 'STORYBOARD_VIDEO')
if (storyboardWorks.length > 0) {
// 取最新的一个任务
const work = storyboardWorks[0]
// 恢复任务状态
currentTask.value = {
taskId: work.taskId,
prompt: work.prompt,
aspectRatio: work.aspectRatio,
duration: work.duration,
resultUrl: work.resultUrl,
createdAt: work.createdAt
}
taskId.value = work.taskId
// 恢复输入参数
if (work.prompt) {
inputText.value = work.prompt
}
if (work.aspectRatio) {
aspectRatio.value = work.aspectRatio
}
if (work.duration) {
duration.value = work.duration || '10'
}
inProgress.value = true
taskStatus.value = work.status || 'PROCESSING'
console.log('恢复正在进行中的任务:', work.taskId, '状态:', work.status)
ElMessage.info('检测到未完成的任务,继续处理中...')
// 开始轮询任务状态
pollStoryboardTask(work.taskId)
}
}
} catch (error) {
console.error('恢复任务失败:', error)
}
}
onMounted(() => {
loadHistory()
restoreProcessingTask()
})
// 组件卸载时清理轮询
onBeforeUnmount(() => {
if (pollIntervalId.value) {
@@ -1576,12 +1912,65 @@ onBeforeUnmount(() => {
box-shadow: none;
}
.login-tip-floating {
margin-top: 12px;
text-align: center;
padding: 12px;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.login-tip-floating p {
color: #9ca3af;
font-size: 13px;
margin: 0 0 8px 0;
}
.login-link-btn {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.login-link-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
/* 右侧面板 */
.right-panel {
background: #0a0a0a;
padding: 32px;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
/* 自定义右侧面板滚动条样式 */
.right-panel::-webkit-scrollbar {
width: 8px;
}
.right-panel::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 4px;
}
.right-panel::-webkit-scrollbar-thumb {
background: #3b82f6;
border-radius: 4px;
}
.right-panel::-webkit-scrollbar-thumb:hover {
background: #2563eb;
}
.preview-area {
@@ -1589,6 +1978,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
gap: 20px;
min-height: min-content;
}
.status-checkbox {
@@ -1624,6 +2014,11 @@ onBeforeUnmount(() => {
overflow: hidden; /* 防止内容溢出 */
min-height: 0; /* 允许flex子项缩小 */
position: relative; /* 为绝对定位的子元素提供定位参考 */
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
align-self: flex-start;
}
.preview-content:hover {
@@ -1631,15 +2026,21 @@ onBeforeUnmount(() => {
}
.preview-placeholder {
text-align: center;
padding: 40px;
width: 100%;
min-height: 400px;
background: #1a1a1a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 40px;
}
.placeholder-text {
font-size: 18px;
color: #6b7280;
font-size: 20px;
color: #9ca3af;
font-weight: 500;
line-height: 1.5;
line-height: 1.6;
}
/* 预览图片 */
@@ -1838,6 +2239,223 @@ onBeforeUnmount(() => {
text-align: left;
}
}
/* 历史记录区域 */
.history-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #2a2a2a;
}
.history-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.history-item {
background: transparent;
border: none;
padding: 0;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
}
.history-status-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.history-status-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: default;
accent-color: #3b82f6;
}
.history-status-checkbox label {
color: #e5e7eb;
font-size: 14px;
font-weight: 500;
cursor: default;
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.history-type {
color: #3b82f6;
font-size: 14px;
font-weight: 600;
}
.history-date {
color: #9ca3af;
font-size: 13px;
}
.history-prompt {
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
}
.history-preview {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
position: relative;
}
.history-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
gap: 12px;
}
.queue-text {
font-size: 18px;
font-weight: 600;
color: #fff;
}
.queue-link {
font-size: 14px;
color: #3b82f6;
cursor: pointer;
text-decoration: none;
}
.queue-link:hover {
color: #60a5fa;
text-decoration: underline;
}
.cancel-btn {
padding: 8px 24px;
background: transparent;
color: #fff;
border: 1px solid #fff;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
margin-top: 8px;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.no-result-text {
font-size: 14px;
color: #6b7280;
}
.history-video-thumbnail {
width: 100%;
height: 100%;
position: relative;
cursor: pointer;
overflow: hidden;
}
.history-video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.history-image-thumbnail {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.history-image-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.history-video-thumbnail:hover .play-overlay {
opacity: 1;
}
.play-icon {
width: 48px;
height: 48px;
background: rgba(59, 130, 246, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
padding-left: 4px;
}
.history-actions {
display: flex;
justify-content: flex-start;
margin-top: 0;
}
.similar-btn {
padding: 10px 20px;
background: #2a2a2a;
color: #e5e7eb;
border: 1px solid #3a3a3a;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.similar-btn:hover {
background: #3a3a3a;
border-color: #4a4a4a;
}
</style>

View File

@@ -62,8 +62,9 @@
<div class="setting-item">
<label>时长</label>
<select v-model="duration" class="setting-select">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="15">15s</option>
<option value="25">25s</option>
</select>
</div>
@@ -78,9 +79,17 @@
<!-- 生成按钮 -->
<div class="generate-section">
<button class="generate-btn" @click="startGenerate">
开始生成
<button
class="generate-btn"
@click="startGenerate"
:disabled="!isAuthenticated"
>
{{ isAuthenticated ? '开始生成' : '请先登录' }}
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>需要登录后才能提交任务</p>
<button class="login-link-btn" @click="goToLogin">立即登录</button>
</div>
</div>
</div>
@@ -154,7 +163,6 @@
<!-- 操作按钮区域 -->
<div class="result-actions">
<button class="action-btn primary" @click="createSimilar">做同款</button>
<button class="action-btn primary" @click="submitWork">投稿</button>
<div class="action-icons">
<button class="icon-btn" @click="downloadVideo" title="下载视频">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -196,6 +204,63 @@
<div class="placeholder-text">开始创作您的第一个作品吧!</div>
</div>
</div>
<!-- 历史记录区域 -->
<div class="history-section" v-if="historyTasks.length > 0">
<div class="history-list">
<div
v-for="task in historyTasks"
:key="task.taskId"
class="history-item"
>
<!-- 顶部状态复选框 -->
<div class="history-status-checkbox" v-if="task.status === 'PENDING' || task.status === 'PROCESSING'">
<input type="checkbox" :checked="true" disabled>
<label>进行中</label>
</div>
<!-- 头部信息 -->
<div class="history-item-header">
<span class="history-type">文生视频</span>
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
</div>
<!-- 描述文字 -->
<div class="history-prompt">{{ task.prompt || '无描述' }}</div>
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">排队中</div>
<div class="queue-link">订阅套餐以提升生成速度</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">取消</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail" @click="toggleHistoryVideo(task)">
<video
:ref="el => setVideoRef(task.taskId, el)"
:src="processHistoryUrl(task.resultUrl)"
muted
preload="metadata"
@loadedmetadata="handleVideoLoaded"
@error="handleVideoError"
@click.stop="toggleHistoryVideo(task)"
></video>
<div class="play-overlay" v-if="!playingVideos[task.taskId]">
<div class="play-icon"></div>
</div>
</div>
<div v-else class="history-placeholder">
<div class="no-result-text">暂无结果</div>
</div>
</div>
<!-- 做同款按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">做同款</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -230,21 +295,25 @@
</template>
<script setup>
import { ref, onUnmounted, computed } from 'vue'
import { ref, onUnmounted, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { textToVideoApi } from '@/api/textToVideo'
import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
import { ElMessage, ElLoading } from 'element-plus'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
const router = useRouter()
const userStore = useUserStore()
// 计算是否已登录
const isAuthenticated = computed(() => userStore.isAuthenticated)
// 响应式数据
const inputText = ref('')
const aspectRatio = ref('16:9')
const duration = ref(5)
const duration = ref(10)
const hdMode = ref(false)
const inProgress = ref(false)
const currentTask = ref(null)
@@ -254,6 +323,9 @@ const stopPolling = ref(null)
const showInProgress = ref(false)
const watermarkOption = ref('without')
const optimizingPrompt = ref(false) // 优化提示词状态
const historyTasks = ref([]) // 历史记录
const playingVideos = ref({}) // 正在播放的视频
const videoRefs = ref({}) // 视频元素引用
// 用户菜单相关
const showUserMenu = ref(false)
@@ -277,6 +349,14 @@ const goBack = () => {
router.push('/')
}
// 跳转到登录页面
const goToLogin = () => {
router.push({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
const goToImageToVideo = () => {
router.push('/image-to-video/create')
}
@@ -317,6 +397,13 @@ const logout = () => {
}
const startGenerate = async () => {
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning('请先登录后再提交任务')
goToLogin()
return
}
// 检查是否已有任务在进行中
if (inProgress.value) {
ElMessage.warning('已有任务在进行中,请等待完成或取消当前任务')
@@ -329,11 +416,6 @@ const startGenerate = async () => {
return
}
// 验证描述文字长度
if (inputText.value.trim().length > 1000) {
ElMessage.error('文本描述不能超过1000个字符')
return
}
// 显示加载状态
const loading = ElLoading.service({
@@ -516,6 +598,8 @@ const optimizePromptHandler = async () => {
}
} else if (error.request) {
errorMessage = '网络错误,请检查网络连接'
} else if (error.code === 'ERR_NETWORK') {
errorMessage = '网络连接错误,请检查您的网络'
} else {
errorMessage = error.message || '优化失败'
}
@@ -559,17 +643,7 @@ const retryTask = () => {
startGenerate()
}
// 投稿功能
const submitWork = () => {
if (!currentTask.value) {
ElMessage.error('没有可投稿的作品')
return
}
// 这里可以调用投稿API
ElMessage.success('投稿成功!')
console.log('投稿作品:', currentTask.value)
}
// 投稿功能(已移除按钮,保留函数已删除)
// 删除作品
const deleteWork = () => {
@@ -593,6 +667,210 @@ const deleteWork = () => {
})
}
// 处理历史记录URL
const processHistoryUrl = (url) => {
if (!url) return ''
// 如果是相对路径,确保格式正确
if (url.startsWith('/') || !url.startsWith('http')) {
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
return url.startsWith('/') ? url : `/${url}`
}
}
return url
}
// 加载历史记录
const loadHistory = async () => {
// 只有登录用户才能查看历史记录
if (!userStore.isAuthenticated) {
historyTasks.value = []
return
}
try {
const response = await textToVideoApi.getTasks(0, 5)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 5)
// 处理URL确保相对路径正确
historyTasks.value = tasks.map(task => ({
...task,
resultUrl: task.resultUrl ? processHistoryUrl(task.resultUrl) : null
}))
console.log('历史记录加载成功:', historyTasks.value.length, '条')
}
} catch (error) {
console.error('加载历史记录失败:', error)
historyTasks.value = []
}
}
// 从历史记录创建同款
const createSimilarFromHistory = (task) => {
if (task.prompt) {
inputText.value = task.prompt
}
if (task.aspectRatio) {
aspectRatio.value = task.aspectRatio
}
if (task.duration) {
duration.value = task.duration
}
if (task.hdMode !== undefined) {
hdMode.value = task.hdMode
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
ElMessage.success('已填充历史记录参数,可以开始生成')
}
// 取消任务
const cancelTask = async (taskId) => {
try {
ElMessage.info('取消功能待实现')
// TODO: 实现取消任务API
} catch (error) {
console.error('取消任务失败:', error)
}
}
// 处理视频加载
const handleVideoLoaded = (event) => {
// 视频元数据加载完成,可以显示缩略图
const video = event.target
video.currentTime = 1 // 跳转到第1秒作为缩略图
// 监听播放状态
video.addEventListener('play', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = true
}
})
video.addEventListener('pause', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = false
}
})
video.addEventListener('ended', () => {
const taskId = Object.keys(videoRefs.value).find(id => videoRefs.value[id] === video)
if (taskId) {
playingVideos.value[taskId] = false
video.currentTime = 1 // 播放结束后回到第1秒作为缩略图
}
})
}
// 处理视频加载错误
const handleVideoError = (event) => {
console.error('历史记录视频加载失败:', event.target.src)
// 可以在这里添加错误处理逻辑,比如显示占位图
}
// 设置视频引用
const setVideoRef = (taskId, el) => {
if (el) {
videoRefs.value[taskId] = el
}
}
// 切换历史记录视频播放
const toggleHistoryVideo = (task) => {
const video = videoRefs.value[task.taskId]
if (!video) return
if (playingVideos.value[task.taskId]) {
video.pause()
playingVideos.value[task.taskId] = false
} else {
// 解除静音并设置音量,确保有声音
try {
video.muted = false
video.volume = 1
// 一些浏览器需要用户交互后才能带声音播放,这里已由点击触发
video.play()
} catch (_) {
// 忽略播放失败(策略限制),用户可再次点击
video.play()
}
playingVideos.value[task.taskId] = true
}
}
// 组件挂载时加载历史记录
// 监听登录状态变化,登录后自动加载历史记录
watch(() => userStore.isAuthenticated, (isAuth) => {
if (isAuth) {
loadHistory()
restoreProcessingTask()
} else {
historyTasks.value = []
}
})
// 恢复正在进行中的任务
const restoreProcessingTask = async () => {
if (!userStore.isAuthenticated) {
return
}
try {
const response = await getProcessingWorks()
if (response.data && response.data.success && response.data.data) {
const works = response.data.data
// 只恢复文生视频类型的任务(包括 PROCESSING 和 PENDING 状态)
const textToVideoWorks = works.filter(work => work.workType === 'TEXT_TO_VIDEO')
if (textToVideoWorks.length > 0) {
// 取最新的一个任务
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
}
inProgress.value = true
taskStatus.value = work.status || 'PROCESSING'
taskProgress.value = 50 // 初始进度设为50%
console.log('恢复正在进行中的任务:', work.taskId, '状态:', work.status)
ElMessage.info('检测到未完成的任务,继续处理中...')
// 开始轮询任务状态
startPollingTask()
}
}
} catch (error) {
console.error('恢复任务失败:', error)
}
}
onMounted(() => {
loadHistory()
restoreProcessingTask()
})
// 组件卸载时清理资源
onUnmounted(() => {
// 停止轮询
@@ -973,12 +1251,65 @@ onUnmounted(() => {
box-shadow: none;
}
.login-tip {
margin-top: 12px;
text-align: center;
padding: 12px;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.login-tip p {
color: #9ca3af;
font-size: 13px;
margin: 0 0 8px 0;
}
.login-link-btn {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.login-link-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
/* 右侧面板 */
.right-panel {
background: #0a0a0a;
padding: 32px;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
/* 自定义右侧面板滚动条样式 */
.right-panel::-webkit-scrollbar {
width: 8px;
}
.right-panel::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 4px;
}
.right-panel::-webkit-scrollbar-thumb {
background: #3b82f6;
border-radius: 4px;
}
.right-panel::-webkit-scrollbar-thumb:hover {
background: #2563eb;
}
.preview-area {
@@ -986,6 +1317,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 20px;
min-height: min-content;
}
.status-checkbox {
@@ -1018,6 +1350,11 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
align-self: flex-start;
}
.preview-content:hover {
@@ -1025,15 +1362,21 @@ onUnmounted(() => {
}
.preview-placeholder {
text-align: center;
padding: 40px;
width: 100%;
min-height: 400px;
background: #1a1a1a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 40px;
}
.placeholder-text {
font-size: 18px;
color: #6b7280;
font-size: 20px;
color: #9ca3af;
font-weight: 500;
line-height: 1.5;
line-height: 1.6;
}
/* 响应式设计 */
@@ -1251,6 +1594,10 @@ onUnmounted(() => {
overflow: hidden;
padding: 20px;
box-sizing: border-box;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
}
/* 生成中状态 */
@@ -1530,4 +1877,207 @@ onUnmounted(() => {
margin-top: 15px;
text-align: center;
}
/* 历史记录区域 */
.history-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #2a2a2a;
}
.history-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.history-item {
background: transparent;
border: none;
padding: 0;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
}
.history-status-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.history-status-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: default;
accent-color: #3b82f6;
}
.history-status-checkbox label {
color: #e5e7eb;
font-size: 14px;
font-weight: 500;
cursor: default;
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.history-type {
color: #3b82f6;
font-size: 14px;
font-weight: 600;
}
.history-date {
color: #9ca3af;
font-size: 13px;
}
.history-prompt {
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
}
.history-preview {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
position: relative;
}
.history-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
gap: 12px;
}
.queue-text {
font-size: 18px;
font-weight: 600;
color: #fff;
}
.queue-link {
font-size: 14px;
color: #3b82f6;
cursor: pointer;
text-decoration: none;
}
.queue-link:hover {
color: #60a5fa;
text-decoration: underline;
}
.cancel-btn {
padding: 8px 24px;
background: transparent;
color: #fff;
border: 1px solid #fff;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
margin-top: 8px;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.no-result-text {
font-size: 14px;
color: #6b7280;
}
.history-video-thumbnail {
width: 100%;
height: 100%;
position: relative;
cursor: pointer;
overflow: hidden;
}
.history-video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
display: block;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.history-video-thumbnail:hover .play-overlay {
opacity: 1;
}
.play-icon {
width: 48px;
height: 48px;
background: rgba(59, 130, 246, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
padding-left: 4px;
}
.history-actions {
display: flex;
justify-content: flex-start;
margin-top: 0;
}
.similar-btn {
padding: 10px 20px;
background: #2a2a2a;
color: #e5e7eb;
border: 1px solid #3a3a3a;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.similar-btn:hover {
background: #3a3a3a;
border-color: #4a4a4a;
}
</style>