Files
AIGC/demo/frontend/src/views/MyWorks.vue

2277 lines
63 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" 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>
</div>
<div class="nav-item" @click="goToImageToVideo">
<el-icon><Picture /></el-icon>
<span>{{ t('works.imageToVideo') }}</span>
</div>
<div class="nav-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>{{ t('works.storyboardVideo') }}</span>
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
</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="reload"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<div class="select-row">
<el-checkbox v-model="multiSelect" size="small">{{ t('works.selectItems', { count: selectedIds.size || 6 }) }}</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"
:src="item.cover"
:alt="item.title"
@error="onImageError"
/>
<!-- 否则使用默认占位符 -->
<div v-else class="work-placeholder">
<el-icon><VideoPlay /></el-icon>
<div class="placeholder-text">{{ item.status === 'PROCESSING' ? t('works.processing') : t('works.noPreview') }}</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-create-btn" @click.stop="createSimilar(item)">
<el-button type="primary" size="small" round>
<el-icon><VideoPlay /></el-icon>
{{ t('profile.createSimilar') }}
</el-button>
</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.quality" class="quality-badge" :class="`quality-${(item.quality || '').toLowerCase()}`">
{{ formatQuality(item.quality) }}
</span>
· {{ item.sizeText }}
</div>
</div>
<template #footer>
<el-space size="small">
<el-button text size="small" @click.stop="download(item)">{{ t('video.download') }}</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">
<span class="label">{{ t('profile.duration') }}</span>
<span class="value">{{ formatDuration(selectedItem.duration) || '5s' }}</span>
</div>
<div class="metadata-item">
<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 class="action-section">
<button class="create-similar-btn full-width" @click="createSimilar(selectedItem)">
{{ t('works.createSimilar') }}
</button>
</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>
</template>
<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 } 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 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(20)
const loading = ref(false)
const hasMore = ref(true)
const items = ref([])
const showBackToTop = ref(false) // 回到顶部按钮显示状态
const failedUrls = ref(new Set()) // 记录加载失败的URL
// 处理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',
// 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 goToSettings = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/system-settings')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
// 退出登录
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)
// 调试日志: 查看转换后的数据
console.log('转换后的作品数据:', transformedData)
if (page.value === 1) items.value = []
items.value = items.value.concat(transformedData)
hasMore.value = data.length === pageSize.value
} 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 filteredItems = computed(() => {
let filtered = [...items.value]
// 过滤掉加载失败的作品URL失效的作品自动隐藏
filtered = filtered.filter(item => {
// 检查 resultUrl 和 cover 是否在失败集合中
const resultUrlFailed = item.resultUrl && failedUrls.value.has(item.resultUrl)
const coverFailed = item.cover && failedUrls.value.has(item.cover)
// 如果任一URL失败则不显示该作品
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 (keyword.value) {
const keywordLower = keyword.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 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'
}
)
// 执行删除
console.log('删除作品:', selectedItem.value.id)
const response = await deleteWork(selectedItem.value.id)
if (response.data.success) {
ElMessage.success(t('works.deleteSuccess'))
// 关闭详情页
handleClose()
// 从列表中移除该作品
items.value = items.value.filter(item => item.id !== selectedItem.value.id)
// 或者重新加载列表
// 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.createSimilarInfo', { title: item.title }))
// 根据作品类型跳转到相应的创建页面
if (item.type === 'video') {
router.push('/text-to-video/create')
} else if (item.type === 'image') {
router.push('/image-to-video/create')
} else {
router.push('/text-to-video/create')
}
} else {
ElMessage.info(t('works.goToCreate'))
}
}
const download = async (item) => {
try {
// 检查是否有结果URL
if (!item.resultUrl) {
ElMessage.error(t('works.noDownloadUrl'))
return
}
ElMessage.success(t('works.downloadStart', { title: item.title }))
// 记录下载次数
try {
await recordDownload(item.id)
} catch (err) {
console.warn('记录下载次数失败:', err)
}
// 构建下载URL使用代理下载模式download=true避免 CORS 问题
const downloadUrl = getWorkFileUrl(item.id, true)
const token = sessionStorage.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已过期')
}
// 创建 blob URL
const blobUrl = window.URL.createObjectURL(blob)
// 从响应头获取文件名
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)
// 创建下载链接并触发下载
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
// 延迟释放 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 toggleSelect = (id) => {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
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 = ''
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) {
console.error('加载用户信息失败:', error)
ElMessage.error(t('profile.loadUserInfoFailed') + ': ' + (error.message || '未知错误'))
}
}
onMounted(() => {
loadUserInfo()
loadList()
})
// 注册/注销全局点击监听用于关闭菜单
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 当页面被激活时(从其他页面返回时)刷新列表
onActivated(() => {
// 重置分页并重新加载
page.value = 1
items.value = []
loadUserInfo()
loadList()
})
</script>
<style scoped>
.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;
}
/* 筛选行:下拉框与重置按钮统一样式 - 匹配设计稿 */
.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;
}
.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;
}
.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-create-btn {
position: absolute;
right: 6px;
bottom: 6px;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
}
.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);
}
.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>