Files
AIGC/demo/frontend/src/views/MyWorks.vue
2026-01-06 14:33:01 +08:00

2792 lines
78 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="works-page">
<!-- 左侧导航栏 -->
<aside class="sidebar">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 导航菜单 -->
<nav class="nav-menu">
<div class="nav-item" @click="goToProfile">
<el-icon><User /></el-icon>
<span>{{ t('profile.title') }}</span>
</div>
<div class="nav-item" @click="goToSubscription">
<el-icon><Compass /></el-icon>
<span>{{ t('profile.subscription') }}</span>
</div>
<div class="nav-item active">
<el-icon><Document /></el-icon>
<span>{{ t('works.title') }}</span>
</div>
</nav>
<!-- 工具分隔线 -->
<div class="divider">
<span>{{ t('profile.tools') }}</span>
</div>
<!-- 工具菜单 -->
<nav class="tools-menu">
<div class="nav-item" @click="goToTextToVideo">
<el-icon><VideoPlay /></el-icon>
<span>{{ t('works.textToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item" @click="goToImageToVideo">
<el-icon><Picture /></el-icon>
<span>{{ t('works.imageToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>{{ t('works.storyboardVideo') }}</span>
<span class="badge-max">Max</span>
</div>
</nav>
</aside>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部栏 -->
<header class="top-header">
<div class="header-right">
<div class="points">
<div class="points-icon">
<el-icon><Star /></el-icon>
</div>
<span class="points-number">{{ userStore.availablePoints }}</span>
</div>
<LanguageSwitcher />
<div class="user-avatar" @click="showUserMenu = !showUserMenu" ref="userStatusRef">
<img src="/images/backgrounds/avatar-default.svg" :alt="t('dashboard.userAvatar')" />
</div>
</div>
</header>
<!-- 内容区域 -->
<div class="content-area" @scroll="handleScroll">
<div class="toolbar">
<div class="works-tab-bar">
<button
class="works-tab-item"
:class="{ active: activeTab === 'all' }"
@click="activeTab = 'all'"
>
{{ t('works.all') }}
</button>
<button
class="works-tab-item"
:class="{ active: activeTab === 'video' }"
@click="activeTab = 'video'"
>
{{ t('works.video') }}
</button>
<button
class="works-tab-item"
:class="{ active: activeTab === 'image' }"
@click="activeTab = 'image'"
>
{{ t('works.image') }}
</button>
</div>
</div>
<div class="filters-bar">
<div class="filters-left">
<el-select v-model="dateFilter" :placeholder="t('works.dateFilter')" size="small">
<el-option :label="t('works.today')" value="today" />
<el-option :label="t('works.thisWeek')" value="week" />
<el-option :label="t('works.thisMonth')" value="month" />
</el-select>
<el-select v-model="category" :placeholder="t('works.taskType')" size="small" @change="onFilterChange">
<el-option :label="t('works.all')" value="all" />
<el-option :label="t('works.textToVideo')" value="text2video" />
<el-option :label="t('works.imageToVideo')" value="image2video" />
<el-option :label="t('works.storyboardVideo')" value="storyboard" />
</el-select>
<el-select v-model="resolution" :placeholder="t('works.resolution')" clearable size="small">
<el-option :label="t('works.sd')" value="sd" />
<el-option :label="t('works.hd')" value="hd" />
</el-select>
<!-- 比例下拉9:16 / 16:9 / 1:1 -->
<el-select v-model="sortBy" :placeholder="t('works.ratio')" clearable size="small">
<el-option label="9:16" value="9:16" />
<el-option label="16:9" value="16:9" />
</el-select>
<el-button size="small" @click="resetFilters">{{ t('common.reset') }}</el-button>
</div>
<div class="filters-right">
<el-input
v-model="keyword"
:placeholder="t('works.searchPlaceholder')"
size="small"
clearable
style="width: 220px"
@keyup.enter.native="doSearch"
>
<template #prefix>
<el-icon class="search-icon" @click="doSearch"><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="select-row">
<el-checkbox :model-value="isAllSelected" @change="toggleSelectAll" size="small">{{ t('works.selectAll') }}</el-checkbox>
<template v-if="multiSelect && selectedIds.size">
<el-tag type="success" size="small">{{ t('works.selectedCount', { count: selectedIds.size }) }}</el-tag>
<el-button size="small" type="primary" @click="bulkDownload" plain>{{ t('video.download') }}</el-button>
<el-button size="small" type="danger" @click="bulkDelete" plain>{{ t('common.delete') }}</el-button>
</template>
</div>
<el-row :gutter="16" class="works-grid" justify="start">
<el-col v-for="item in filteredItems" :key="item.id" :xs="24" :sm="12" :md="8" :lg="6" :xl="4">
<el-card class="work-card" :class="{ selected: selectedIds.has(item.id) }" shadow="hover">
<div class="thumb" @click="multiSelect ? toggleSelect(item.id) : openDetail(item)">
<!-- 调试信息面板 - 如需调试改为 v-if="true" -->
<div class="debug-overlay" v-if="false" style="position: absolute; top: 0; left: 0; background: rgba(0,0,0,0.9); color: #0f0; font-size: 9px; padding: 4px; z-index: 100; max-width: 100%; word-break: break-all; line-height: 1.3;">
<div>ID: {{ item.id }}</div>
<div>type: {{ item.type }}</div>
<div>status: {{ item.status }}</div>
<div>hasResultUrl: {{ !!item.resultUrl }}</div>
<div>hasCover: {{ !!item.cover }}</div>
<div style="color: #ff0;">resultUrl: {{ item.resultUrl?.substring(item.resultUrl.lastIndexOf('/') + 1) }}</div>
<div style="color: #0ff;">cover: {{ item.cover?.substring(item.cover.lastIndexOf('/') + 1) }}</div>
</div>
<!-- 如果是视频类型且有视频URL使用video元素显示首帧 -->
<video
v-if="item.type === 'video' && item.resultUrl"
:src="item.resultUrl"
:data-cover="item.cover !== item.resultUrl ? item.cover : ''"
:poster="item.cover !== item.resultUrl ? item.cover : '/images/backgrounds/video-placeholder.jpg'"
class="work-thumbnail-video"
muted
preload="metadata"
@loadedmetadata="onVideoLoaded"
@error="onVideoError"
></video>
<!-- 如果有封面图使用图片懒加载 -->
<img
v-else-if="item.cover"
v-lazy:loading="item.cover"
:alt="item.title"
@error="onImageError"
/>
<!-- 否则使用默认占位符 -->
<div v-else class="work-placeholder" :class="{ 'is-processing': item.status === 'PROCESSING' || item.status === 'PENDING' }">
<el-icon><VideoPlay /></el-icon>
<div class="placeholder-text">{{ item.status === 'PROCESSING' ? t('works.processing') : (item.status === 'PENDING' ? t('works.queuing') : t('works.noPreview')) }}</div>
</div>
<!-- 生成中/排队中状态的覆盖层始终显示 -->
<div v-if="item.status === 'PROCESSING' || item.status === 'PENDING'" class="processing-overlay">
<div class="processing-content">
<el-icon class="processing-icon"><VideoPlay /></el-icon>
<div class="processing-text">{{ item.status === 'PROCESSING' ? t('works.processing') : t('works.queuing') }}</div>
<div class="progress-bar-container">
<div class="progress-bar-animated"></div>
</div>
</div>
</div>
<div class="checker" v-if="multiSelect">
<el-checkbox :model-value="selectedIds.has(item.id)" @change="() => toggleSelect(item.id)" />
</div>
<div class="actions" @click.stop>
<el-tooltip :content="t('works.favorite')" placement="top"><el-button circle size="small" text><el-icon><Star /></el-icon></el-button></el-tooltip>
<el-dropdown @command="(cmd)=>moreCommand(cmd,item)">
<el-button circle size="small" text><el-icon><MoreFilled /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<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>
</el-dropdown>
</div>
<!-- 鼠标悬停时显示的按钮区域 -->
<div class="hover-buttons-container">
<!-- 做同款按钮 - 左下角 -->
<div class="hover-create-btn left" @click.stop="createSimilar(item)">
<el-button type="primary" size="small" round>
<el-icon><VideoPlay /></el-icon>
{{ t('profile.createSimilar') }}
</el-button>
</div>
<!-- 生视频按钮 - 右下角仅分镜图显示 -->
<div v-if="item.category === '分镜图' || item.workType === 'STORYBOARD_IMAGE'" class="hover-create-btn right" @click.stop="goToGenerateVideo(item)">
<el-button type="success" size="small" round>
<el-icon><Film /></el-icon>
{{ t('video.storyboard.generateVideo') }}
</el-button>
</div>
</div>
</div>
<div class="meta">
<div class="title" :title="item.title">{{ item.title }}</div>
<div class="sub">
{{ item.date || t('profile.unknown') }} · {{ item.id }}
<span v-if="item.sizeText && item.sizeText !== '未知大小'"> · {{ item.sizeText }}</span>
</div>
</div>
<template #footer>
<el-space size="small">
<el-button text size="small" @click.stop="download(item)">{{ t('video.download') }}</el-button>
<el-button text size="small" type="danger" @click.stop="handleDeleteWork(item)">{{ t('common.delete') || '删除' }}</el-button>
</el-space>
</template>
</el-card>
</el-col>
</el-row>
<!-- 作品详情模态框 -->
<el-dialog
v-model="detailDialogVisible"
:title="selectedItem?.title"
width="70vw"
:before-close="handleClose"
class="work-detail-dialog"
:modal="true"
:show-close="false"
:close-on-click-modal="true"
:close-on-press-escape="true"
align-center
>
<!-- 自定义关闭按钮 -->
<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">
<el-icon class="error-icon" :size="48"><VideoCamera /></el-icon>
<h3>{{ t('works.videoLoadFailed') }}</h3>
<p>{{ t('works.videoFileNotExist') }}</p>
<div class="error-actions">
<el-button type="primary" @click="retryLoadVideo">
<el-icon><Refresh /></el-icon>
{{ t('works.retry') }}
</el-button>
<el-button type="danger" @click="deleteFailedWork">
<el-icon><Delete /></el-icon>
{{ t('works.deleteFailedWork') }}
</el-button>
</div>
</div>
</div>
<video
v-if="selectedItem.type === 'video'"
ref="detailVideoRef"
class="detail-video"
:src="selectedItem.resultUrl || selectedItem.cover"
:poster="selectedItem.cover !== selectedItem.resultUrl ? selectedItem.cover : ''"
controls
@error="onDetailVideoError"
@loadedmetadata="onDetailVideoLoaded"
>
{{ t('profile.browserNotSupport') }}
</video>
<img
v-else
class="detail-image"
:src="selectedItem.cover"
: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>
<div class="detail-right">
<!-- 用户信息头部 -->
<div class="detail-header">
<div class="user-info">
<div class="avatar">
<img src="/images/backgrounds/avatar-default.svg" :alt="t('dashboard.userAvatar')" class="avatar-image" />
</div>
<div class="username">{{ (selectedItem && selectedItem.username) || t('profile.anonymousUser') }}</div>
</div>
</div>
<!-- 详情标题行 -->
<div class="detail-title-row">
<h3>{{ t('works.videoDetail') }}</h3>
<span class="category-badge">{{ selectedItem.category }}</span>
</div>
<!-- 描述区域 -->
<div class="description-section">
<div class="section-header">
<span class="section-label">{{ t('works.description') }}</span>
</div>
<p class="description-text">
{{ getDescription(selectedItem) }}
<el-icon class="copy-icon" @click="copyPrompt" :title="t('common.copy')"><CopyDocument /></el-icon>
</p>
</div>
<!-- 元数据区域 -->
<div class="metadata-section">
<div class="metadata-item">
<span class="label">{{ t('profile.createTime') }}</span>
<span class="value">{{ selectedItem.createTime }}</span>
</div>
<div class="metadata-item">
<span class="label">{{ t('profile.workId') }}</span>
<span class="value">{{ selectedItem.taskId }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type !== 'image'">
<span class="label">{{ t('profile.duration') }}</span>
<span class="value">{{ formatDuration(selectedItem.duration) || '5s' }}</span>
</div>
<div class="metadata-item" v-if="selectedItem.type !== 'image'">
<span class="label">{{ t('profile.quality') }}</span>
<span class="value">{{ selectedItem.quality || '1080p' }}</span>
</div>
<div class="metadata-item">
<span class="label">{{ t('profile.aspectRatio') }}</span>
<span class="value">{{ selectedItem.aspectRatio || '16:9' }}</span>
</div>
</div>
</div>
</div>
</el-dialog>
<div class="loading-indicator" v-if="loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ t('common.loading') }}</span>
</div>
<div class="finished" v-if="!hasMore && filteredItems.length>0">
<span>{{ t('works.allLoaded') }}</span>
</div>
<el-empty v-if="!loading && filteredItems.length===0" :description="t('works.noContent')" />
<!-- 回到顶部按钮 -->
<transition name="fade">
<div v-show="showBackToTop" class="back-to-top" @click="scrollToTop" :title="t('works.backToTop')">
<el-icon><ArrowUp /></el-icon>
</div>
</transition>
</div>
</main>
</div>
<!-- 用户菜单下拉管理员功能 -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<template v-if="userStore.isAdmin">
<div class="menu-item" @click="goToDashboard">
<el-icon><User /></el-icon>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click="goToOrders">
<el-icon><Document /></el-icon>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="menu-item" @click="goToApiManagement">
<el-icon><Document /></el-icon>
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click="goToTaskRecord">
<el-icon><Document /></el-icon>
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<div class="menu-item" @click="logout">
<el-icon><User /></el-icon>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
</template>
<script setup>
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, CopyDocument, Download, Close, Setting, Lock, Warning } from '@element-plus/icons-vue'
import { getMyWorks, getWorkDetail, deleteWork, recordDownload, getWorkFileUrl } from '@/api/userWorks'
import { getCurrentUser } from '@/api/auth'
import { useUserStore } from '@/stores/user'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
// 用户菜单状态
const showUserMenu = ref(false)
const userStatusRef = ref(null)
// 计算用户菜单 Teleport 的位置
const menuStyle = computed(() => {
if (!userStatusRef.value || !showUserMenu.value) return {}
const rect = userStatusRef.value.getBoundingClientRect()
return {
position: 'fixed',
top: `${rect.bottom + 8}px`,
right: `${window.innerWidth - rect.right}px`,
zIndex: 99999
}
})
// 用户信息
const userInfo = ref({
username: '',
nickname: '',
bio: '',
avatar: '',
id: '',
points: 0,
frozenPoints: 0
})
const activeTab = ref('all')
const dateRange = ref([])
const dateFilter = ref(null)
const category = ref('all')
const resolution = ref(null)
const sortBy = ref(null)
const order = ref('desc')
const keyword = ref('')
const searchKeyword = ref('')
const multiSelect = ref(false)
const selectedIds = ref(new Set())
// 模态框相关状态
const detailDialogVisible = ref(false)
const selectedItem = ref(null)
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(100)
const loading = ref(false)
const hasMore = ref(true)
const items = ref([])
const showBackToTop = ref(false) // 回到顶部按钮显示状态
const failedUrls = ref(new Set()) // 记录加载失败的URL
const pollingIntervalId = ref(null) // 轮询定时器ID
const POLLING_INTERVAL = 120000 // 2分钟轮询间隔
// 处理URL确保相对路径正确
const processUrl = (url) => {
if (!url) return null
// data: 协议Base64 图片等)直接返回,避免被当成相对路径错误加前缀
if (url.startsWith('data:')) {
return url
}
// 如果已经是完整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)
const cover = thumbnailUrl || resultUrl || '/images/backgrounds/welcome.jpg'
// 调试日志查看URL处理结果
console.log(`转换作品 ${work.id}:`, {
原始resultUrl: work.resultUrl,
原始thumbnailUrl: work.thumbnailUrl,
处理后resultUrl: resultUrl,
处理后thumbnailUrl: thumbnailUrl,
最终cover: cover
})
return {
id: work.id?.toString() || work.taskId || '',
taskId: work.taskId || work.id?.toString() || '',
title: work.title || work.prompt || '未命名作品',
cover: cover,
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' ? '分镜视频' : work.workType === 'STORYBOARD_IMAGE' ? '分镜图' : '未知',
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',
uploadedImages: work.uploadedImages || null, // 分镜图阶段用户上传的参考图
videoReferenceImages: work.videoReferenceImages || null, // 视频阶段用户上传的参考图(分镜视频做同款需要)
imagePrompt: work.imagePrompt || null, // 分镜图优化后的提示词
videoPrompt: work.videoPrompt || null, // 视频优化后的提示词
workType: work.workType || '', // 原始作品类型
// overlayText 已移除,前端详情不再显示浮动文本
}
}
// 点击外部关闭用户菜单
const handleClickOutside = (event) => {
if (!userStatusRef.value) return
if (!userStatusRef.value.contains(event.target)) {
showUserMenu.value = false
}
}
// 管理员菜单导航
const goToDashboard = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/admin/dashboard')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToOrders = () => {
showUserMenu.value = false
router.push('/admin/orders')
}
const goToMembers = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/member-management')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToErrorStats = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/admin/error-statistics')
}
}
const goToSettings = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/system-settings')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToApiManagement = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/api-management')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToTaskRecord = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/generate-task-record')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
}
// 退出登录
const logout = async () => {
try {
showUserMenu.value = false
await userStore.logoutUser()
localStorage.removeItem('user')
localStorage.removeItem('token')
ElMessage.success(t('profile.logoutSuccess'))
router.push('/login')
} catch (error) {
console.error('退出登录失败:', error)
ElMessage.error(t('profile.logoutFailed'))
}
}
const loadList = async () => {
loading.value = true
try {
const response = await getMyWorks({
page: page.value - 1, // 后端使用0-based分页
size: pageSize.value
})
if (response.data.success) {
const data = response.data.data || []
// 调试日志: 查看原始数据
console.log('原始作品数据:', data)
data.forEach((work, index) => {
console.log(`作品 ${index}:`, {
id: work.id,
title: work.title || work.prompt,
status: work.status,
resultUrl: work.resultUrl,
thumbnailUrl: work.thumbnailUrl,
workType: work.workType
})
})
// 转换数据格式
const transformedData = data
.map(transformWorkData)
.filter(work => work.status !== 'FAILED' && work.status !== 'DELETED')
// 调试日志: 查看转换后的数据
console.log('转换后的作品数据:', transformedData)
if (page.value === 1) items.value = []
items.value = items.value.concat(transformedData)
hasMore.value = data.length === pageSize.value
// 检查是否有处理中的任务,如果有则启动轮询
checkAndStartPolling()
} else {
throw new Error(response.data.message || t('profile.loadWorksFailed'))
}
} catch (error) {
console.error('加载作品列表失败:', error)
ElMessage.error(t('profile.loadWorksFailed'))
} finally {
loading.value = false
}
}
// 检查是否有处理中的任务,如果有则启动轮询
const checkAndStartPolling = () => {
const hasProcessingTasks = items.value.some(
item => item.status === 'PROCESSING' || item.status === 'PENDING'
)
if (hasProcessingTasks && !pollingIntervalId.value) {
console.log('[MyWorks] 检测到处理中的任务启动2分钟轮询')
startPolling()
} else if (!hasProcessingTasks && pollingIntervalId.value) {
console.log('[MyWorks] 没有处理中的任务,停止轮询')
stopPolling()
}
}
// 启动轮询
const startPolling = () => {
if (pollingIntervalId.value) return // 避免重复启动
pollingIntervalId.value = setInterval(async () => {
console.log('[MyWorks] 执行轮询刷新...')
// 静默刷新不显示loading
try {
const response = await getMyWorks({
page: 0,
size: pageSize.value
})
if (response.data.success) {
const data = response.data.data || []
const transformedData = data
.map(transformWorkData)
.filter(work => work.status !== 'FAILED' && work.status !== 'DELETED')
// 更新列表
items.value = transformedData
// 检查是否还需要继续轮询
const hasProcessingTasks = transformedData.some(
item => item.status === 'PROCESSING' || item.status === 'PENDING'
)
if (!hasProcessingTasks) {
console.log('[MyWorks] 所有任务已完成,停止轮询')
stopPolling()
// 刷新用户积分
await userStore.fetchCurrentUser()
}
}
} catch (error) {
console.error('[MyWorks] 轮询刷新失败:', error)
}
}, POLLING_INTERVAL)
}
// 停止轮询
const stopPolling = () => {
if (pollingIntervalId.value) {
clearInterval(pollingIntervalId.value)
pollingIntervalId.value = null
console.log('[MyWorks] 轮询已停止')
}
}
// 筛选后的作品列表
const filteredItems = computed(() => {
let filtered = [...items.value]
// 过滤掉加载失败的作品(但保留 PROCESSING 和 PENDING 状态的作品)
filtered = filtered.filter(item => {
// PROCESSING 和 PENDING 状态的作品始终保留,不受 failedUrls 影响
if (item.status === 'PROCESSING' || item.status === 'PENDING') {
return true
}
const resultUrlFailed = item.resultUrl && failedUrls.value.has(item.resultUrl)
const coverFailed = item.cover && failedUrls.value.has(item.cover)
return !resultUrlFailed && !coverFailed
})
// 按状态筛选(默认只显示已完成的作品,除非用户选择查看全部)
// 注释掉状态过滤,允许显示所有作品包括处理中的
// filtered = filtered.filter(item => item.status === 'COMPLETED')
// 按日期筛选
if (dateFilter.value) {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
filtered = filtered.filter(item => {
if (!item.createdAt && !item.date) return false
// 获取作品创建日期
let itemDate
if (item.createdAt) {
itemDate = new Date(item.createdAt)
} else if (item.date) {
// 如果只有 date 字符串,尝试解析
itemDate = new Date(item.date)
}
if (!itemDate || isNaN(itemDate.getTime())) return false
// 重置时间为当天开始
const itemDay = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate())
if (dateFilter.value === 'today') {
// 今天:日期相同
return itemDay.getTime() === today.getTime()
} else if (dateFilter.value === 'week') {
// 本周过去7天内
const weekAgo = new Date(today)
weekAgo.setDate(weekAgo.getDate() - 7)
return itemDay >= weekAgo && itemDay <= today
} else if (dateFilter.value === 'month') {
// 本月过去30天内
const monthAgo = new Date(today)
monthAgo.setDate(monthAgo.getDate() - 30)
return itemDay >= monthAgo && itemDay <= today
}
return true
})
}
// 按类型筛选(全部/视频/图片)
if (activeTab.value === 'video') {
filtered = filtered.filter(item => item.type === 'video')
} else if (activeTab.value === 'image') {
filtered = filtered.filter(item => item.type === 'image')
}
// 按分类筛选
if (category.value !== 'all') {
const categoryMap = {
'text2video': '文生视频',
'image2video': '图生视频',
'storyboard': '分镜视频',
'reference': '参考图'
}
const targetCategory = categoryMap[category.value]
if (targetCategory) {
filtered = filtered.filter(item => item.category === targetCategory)
}
}
// 按清晰度筛选
if (resolution.value) {
filtered = filtered.filter(item => {
const itemQuality = (item.quality || '').toLowerCase()
const filterValue = resolution.value.toLowerCase()
// 映射关系sd=标清, hd=高清, uhd=超清
if (filterValue === 'sd') {
return itemQuality === 'sd' || itemQuality.includes('标清')
} else if (filterValue === 'hd') {
return itemQuality === 'hd' || itemQuality.includes('高清')
} else if (filterValue === 'uhd') {
return itemQuality === 'uhd' || itemQuality.includes('超清') || itemQuality.includes('4k')
}
return false
})
}
// 按宽高比筛选(使用 sortBy 作为比例过滤值)
if (sortBy.value) {
const targetRatio = (sortBy.value || '').trim()
filtered = filtered.filter(item => {
const ratio = (item.aspectRatio || '').trim()
return targetRatio && ratio === targetRatio
})
}
// 按关键词筛选(只有点击搜索或回车后才生效)
if (searchKeyword.value) {
const keywordLower = searchKeyword.value.toLowerCase()
filtered = filtered.filter(item =>
item.title.toLowerCase().includes(keywordLower) ||
item.id.includes(keywordLower)
)
}
return filtered
})
const reload = () => {
page.value = 1
hasMore.value = true
loadList()
}
// 执行搜索
const doSearch = () => {
searchKeyword.value = keyword.value
}
// 筛选变化时的处理
const onFilterChange = () => {
// 筛选是响应式的,不需要额外处理
console.log('筛选条件变化:', { category: category.value, activeTab: activeTab.value })
}
const loadMore = () => {
if (loading.value || !hasMore.value) return
page.value += 1
loadList()
}
// 滚动监听,触底自动加载更多,控制回到顶部按钮显示
const handleScroll = (event) => {
const target = event.target
const scrollTop = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.clientHeight
// 控制回到顶部按钮显示滚动超过300px时显示
showBackToTop.value = scrollTop > 300
// 当滚动到距离底部100px时自动加载更多
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
// 滚动到顶部
const scrollToTop = () => {
const contentArea = document.querySelector('.content-area')
if (contentArea) {
contentArea.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
const openDetail = async (item) => {
// 优先从后端拉取最新的详情数据,降级为传入的 item
try {
const resp = await getWorkDetail(item.id)
const payload = resp?.data?.data || resp?.data || null
if (payload) {
selectedItem.value = transformWorkData(payload)
} else {
selectedItem.value = item
}
} catch (err) {
console.warn('获取作品详情失败,使用已有数据:', err)
selectedItem.value = item
}
detailDialogVisible.value = true
}
// 获取作品提示词(优先使用 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
// 回退文案
return t('profile.noPrompt')
}
// 格式化清晰度显示
const formatQuality = (quality) => {
if (!quality) return ''
const q = quality.toUpperCase()
const qualityMap = {
'SD': t('works.sd'),
'HD': t('works.hd'),
'UHD': t('works.uhd'),
'4K': t('works.uhd')
}
return qualityMap[q] || q
}
// 格式化时长(支持数字秒或字符串),返回类似 "5s" 或原始字符串
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 handleClose = () => {
detailDialogVisible.value = false
selectedItem.value = null
activeDetailTab.value = 'detail'
detailVideoError.value = false
}
// 详情页视频加载成功
const onDetailVideoLoaded = (event) => {
console.log('✓ 详情页视频加载成功')
detailVideoError.value = false
}
// 详情页视频加载失败
const onDetailVideoError = (event) => {
console.error('❌ 详情页视频加载失败:', event.target?.src)
detailVideoError.value = true
// 详细诊断
const url = event.target?.src
if (url) {
console.log('🔍 开始诊断详情页视频加载失败原因...')
fetch(url, { method: 'HEAD' })
.then(response => {
console.log('📊 HTTP 响应状态:', response.status, response.statusText)
if (response.status === 403) {
console.error('🔒 403 Forbidden - OSS Bucket 权限问题!')
console.error('💡 解决方法:')
console.error(' 1. 检查 OSS Bucket 读取权限设置')
console.error(' 2. 后端需要生成签名 URL')
} else if (response.status === 404) {
console.error('❌ 404 Not Found - 文件不存在!')
console.error('💡 可能原因:')
console.error(' 1. OSS 设置了生命周期规则自动删除')
console.error(' 2. 文件被手动删除')
console.error(' 3. 检查 OSS 控制台是否还有该文件')
} else if (response.ok) {
console.log('✓ 文件存在但无法播放')
console.error('💡 可能原因CORS 配置或视频编码问题')
}
})
.catch(err => console.error('🌐 网络错误:', err.message))
}
}
// 重试加载视频
const retryLoadVideo = () => {
detailVideoError.value = false
if (detailVideoRef.value) {
detailVideoRef.value.load()
}
}
// 删除失败的作品
const deleteFailedWork = async () => {
if (!selectedItem.value) return
try {
await ElMessageBox.confirm(
t('works.deleteFailedWorkConfirm'),
t('works.deleteConfirmTitle'),
{
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
confirmButtonClass: 'el-button--danger'
}
)
// 执行删除
const itemId = selectedItem.value.id // 先保存 id因为 handleClose 会将 selectedItem 设为 null
console.log('删除作品:', itemId)
const response = await deleteWork(itemId)
if (response.data.success) {
ElMessage.success(t('works.deleteSuccess'))
// 从列表中移除该作品(在关闭详情页之前)
items.value = items.value.filter(item => item.id !== itemId)
// 关闭详情页
handleClose()
// 或者重新加载列表
// reload()
} else {
throw new Error(response.data.message || t('works.deleteFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除作品失败:', error)
ElMessage.error(error.message || t('works.deleteFailed'))
}
}
}
// 创建同款
const createSimilar = (item) => {
if (!item) {
ElMessage.info(t('works.goToCreate'))
return
}
// 根据作品类别跳转到对应的创建页面,并携带参数
const query = {
taskId: item.taskId,
prompt: item.prompt || '',
aspectRatio: item.aspectRatio || '',
duration: item.duration || '',
hdMode: item.quality === 'HD' ? 'true' : 'false'
}
// 添加参考图(图生视频需要,分镜图/分镜视频的 cover 是生成结果不传递到 referenceImage
if (item.category !== '分镜图' && item.category !== '分镜视频' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.referenceImage = item.cover
}
console.log('[做同款] 跳转参数:', query, 'category:', item.category, 'workType:', item.workType)
if (item.category === '文生视频') {
router.push({ path: '/text-to-video/create', query })
} else if (item.category === '图生视频') {
router.push({ path: '/image-to-video/create', query })
} else if (item.category === '分镜图' || item.workType === 'STORYBOARD_IMAGE') {
// 分镜图做同款:进入 Step 1生成分镜图使用 imagePrompt
query.step = 'image'
if (item.imagePrompt) {
query.imagePrompt = item.imagePrompt
}
// 传递分镜图阶段的参考图
if (item.uploadedImages) {
query.uploadedImages = item.uploadedImages
}
router.push({ path: '/storyboard-video/create', query })
} else if (item.category === '分镜视频') {
// 分镜视频做同款:进入 Step 2生成视频携带已生成的分镜图和视频参考图
query.step = 'video'
// cover 是分镜图thumbnailUrl传递给 Step 2 作为分镜图
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.storyboardImage = item.cover
}
// 传递视频阶段的参考图videoReferenceImages不是分镜图阶段的参考图
if (item.videoReferenceImages) {
query.videoReferenceImages = item.videoReferenceImages
}
router.push({ path: '/storyboard-video/create', query })
} else {
// 默认跳转到文生视频
router.push({ path: '/text-to-video/create', query })
}
ElMessage.success(t('works.createSimilarInfo', { title: item.title }))
}
// 生视频 - 使用分镜图直接生成视频
const goToGenerateVideo = (item) => {
if (!item) return
// 跳转到图生视频创作页面,传递分镜图作为参考图
const query = {
aspectRatio: item.aspectRatio || '',
duration: item.duration || '',
hdMode: item.quality === 'HD' ? 'true' : 'false'
}
// 使用分镜图作为参考图
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.referenceImage = item.cover
} else if (item.resultUrl) {
query.referenceImage = item.resultUrl
}
// 传递视频提示词:优先使用 videoPrompt其次 imagePrompt最后 prompt
if (item.videoPrompt) {
query.prompt = item.videoPrompt
} else if (item.imagePrompt) {
query.prompt = item.imagePrompt
} else if (item.prompt) {
query.prompt = item.prompt
}
console.log('[生视频] 跳转到图生视频页面,参数:', query)
router.push({ path: '/image-to-video/create', query })
ElMessage.success(t('works.readyToGenerateVideo'))
}
const download = async (item) => {
try {
// 检查是否有结果URL
if (!item.resultUrl) {
ElMessage.error(t('works.noDownloadUrl'))
return
}
ElMessage.info(t('works.downloadStart', { title: item.title }))
// 记录下载次数
try {
await recordDownload(item.id)
} catch (err) {
console.warn('记录下载次数失败:', err)
}
// 使用兼容 Safari 的下载工具
const videoUrl = item.resultUrl
console.log('下载URL:', videoUrl)
try {
const { downloadFile } = await import('@/utils/download')
const filename = `${item.title || 'work'}_${Date.now()}${item.type === 'video' ? '.mp4' : '.png'}`
const mimeType = item.type === 'video' ? 'video/mp4' : 'image/png'
const success = await downloadFile(videoUrl, filename, mimeType)
if (success) {
ElMessage.success(t('works.downloadComplete'))
return
}
} catch (directError) {
console.warn('直接下载失败,尝试后端代理:', directError)
}
// 备用方案:使用后端代理
const downloadUrl = getWorkFileUrl(item.id, true)
const token = localStorage.getItem('token')
console.log('开始下载:', downloadUrl)
// 使用 fetch 下载文件(后端代理模式)
const response = await fetch(downloadUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
})
console.log('响应状态:', response.status)
if (!response.ok) {
const errorText = await response.text()
console.error('下载失败:', errorText)
throw new Error(`下载失败: ${response.status}`)
}
// 获取文件内容
const blob = await response.blob()
console.log('文件大小:', blob.size, 'bytes')
if (blob.size === 0) {
throw new Error('文件内容为空可能URL已过期')
}
// 从响应头获取文件名
const contentDisposition = response.headers.get('content-disposition')
let filename = item.title || 'work'
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '').replace(/%20/g, ' '))
}
}
// 如果没有扩展名,根据类型添加
if (!filename.includes('.')) {
filename += item.type === 'video' ? '.mp4' : '.png'
}
console.log('下载文件名:', filename)
// 使用兼容工具下载 blob
const blobUrl = window.URL.createObjectURL(blob)
const { downloadFile } = await import('@/utils/download')
await downloadFile(blobUrl, filename, item.type === 'video' ? 'video/mp4' : 'image/png')
// 延迟释放 blob URL
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000)
ElMessage.success(t('works.downloadComplete'))
} catch (error) {
console.error('下载作品失败:', error)
ElMessage.error(error.message || t('works.downloadFailed'))
}
}
const moreCommand = async (cmd, item) => {
if (cmd === 'rename') {
ElMessage.info(t('works.renameDevMsg'))
} else if (cmd === 'delete') {
try {
await ElMessageBox.confirm(t('works.deleteWorkConfirm'), t('works.deleteConfirmTitle'), {
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel')
})
// 执行删除
const response = await deleteWork(item.id)
if (response.data.success) {
ElMessage.success(t('works.deleteSuccess'))
// 从列表中移除
items.value = items.value.filter(i => i.id !== item.id)
} else {
throw new Error(response.data.message || t('works.deleteFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除作品失败:', error)
ElMessage.error(error.message || t('works.deleteFailed'))
}
}
}
}
// 删除单个作品
const handleDeleteWork = async (item) => {
try {
await ElMessageBox.confirm(
t('works.deleteWorkConfirm') || `确定要删除作品"${item.title}"吗?`,
t('works.deleteConfirmTitle') || '删除确认',
{
type: 'warning',
confirmButtonText: t('common.delete') || '删除',
cancelButtonText: t('common.cancel') || '取消'
}
)
// 执行删除
const response = await deleteWork(item.id)
if (response.data.success) {
ElMessage.success(t('works.deleteSuccess') || '删除成功')
// 从列表中移除
items.value = items.value.filter(i => i.id !== item.id)
} else {
throw new Error(response.data.message || t('works.deleteFailed') || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除作品失败:', error)
ElMessage.error(error.message || t('works.deleteFailed') || '删除失败')
}
}
}
const toggleSelect = (id) => {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
// 全选/取消全选
const isAllSelected = computed(() => {
if (filteredItems.value.length === 0) return false
return filteredItems.value.every(item => selectedIds.value.has(item.id))
})
const toggleSelectAll = () => {
if (isAllSelected.value) {
// 取消全选
selectedIds.value = new Set()
multiSelect.value = false
} else {
// 全选
multiSelect.value = true
selectedIds.value = new Set(filteredItems.value.map(item => item.id))
}
}
const bulkDownload = async () => {
if (selectedIds.value.size === 0) {
ElMessage.warning(t('works.noItemsSelected'))
return
}
ElMessage.success(t('works.bulkDownloadStart', { count: selectedIds.value.size }))
// 获取选中的作品
const selectedItems = items.value.filter(item => selectedIds.value.has(item.id))
// 逐个下载(避免浏览器阻止弹窗)
for (const item of selectedItems) {
try {
await download(item)
// 延迟一下,避免浏览器阻止
await new Promise(resolve => setTimeout(resolve, 500))
} catch (error) {
console.error(`下载作品 ${item.id} 失败:`, error)
}
}
ElMessage.success(t('works.bulkDownloadComplete'))
}
const bulkDelete = async () => {
try {
await ElMessageBox.confirm(t('works.bulkDeleteConfirm', { count: selectedIds.value.size }), t('works.deleteConfirmTitle'), {
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel')
})
ElMessage.success(t('works.bulkDeleteSuccess'))
selectedIds.value = new Set()
} catch (_) {}
}
// 导航方法
const goToProfile = () => {
console.log('导航到个人主页')
router.push('/profile')
}
const goToSubscription = () => {
console.log('导航到会员订阅')
router.push('/subscription')
}
const goToTextToVideo = () => {
console.log('导航到文生视频创作')
router.push('/text-to-video/create')
}
const goToImageToVideo = () => {
console.log('导航到图生视频创作')
router.push('/image-to-video/create')
}
const goToStoryboardVideo = () => {
console.log('导航到分镜视频创作')
router.push('/storyboard-video/create')
}
// 重置筛选器
const resetFilters = () => {
dateFilter.value = null
category.value = 'all'
resolution.value = null
sortBy.value = null
keyword.value = ''
searchKeyword.value = ''
ElMessage.success(t('works.filtersReset'))
}
// 视频加载元数据后,跳转到第一帧(但不播放)
const onVideoLoaded = (event) => {
const video = event.target
console.log('✓ 视频加载成功:', video.src)
console.log('视频信息:', {
duration: video.duration,
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
readyState: video.readyState
})
if (video && video.duration) {
// 跳转到第一帧0秒
video.currentTime = 0.1
// 确保视频不播放
video.pause()
}
}
// 复制提示词
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
const url = video.src
console.error('❌ 视频加载失败:', url)
console.error('错误详情:', event)
// 记录失败的URL
failedUrls.value.add(url)
// 详细诊断 - 测试视频 URL 是否可访问
console.log('🔍 开始诊断视频加载失败原因...')
// 方法1: 使用 fetch 获取详细错误
fetch(url, { method: 'HEAD' })
.then(response => {
console.log('📊 HTTP 响应状态:', response.status, response.statusText)
console.log('📊 响应头:', [...response.headers.entries()])
if (response.status === 403) {
console.error('🔒 403 Forbidden - OSS Bucket 权限问题!')
console.error('💡 可能原因:')
console.error(' 1. Bucket 从"公共读"改为"私有"')
console.error(' 2. 需要使用签名 URL 访问')
console.error(' 3. IP 白名单限制')
} else if (response.status === 404) {
console.error('❌ 404 Not Found - 文件不存在!')
console.error('💡 可能原因:')
console.error(' 1. 文件被删除(手动或生命周期规则)')
console.error(' 2. 文件路径变化')
console.error(' 3. URL 格式错误')
} else if (response.ok) {
console.log('✓ HTTP 状态正常,可能是 CORS 或视频编码问题')
console.error('💡 可能原因:')
console.error(' 1. OSS CORS 配置缺失或错误')
console.error(' 2. 视频编码格式浏览器不支持')
}
})
.catch(err => {
console.error('🌐 网络错误:', err.message)
console.error('💡 可能原因:')
console.error(' 1. 网络连接问题')
console.error(' 2. DNS 解析失败')
console.error(' 3. 阿里云账户欠费')
})
// 方法2: 使用 Image 对象测试(绕过 CORS
const testImg = new Image()
testImg.onload = () => console.log('✓ URL 可访问(使用 Image 测试)')
testImg.onerror = () => console.error('✗ URL 不可访问(使用 Image 测试)')
testImg.src = url
// 隐藏video元素
if (video) {
video.style.display = 'none'
const thumbContainer = video.parentElement
const existingImg = thumbContainer.querySelector('.fallback-cover-image')
const placeholder = thumbContainer.querySelector('.work-placeholder')
const coverUrl = video.getAttribute('data-cover')
// 如果有独立的封面图不同于视频URL尝试显示封面
if (coverUrl && coverUrl !== url && coverUrl !== '' && !existingImg) {
console.log('🔄 视频加载失败,尝试使用独立封面图:', coverUrl)
const img = document.createElement('img')
img.className = 'fallback-cover-image work-thumbnail-video'
img.src = coverUrl
img.alt = '封面图'
img.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;'
img.onerror = () => {
console.error('❌ 封面图也加载失败:', coverUrl)
img.style.display = 'none'
showPlaceholder(thumbContainer, placeholder)
}
thumbContainer.appendChild(img)
} else {
// 没有独立封面,直接显示占位符
console.warn('⚠️ 没有独立封面图,显示占位符')
showPlaceholder(thumbContainer, placeholder)
}
}
}
// 显示占位符的辅助函数
const showPlaceholder = (container, existingPlaceholder) => {
if (!existingPlaceholder) {
const div = document.createElement('div')
div.className = 'work-placeholder'
div.innerHTML = `
<svg class="el-icon" style="width: 1em; height: 1em; font-size: 32px; margin-bottom: 8px;">
<use href="#icon-video-play"></use>
</svg>
<div class="placeholder-text" style="font-size: 13px; color: #999;">视频加载失败</div>
<div class="placeholder-text" style="font-size: 11px; color: #666; margin-top: 4px;">文件可能不存在</div>
`
div.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; flex-direction: column; align-items: center; justify-content: center;'
container.appendChild(div)
} else {
existingPlaceholder.style.display = 'flex'
}
}
// 图片加载失败处理
const onImageError = (event) => {
const img = event.target
const url = img.src
console.error('❌ 图片加载失败:', url)
console.error('错误详情:', event)
// 记录失败的URL
failedUrls.value.add(url)
// 测试图片 URL 是否可访问 - 使用正常 GET 请求检查
fetch(url, { method: 'GET' })
.then(response => {
if (response.ok) {
console.log('✓ 图片文件存在但浏览器无法加载,可能是 CORS 或格式问题')
} else {
console.error(`✗ HTTP ${response.status}: ${response.statusText} - 文件不存在或无权限`)
}
})
.catch(err => {
console.error('✗ 网络错误或文件不存在:', err.message)
})
// 隐藏图片,显示占位符
if (img) {
img.style.display = 'none'
// 创建或显示占位符(包含完整的图标和文字)
const placeholder = img.parentElement.querySelector('.work-placeholder')
if (!placeholder) {
const div = document.createElement('div')
div.className = 'work-placeholder'
div.innerHTML = `
<svg class="el-icon" style="width: 1em; height: 1em; font-size: 24px;">
<use href="#icon-video-play"></use>
</svg>
<div class="placeholder-text">加载失败</div>
`
img.parentElement.appendChild(div)
} else {
placeholder.style.display = 'flex'
}
}
}
// 加载用户信息
const loadUserInfo = async () => {
try {
const response = await getCurrentUser()
console.log('获取用户信息响应:', response)
if (response && response.data && response.data.success && response.data.data) {
const user = response.data.data
console.log('用户数据:', user)
userInfo.value = {
username: user.username || '',
nickname: user.nickname || user.username || '',
bio: user.bio || '',
avatar: user.avatar || '',
id: user.id ? String(user.id) : '',
points: user.points || 0,
frozenPoints: user.frozenPoints || 0
}
console.log('设置后的用户信息:', userInfo.value)
} else {
console.error('获取用户信息失败:', response?.data?.message || '未知错误')
ElMessage.error(t('profile.loadUserInfoFailed'))
}
} catch (error) {
// 401错误由axios拦截器处理不重复提示
if (error.response?.status === 401) {
return
}
console.error('加载用户信息失败:', error)
ElMessage.error(t('profile.loadUserInfoFailed') + ': ' + (error.message || '未知错误'))
}
}
onMounted(async () => {
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
loadUserInfo()
loadList()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
// 清理轮询定时器
stopPolling()
})
// 当页面被激活时(从其他页面返回时)刷新列表
onActivated(() => {
// 重置分页并重新加载
page.value = 1
items.value = []
loadUserInfo()
loadList()
})
</script>
<style scoped>
/* 图片懒加载样式 */
.lazy-loading {
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
background-size: 200% 100%;
animation: lazy-shimmer 1.5s infinite;
}
.lazy-loaded {
animation: lazy-fade-in 0.3s ease-in;
}
.lazy-error {
background: #1a1a1a;
}
@keyframes lazy-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes lazy-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.works-page {
height: 100vh;
background: #0a0a0a;
color: white;
display: flex;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
width: 100%;
position: relative;
overflow: hidden;
}
/* 页面特殊效果 */
.works-page::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 10% 20%, rgba(64, 158, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 90% 80%, rgba(103, 194, 58, 0.1) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, rgba(230, 162, 60, 0.05) 0%, transparent 50%);
animation: profileGlow 6s ease-in-out infinite alternate;
pointer-events: none;
z-index: 1;
}
@keyframes profileGlow {
0% { opacity: 0.3; }
100% { opacity: 0.6; }
}
.works-page > * {
position: relative;
z-index: 2;
}
/* 左侧导航栏 */
.sidebar {
width: 280px !important;
background: #000000 !important;
padding: 24px 0 !important;
border-right: 1px solid #1a1a1a !important;
flex-shrink: 0 !important;
z-index: 100 !important;
display: block !important;
position: relative !important;
}
.logo {
padding: 0 24px 32px;
display: flex;
align-items: center;
justify-content: center;
}
.logo img {
height: 40px;
width: auto;
}
.nav-menu, .tools-menu {
padding: 0 24px;
}
.nav-item {
display: flex;
align-items: center;
padding: 14px 18px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.nav-item:hover {
background: #2a2a2a;
}
.nav-item.active {
background: #1e3a8a;
}
.nav-item .el-icon {
margin-right: 14px;
font-size: 20px;
}
.nav-item span {
font-size: 15px;
flex: 1;
}
.sora-tag {
margin-left: 8px;
font-size: 10px;
padding: 2px 6px;
}
.divider {
margin: 30px 20px 20px;
padding: 0 16px;
color: #666;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
}
/* 主内容区域 */
.main-content {
flex: 1;
padding: 0;
}
/* 顶部导航栏 */
.top-header {
height: 80px;
padding: 0 30px;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: flex-end;
background: #0a0a0a;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.points {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(64, 158, 255, 0.1);
border-radius: 20px;
border: 1px solid rgba(64, 158, 255, 0.3);
}
.points-icon {
width: 20px;
height: 20px;
background: #409EFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
}
.points-number {
color: #409EFF;
font-size: 14px;
font-weight: 600;
}
.user-avatar {
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
transition: transform 0.3s ease;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-avatar:hover {
transform: scale(1.05);
}
/* 用户菜单样式(右上角头像下拉) */
.user-menu-teleport {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 160px;
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
color: white;
font-size: 14px;
}
.menu-item:hover {
background: #2a2a2a;
}
.menu-item .el-icon {
margin-right: 8px;
font-size: 16px;
}
.menu-item:not(:last-child) {
border-bottom: 1px solid #333;
}
.settings-icon {
cursor: pointer;
color: #9ca3af;
font-size: 20px;
}
.settings-icon:hover {
color: white;
}
/* 内容区域 */
.content-area {
flex: 1;
padding: 20px 24px;
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth; /* 平滑滚动 */
}
/* 自定义滚动条样式 - 更明显美观的滚动条 */
.content-area::-webkit-scrollbar {
width: 12px;
}
.content-area::-webkit-scrollbar-track {
background: rgba(26, 26, 26, 0.5);
border-radius: 6px;
margin: 4px 0;
}
.content-area::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
border-radius: 6px;
transition: all 0.3s ease;
border: 2px solid rgba(26, 26, 26, 0.5);
}
.content-area::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
border-color: rgba(26, 26, 26, 0.3);
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0 4px;
}
.works-tab-bar {
display: inline-flex;
align-items: center;
gap: 8px;
}
.works-tab-item {
width: 163px;
height: 44px;
border-radius: 12px;
border: none;
background: #1a1c20; /* 未选中:略深一点 */
color: #cbd5e1;
font-size: 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 16px;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.1s ease;
outline: none;
white-space: nowrap;
}
.works-tab-item:hover {
background: #25272c;
color: #e5e7eb;
}
.works-tab-item.active {
background: #313338; /* 选中态,匹配你的 SVG 颜色 */
color: #ffffff;
}
.works-tab-item:active {
transform: scale(0.98);
}
.filters { margin-left: 10px; }
.filters-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #333;
margin-bottom: 16px;
background: #1a1a1a;
}
.filters-left {
display: flex;
align-items: center;
gap: 8px;
}
/* 下拉框固定宽度 104px匹配设计稿 */
.filters-left :deep(.el-select) {
width: 104px;
}
.filters-right {
display: flex;
align-items: center;
}
.search-icon {
cursor: pointer;
transition: color 0.2s;
}
.search-icon:hover {
color: #409eff;
}
/* 筛选行:下拉框与重置按钮统一样式 - 匹配设计稿 */
.filters-bar :deep(.el-select__wrapper),
.filters-bar :deep(.el-button) {
height: 32px;
padding: 0 16px;
border-radius: 8px;
background-color: rgba(255, 255, 255, 0.1);
border: none;
box-shadow: none;
color: #ffffff;
transition: background-color 0.2s ease;
}
.filters-bar :deep(.el-select__wrapper:hover),
.filters-bar :deep(.el-select__wrapper.is-focused),
.filters-bar :deep(.el-button:hover),
.filters-bar :deep(.el-button:focus) {
background-color: rgba(255, 255, 255, 0.15);
border: none;
box-shadow: none;
}
/* 下拉框文字样式 */
.filters-bar :deep(.el-select__placeholder),
.filters-bar :deep(.el-select__selected-item),
.filters-bar :deep(.el-input__inner) {
color: #ffffff !important;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0;
}
.filters-bar :deep(.el-icon) {
color: #ffffff !important;
font-size: 12px;
}
/* 确保 placeholder 可见 */
.filters-bar :deep(.el-select__placeholder) {
opacity: 1 !important;
}
/* 搜索框深色样式 */
.filters-bar :deep(.el-input__wrapper) {
background-color: rgba(255, 255, 255, 0.1) !important;
border: none !important;
box-shadow: none !important;
}
.filters-bar :deep(.el-input__inner) {
color: #ffffff !important;
}
.filters-bar :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.6) !important;
}
.select-row { padding: 4px 0 8px; }
.works-grid {
margin-top: 12px;
}
.work-card {
margin-bottom: 14px;
width: 100%;
}
.thumb {
position: relative;
width: 100%;
padding-top: 100%;
overflow: hidden;
border-radius: 6px;
cursor: pointer;
aspect-ratio: 1 / 1;
}
.thumb img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
.work-thumbnail-video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.work-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
font-size: 24px;
gap: 8px;
}
.placeholder-text {
font-size: 12px;
color: #666;
}
/* 动态进度条 */
.progress-bar-container {
width: 60%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 12px;
}
.progress-bar-animated {
width: 30%;
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
border-radius: 2px;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
@keyframes progress-move {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(233%);
}
100% {
transform: translateX(-100%);
}
}
@keyframes progress-gradient {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
/* 生成中/排队中状态覆盖层 */
.processing-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
border-radius: 8px;
}
.processing-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px;
}
.processing-icon {
font-size: 32px;
color: #409eff;
}
.processing-text {
font-size: 14px;
color: #fff;
font-weight: 500;
}
.work-placeholder.is-processing {
background: rgba(0, 0, 0, 0.5);
}
.checker { position: absolute; left: 6px; top: 6px; }
.actions { position: absolute; right: 6px; top: 6px; display: flex; gap: 4px; opacity: 0; transition: opacity .2s ease; }
.thumb:hover .actions { opacity: 1; }
/* 鼠标悬停时显示的按钮容器 */
.hover-buttons-container {
position: absolute;
left: 0;
right: 0;
bottom: 6px;
display: flex;
justify-content: space-between;
padding: 0 6px;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
}
.thumb:hover .hover-buttons-container {
opacity: 1;
transform: translateY(0);
}
/* 鼠标悬停时显示的做同款按钮 */
.hover-create-btn {
transition: all 0.3s ease;
}
.hover-create-btn.left {
/* 左下角 */
}
.hover-create-btn.right {
/* 右下角 */
}
.thumb:hover .hover-create-btn {
opacity: 1;
transform: translateY(0);
}
.hover-create-btn .el-button {
background: rgba(64, 158, 255, 0.9);
border: none;
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.hover-create-btn .el-button:hover {
background: rgba(64, 158, 255, 1);
transform: scale(1.05);
}
/* 生视频按钮样式 */
.hover-create-btn.right .el-button {
background: rgba(103, 194, 58, 0.9);
box-shadow: 0 4px 12px rgba(103, 194, 58, 0.3);
}
.hover-create-btn.right .el-button:hover {
background: rgba(103, 194, 58, 1);
}
.work-card.selected .thumb::after {
content: '';
position: absolute;
inset: 0;
border: 2px solid #409eff;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(64,158,255,0.15) inset;
}
.meta { margin-top: 10px; }
.title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sub {
color: #909399;
font-size: 12px;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
/* 清晰度标签样式 */
.quality-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
margin: 0 2px;
}
.quality-sd {
background: rgba(103, 194, 58, 0.15);
color: #67c23a;
border: 1px solid rgba(103, 194, 58, 0.3);
}
.quality-hd {
background: rgba(64, 158, 255, 0.15);
color: #409eff;
border: 1px solid rgba(64, 158, 255, 0.3);
}
.quality-uhd, .quality-4k {
background: rgba(230, 162, 60, 0.15);
color: #e6a23c;
border: 1px solid rgba(230, 162, 60, 0.3);
}
.loading-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 20px 0;
margin: 20px 0;
color: #409EFF;
font-size: 14px;
}
.loading-indicator .el-icon {
font-size: 18px;
}
/* 让卡片与页面背景一致 */
:deep(.work-card.el-card) {
background-color: #0a0a0a;
border-color: #1f2937;
color: #e5e7eb;
}
:deep(.work-card .el-card__body) {
background-color: #0a0a0a;
}
:deep(.work-card .el-card__footer) {
background-color: #0a0a0a;
border-top: 1px solid #1f2937;
}
/* 模态框样式 */
:deep(.work-detail-dialog) {
--el-dialog-margin-top: 5vh;
--el-dialog-bg-color: transparent;
--el-dialog-border-radius: 12px;
}
:deep(.work-detail-dialog .el-dialog) {
background: transparent !important;
box-shadow: none !important;
border: none !important;
margin: 0 auto;
max-width: 95vw;
}
:deep(.work-detail-dialog .el-dialog__header) {
display: none !important;
}
:deep(.work-detail-dialog .el-dialog__body) {
padding: 0 !important;
background: transparent !important;
position: relative;
}
/* 遮罩层样式 */
:deep(.el-overlay) {
background-color: rgba(0, 0, 0, 0.85) !important;
backdrop-filter: blur(8px);
}
/* 自定义关闭按钮 */
.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;
}
.dialog-close-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.detail-content {
display: flex;
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: 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;
}
.detail-video, .detail-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.detail-right {
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;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
border: 2px solid #333;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.username {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.detail-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.detail-title-row h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
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: 6px;
}
.section-label {
font-size: 13px;
color: #888;
}
.description-text {
font-size: 13px;
line-height: 1.6;
color: #ccc;
margin: 0;
}
.copy-icon {
margin-left: 6px;
cursor: pointer;
font-size: 14px;
color: #666;
vertical-align: middle;
}
.copy-icon:hover {
color: #409eff;
}
.metadata-section {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: auto;
}
.metadata-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.metadata-item .label {
font-size: 13px;
color: #888;
}
.metadata-item .value {
font-size: 13px;
color: #fff;
}
.action-section {
padding-top: 12px;
}
.create-similar-btn {
width: 100%;
background: #409eff;
color: #fff;
border: none;
border-radius: 8px;
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: #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);
}
/* 回到顶部按钮样式 */
.back-to-top {
position: fixed;
bottom: 40px;
right: 40px;
width: 48px;
height: 48px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
transition: all 0.3s ease;
z-index: 999;
}
.back-to-top:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
}
.back-to-top:active {
transform: translateY(-2px);
}
.back-to-top .el-icon {
font-size: 24px;
color: white;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 优化加载完成提示样式 */
.finished {
text-align: center;
color: #409eff;
margin: 20px 0;
font-size: 14px;
padding: 12px;
background: rgba(64, 158, 255, 0.1);
border-radius: 8px;
border: 1px solid rgba(64, 158, 255, 0.2);
}
</style>
<!-- 全局下拉框深色样式弹出层传送到body需要非scoped样式 -->
<style>
.el-select-dropdown {
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
}
.el-select-dropdown__item {
color: #e5e7eb !important;
}
.el-select-dropdown__item:hover,
.el-select-dropdown__item.hover {
background-color: #2a2a2a !important;
}
.el-select-dropdown__item.is-selected {
color: #409eff !important;
background-color: rgba(64, 158, 255, 0.1) !important;
}
.el-popper.is-light {
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
}
.el-popper.is-light .el-popper__arrow::before {
background-color: #1a1a1a !important;
border-color: #333 !important;
}
/* Sora2.0 SVG 风格标签 */
.badge-pro, .badge-max {
font-size: 9px;
padding: 0 3px;
border-radius: 2px;
font-weight: 500;
margin-left: 6px;
background: rgba(62, 163, 255, 0.2);
color: #5AE0FF;
flex: 0 0 auto !important;
width: auto !important;
}
.badge-max {
background: rgba(255, 100, 150, 0.2);
color: #FF7EB3;
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->
<style>
@keyframes progress-move {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(233%);
}
100% {
transform: translateX(-100%);
}
}
@keyframes progress-gradient {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
</style>