2277 lines
63 KiB
Vue
2277 lines
63 KiB
Vue
<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
|
||
}
|
||
// 如果已经是完整URL(http/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>
|
||
|
||
|